├── .dockerignore ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .prettierrc ├── .vscode └── settings.json ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yml ├── package-lock.json ├── package.json ├── readme └── phaser-with-nodejs.png ├── src ├── client │ ├── assets │ │ ├── box.png │ │ ├── bug.png │ │ ├── controls.png │ │ ├── dude.png │ │ ├── fullscreen.png │ │ ├── mummy37x45.png │ │ ├── star.png │ │ └── starfield.jpg │ ├── components │ │ ├── animations.ts │ │ ├── controls.ts │ │ ├── cursors.ts │ │ ├── fullscreenButton.ts │ │ ├── fullscreenEvent.ts │ │ ├── resize.ts │ │ └── texts.ts │ ├── config.ts │ ├── game.ts │ ├── index.html │ ├── index.ts │ └── scenes │ │ ├── mainScene.ts │ │ ├── menuScene.ts │ │ └── preloadScene.ts ├── constants.ts ├── physics │ ├── game.ts │ ├── index.html │ └── index.ts ├── server │ ├── game │ │ ├── arcadeObjects │ │ │ ├── box.ts │ │ │ ├── dude.ts │ │ │ ├── map.ts │ │ │ ├── mummy.ts │ │ │ └── star.ts │ │ ├── config.ts │ │ ├── game.ts │ │ ├── matterObjects │ │ │ ├── box.ts │ │ │ ├── dude.ts │ │ │ ├── matterGameObject.ts │ │ │ ├── matterGameObjectGroup.ts │ │ │ └── star.ts │ │ └── scenes │ │ │ ├── arcadeScene.ts │ │ │ └── matterScene.ts │ ├── managers │ │ ├── roomManager.ts │ │ └── syncManager.ts │ ├── routes │ │ └── routes.ts │ ├── server.ts │ └── socket │ │ ├── ioGame.ts │ │ └── ioStats.ts └── stats │ ├── index.html │ └── index.ts ├── tsconfig.json ├── typings └── custom.d.ts └── webpack ├── webpack.client.cjs ├── webpack.client.prod.cjs ├── webpack.physics.cjs ├── webpack.physics.prod.cjs ├── webpack.server.cjs ├── webpack.stats.cjs └── webpack.stats.prod.cjs /.dockerignore: -------------------------------------------------------------------------------- 1 | /* 2 | !/dist 3 | !package*.json -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # read: https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions 2 | 3 | name: CI 4 | 5 | on: [push, pull_request] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | strategy: 12 | matrix: 13 | node-version: [16.x, 18.x, 19.x] 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v3 18 | 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | 24 | - name: Install Dependencies 25 | run: npm install 26 | 27 | - name: Build Packages 28 | run: npm run build 29 | 30 | # - name: Run Tests 31 | # run: npm test 32 | 33 | # - name: Run Prettier 34 | # run: npm run format 35 | 36 | # - name: Run ESLint 37 | # run: npm run lint 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "semi": false, 4 | "singleQuote": true, 5 | "trailingComma": "none" 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "git.enabled": true 3 | } 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:10 2 | 3 | WORKDIR /usr/src/app 4 | 5 | # https://www.npmjs.com/package/canvas 6 | RUN apt-get update -y && apt-get install -y build-essential libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev 7 | 8 | COPY package*.json ./ 9 | COPY dist/ dist/ 10 | 11 | EXPOSE 3000 12 | 13 | CMD [ "npm", "run", "docker:start" ] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Yannick Deubel (https://github.com/yandeu); Project Url: https://github.com/yandeu/phaser3-multiplayer-with-physics 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |
3 | header 4 |
5 | Phaser 3 - Real-Time Multiplayer example with Physics 6 |
7 |

8 | 9 |

10 | A Real-Time Multiplayer example using Phaser 3 with Physics (MatterJS and Arcade) on a NodeJS server with Express and Socket.io

11 | 12 |

13 | 14 | 15 | 16 | GitHub package.json version 17 | 18 | 19 | 20 | GitHub last commit 21 | 22 |

23 | 24 | --- 25 | 26 | ## ⚠️ Note 27 | 28 | This example is using [`@geckos.io/phaser-on-nodejs`](https://github.com/geckosio/phaser-on-nodejs#readme) which is very slow tedious. If you need to run Phaser's Arcade Physics on the Server, I recommend using [`arcade-physics`](https://github.com/yandeu/arcade-physics#readme). I will not update this example anymore. 29 | 30 | ## Play It 31 | 32 | _This example is running on NodeJS on Heroku (**Free Dyno** in **Europe**) which causes the example sometimes to take **about 1 minute to load**._ 33 | 34 | - Play it here - [phaser3-multiplayer-example](http://phaser3-multiplayer-example.herokuapp.com/) 35 | _It works best if your latency is below 100ms, which should the case if you are located in Europe._ 36 | 37 | ## Key Features 38 | 39 | - The physics is entirely calculated on the Server 40 | - Automatically manages Rooms (new Phaser instances) 41 | - Physics debugging version 42 | - A nice Stats page 43 | 44 | ## Geckos.io 45 | 46 | Why does this game example not use geckos.io? 47 | Well there are two reasons: 48 | 49 | - Geckos.io did not exist back then. 50 | - This example is deployed on heroku, which does not allow to forward UDP ports, geckos.io depends on. 51 | 52 | ## Video 53 | 54 | Watch it on YouTube 55 | 56 | [![thumbnail](https://i.ytimg.com/vi/n8gJQEfA18s/hqdefault.jpg?sqp=-oaymwEZCNACELwBSFXyq4qpAwsIARUAAIhCGAFwAQ==&rs=AOn4CLCpxKgRIHTOZICjxwhdKSrtsIrOJw)](https://youtu.be/n8gJQEfA18s) 57 | 58 | ## Matter Physics vs Arcade Physics 59 | 60 | This example includes 2 different games. One with MatterJS and the other with Arcade. The MatterJS game has only one level. The Arcade one is a simple platformer game with 3 levels. 61 | 62 | So in total, you can play 4 different levels. For each level, the server creates a new room, which creates a new Phaser instance, which are completely isolated from each other. There can be up to 4 players per room. 63 | 64 | ## Structure 65 | 66 | ```bash 67 | ├── src 68 | │ ├── client # Contains all the code the client will need 69 | │ ├── physics # This is for debugging the physics (Arcade and MatterJS) 70 | │ ├── server # Contains the code running on the NodeJS server 71 | │ └── stats # The stats page will show useful information about the server 72 | ``` 73 | 74 | ## How To Use 75 | 76 | To clone and run this template, you'll need [Git](https://git-scm.com) and [Node.js](https://nodejs.org/en/download/) (which comes with [npm](http://npmjs.com)) installed on your computer. 77 | 78 | You also need to install some additional packages since this example uses [Node-Canvas](https://www.npmjs.com/package/canvas). See [here](https://www.npmjs.com/package/canvas#compiling). 79 | 80 | From your command line: 81 | 82 | ```bash 83 | # Clone this repository 84 | $ git clone --depth 1 https://github.com/yandeu/phaser3-multiplayer-with-physics.git phaser3-example 85 | 86 | # Go into the repository 87 | $ cd phaser3-example 88 | 89 | # Install dependencies 90 | $ npm install 91 | 92 | # Start the local development server (on port 3000) 93 | $ npm run dev 94 | 95 | # To publish a production build using docker use docker:publish 96 | # This needs docker and docker-compose to be installed on your machine 97 | $ npm run docker:publish 98 | ``` 99 | 100 | ## Other Multiplayer Examples 101 | 102 | Looking for a simpler multiplayer example? Take a look at [Phaser 3 - Multiplayer Game Example](https://github.com/geckosio/phaser3-multiplayer-game-example). 103 | 104 | ## License 105 | 106 | The MIT License (MIT) 2019 - [Yannick Deubel](https://github.com/yandeu). Please have a look at the [LICENSE](LICENSE) for more details. 107 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | phaser3-coop-game: 5 | container_name: phaser3-coop-game 6 | build: . 7 | ports: 8 | - '8080:3000' 9 | restart: always 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "phaser3-multiplayer-with-physics", 3 | "version": "1.1.0", 4 | "description": "Phaser 3 - Real-Time Multiplayer Game with MatterJS Physics", 5 | "homepage": "https://github.com/yandeu/phaser3-multiplayer-with-physics#readme", 6 | "main": "server.js", 7 | "scripts": { 8 | "start": "npm run dev", 9 | "dev": "npm-run-all --parallel server client physics stats", 10 | "server": "npm-run-all --parallel server:*", 11 | "server:webpack": "webpack --config webpack/webpack.server.cjs --watch", 12 | "server:nodemon": "nodemon dist/server/server.js", 13 | "stats": "webpack --config webpack/webpack.stats.cjs --watch", 14 | "client": "webpack --config webpack/webpack.client.cjs --watch", 15 | "physics": "webpack --config webpack/webpack.physics.cjs --watch", 16 | "build": "webpack --config webpack/webpack.client.prod.cjs && webpack --config webpack/webpack.physics.prod.cjs && webpack --config webpack/webpack.server.cjs && webpack --config webpack/webpack.stats.prod.cjs", 17 | "serve": "node dist/server/server.js", 18 | "prettier": "prettier --write 'src/**/*.ts'", 19 | "docker:start": "npm i --only=production && npm run serve", 20 | "docker:publish": "npm run build && docker-compose up -d --build", 21 | "docker:up": "docker-compose up -d --build", 22 | "postinstall": "webpack --config webpack/webpack.server.cjs && webpack --config webpack/webpack.stats.cjs && webpack --config webpack/webpack.client.cjs && webpack --config webpack/webpack.physics.cjs" 23 | }, 24 | "author": { 25 | "name": "Yannick Deubel", 26 | "url": "https://github.com/yandeu" 27 | }, 28 | "repository": { 29 | "type": "git", 30 | "url": "https://github.com/yandeu/phaser3-multiplayer-with-physics.git" 31 | }, 32 | "template": { 33 | "name": "phaser3-multiplayer-with-physics", 34 | "description": "This game is based on the phaser3-multiplayer-with-physics", 35 | "url": "https://github.com/yandeu/phaser3-multiplayer-with-physics", 36 | "author": "Yannick Deubel (https://github.com/yandeu)" 37 | }, 38 | "license": "MIT", 39 | "dependencies": { 40 | "@geckos.io/phaser-on-nodejs": "^1.2.8", 41 | "axios": "^1.2.1", 42 | "compression": "^1.7.4", 43 | "express": "^4.17.1", 44 | "helmet": "^3.23.1", 45 | "moment": "^2.27.0", 46 | "phaser": "3.55.2", 47 | "pidusage": "^2.0.20", 48 | "socket.io": "^2.3.0", 49 | "socket.io-client": "^2.3.0", 50 | "source-map-support": "^0.5.19", 51 | "uuid": "^8.1.0" 52 | }, 53 | "devDependencies": { 54 | "@types/compression": "^1.7.0", 55 | "@types/express": "^4.17.6", 56 | "@types/helmet": "^0.0.47", 57 | "@types/matter-js": "^0.14.4", 58 | "@types/node": "^18", 59 | "@types/pidusage": "^2.0.1", 60 | "@types/socket.io": "^2.1.8", 61 | "@types/socket.io-client": "^1.4.33", 62 | "@types/uuid": "^3.4.9", 63 | "copy-webpack-plugin": "^11.0.0", 64 | "html-webpack-plugin": "^5.5.0", 65 | "nodemon": "^2.0.20", 66 | "npm-run-all": "^4.1.5", 67 | "object-sizeof": "^1.6.0", 68 | "ts-loader": "^9.4.2", 69 | "typescript": "^4.9.4", 70 | "webpack": "^5.75.0", 71 | "webpack-cli": "^5.0.1", 72 | "webpack-merge": "^5.8.0", 73 | "webpack-node-externals": "^3.0.0" 74 | } 75 | } -------------------------------------------------------------------------------- /readme/phaser-with-nodejs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yandeu/phaser3-multiplayer-with-physics/ea494f835cfe50edefd86b7af378a6c35fa3dcca/readme/phaser-with-nodejs.png -------------------------------------------------------------------------------- /src/client/assets/box.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yandeu/phaser3-multiplayer-with-physics/ea494f835cfe50edefd86b7af378a6c35fa3dcca/src/client/assets/box.png -------------------------------------------------------------------------------- /src/client/assets/bug.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yandeu/phaser3-multiplayer-with-physics/ea494f835cfe50edefd86b7af378a6c35fa3dcca/src/client/assets/bug.png -------------------------------------------------------------------------------- /src/client/assets/controls.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yandeu/phaser3-multiplayer-with-physics/ea494f835cfe50edefd86b7af378a6c35fa3dcca/src/client/assets/controls.png -------------------------------------------------------------------------------- /src/client/assets/dude.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yandeu/phaser3-multiplayer-with-physics/ea494f835cfe50edefd86b7af378a6c35fa3dcca/src/client/assets/dude.png -------------------------------------------------------------------------------- /src/client/assets/fullscreen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yandeu/phaser3-multiplayer-with-physics/ea494f835cfe50edefd86b7af378a6c35fa3dcca/src/client/assets/fullscreen.png -------------------------------------------------------------------------------- /src/client/assets/mummy37x45.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yandeu/phaser3-multiplayer-with-physics/ea494f835cfe50edefd86b7af378a6c35fa3dcca/src/client/assets/mummy37x45.png -------------------------------------------------------------------------------- /src/client/assets/star.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yandeu/phaser3-multiplayer-with-physics/ea494f835cfe50edefd86b7af378a6c35fa3dcca/src/client/assets/star.png -------------------------------------------------------------------------------- /src/client/assets/starfield.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yandeu/phaser3-multiplayer-with-physics/ea494f835cfe50edefd86b7af378a6c35fa3dcca/src/client/assets/starfield.jpg -------------------------------------------------------------------------------- /src/client/components/animations.ts: -------------------------------------------------------------------------------- 1 | import { SKINS } from '../../constants' 2 | 3 | export const createDudeAnimations = (scene: Phaser.Scene) => { 4 | scene.anims.create({ 5 | key: 'left', 6 | frames: scene.anims.generateFrameNumbers(SKINS.DUDE.toString(), { start: 0, end: 3 }), 7 | frameRate: 10, 8 | repeat: -1 9 | }) 10 | 11 | scene.anims.create({ 12 | key: 'idle', 13 | frames: [{ key: SKINS.DUDE.toString(), frame: 4 }], 14 | frameRate: 20 15 | }) 16 | 17 | scene.anims.create({ 18 | key: 'right', 19 | frames: scene.anims.generateFrameNumbers(SKINS.DUDE.toString(), { start: 5, end: 8 }), 20 | frameRate: 10, 21 | repeat: -1 22 | }) 23 | } 24 | 25 | export const setDudeAnimation = (sprite: Phaser.GameObjects.Sprite, animation: string = 'idle') => { 26 | if (!sprite.anims.isPlaying) sprite.play(animation) 27 | else if (sprite.anims.isPlaying && sprite.anims.getName() !== animation) sprite.play(animation) 28 | } 29 | 30 | export const createMummyAnimation = (scene: Phaser.Scene) => { 31 | scene.anims.create({ 32 | key: 'walk', 33 | frames: scene.anims.generateFrameNumbers(SKINS.MUMMY.toString(), {}), 34 | frameRate: 16, 35 | repeat: 7 36 | }) 37 | } 38 | 39 | export const setMummyAnimation = (sprite: Phaser.GameObjects.Sprite, direction: string) => { 40 | if (!sprite.anims.isPlaying) sprite.anims.play('walk') 41 | let flipX = direction === 'left' ? true : false 42 | sprite.setFlipX(flipX) 43 | } 44 | -------------------------------------------------------------------------------- /src/client/components/controls.ts: -------------------------------------------------------------------------------- 1 | export default class Controls { 2 | left = false 3 | right = false 4 | up = false 5 | controls: Control[] = [] 6 | none = true 7 | prevNone = true 8 | 9 | constructor(public scene: Phaser.Scene, public socket: SocketIOClient.Socket) { 10 | // add a second pointer 11 | scene.input.addPointer() 12 | 13 | const detectPointer = (gameObject: Control, down: boolean) => { 14 | if (gameObject.btn) { 15 | switch (gameObject.btn) { 16 | case 'left': 17 | this.left = down 18 | break 19 | case 'right': 20 | this.right = down 21 | break 22 | case 'up': 23 | this.up = down 24 | break 25 | } 26 | } 27 | } 28 | scene.input.on('gameobjectdown', (pointer: Phaser.Input.Pointer, gameObject: Control) => 29 | detectPointer(gameObject, true) 30 | ) 31 | scene.input.on('gameobjectup', (pointer: Phaser.Input.Pointer, gameObject: Control) => 32 | detectPointer(gameObject, false) 33 | ) 34 | 35 | let left = new Control(scene, 0, 0, 'left').setRotation(-0.5 * Math.PI) 36 | let right = new Control(scene, 0, 0, 'right').setRotation(0.5 * Math.PI) 37 | let up = new Control(scene, 0, 0, 'up') 38 | this.controls.push(left, right, up) 39 | this.resize() 40 | 41 | this.scene.events.on('update', this.update, this) 42 | } 43 | 44 | controlsDown() { 45 | return { left: this.left, right: this.right, up: this.up, none: this.none } 46 | } 47 | 48 | resize() { 49 | const SCALE = 1 50 | const controlsRadius = (192 / 2) * SCALE 51 | const w = this.scene.cameras.main.width - 10 - controlsRadius 52 | const h = this.scene.cameras.main.height - 10 - controlsRadius 53 | let positions = [ 54 | { 55 | x: controlsRadius + 10, 56 | y: h 57 | }, 58 | { x: controlsRadius + 214, y: h }, 59 | { x: w, y: h } 60 | ] 61 | this.controls.forEach((ctl, i) => { 62 | ctl.setPosition(positions[i].x, positions[i].y) 63 | ctl.setScale(SCALE) 64 | }) 65 | } 66 | 67 | update() { 68 | this.none = this.left || this.right || this.up ? false : true 69 | 70 | if (!this.none || this.none !== this.prevNone) { 71 | let total = 0 72 | if (this.left) total += 1 73 | if (this.right) total += 2 74 | if (this.up) total += 4 75 | if (this.none) total += 8 76 | this.socket.emit('U' /* short for updateDude */, total) 77 | } 78 | 79 | this.prevNone = this.none 80 | } 81 | } 82 | 83 | class Control extends Phaser.GameObjects.Image { 84 | constructor(scene: Phaser.Scene, x: number, y: number, public btn: string) { 85 | super(scene, x, y, 'controls') 86 | scene.add.existing(this) 87 | 88 | this.setInteractive() 89 | .setScrollFactor(0) 90 | .setAlpha(0.5) 91 | .setDepth(2) 92 | 93 | if (!scene.sys.game.device.input.touch) this.setAlpha(0) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/client/components/cursors.ts: -------------------------------------------------------------------------------- 1 | export default class Cursors { 2 | cursors: Phaser.Types.Input.Keyboard.CursorKeys 3 | 4 | none = true 5 | prevNone = true 6 | 7 | left = false 8 | right = false 9 | up = false 10 | 11 | constructor(public scene: Phaser.Scene, public socket: SocketIOClient.Socket) { 12 | this.cursors = scene.input.keyboard.createCursorKeys() 13 | 14 | this.scene.events.on('update', this.update, this) 15 | } 16 | 17 | cursorsDown() { 18 | return { left: this.left, right: this.right, up: this.up, none: this.none } 19 | } 20 | 21 | update() { 22 | if (!this.cursors.left || !this.cursors.right || !this.cursors.up) return 23 | 24 | this.none = this.cursors.left.isDown || this.cursors.right.isDown || this.cursors.up.isDown ? false : true 25 | 26 | if (!this.none || this.none !== this.prevNone) { 27 | this.left = false 28 | this.right = false 29 | this.up = false 30 | 31 | if (this.cursors.left.isDown) { 32 | this.left = true 33 | } else if (this.cursors.right.isDown) { 34 | this.right = true 35 | } 36 | 37 | if (this.cursors.up.isDown) { 38 | this.up = true 39 | } 40 | 41 | if (!PHYSICS_DEBUG) { 42 | let total = 0 43 | if (this.left) total += 1 44 | if (this.right) total += 2 45 | if (this.up) total += 4 46 | if (this.none) total += 8 47 | this.socket.emit('U' /* short for updateDude */, total) 48 | } 49 | } 50 | 51 | this.prevNone = this.none 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/client/components/fullscreenButton.ts: -------------------------------------------------------------------------------- 1 | const fullscreenButton = (scene: Phaser.Scene) => { 2 | let button = scene.add 3 | .image(0, 0, 'fullscreen', 0) 4 | .setOrigin(1, 0) 5 | .setInteractive() 6 | .setScrollFactor(0) 7 | .setDepth(100) 8 | 9 | button.on('pointerup', () => { 10 | if (scene.scale.isFullscreen) { 11 | button.setFrame(0) 12 | scene.scale.stopFullscreen() 13 | } else { 14 | button.setFrame(1) 15 | scene.scale.startFullscreen() 16 | } 17 | }) 18 | return button 19 | } 20 | 21 | export default fullscreenButton 22 | -------------------------------------------------------------------------------- /src/client/components/fullscreenEvent.ts: -------------------------------------------------------------------------------- 1 | // listen for fullscreen change event 2 | const FullScreenEvent = (callback: Function) => { 3 | const fullScreenChange = () => { 4 | let times = [50, 100, 200, 500, 1000, 2000, 5000] 5 | times.forEach(time => { 6 | window.setTimeout(() => { 7 | callback() 8 | }, time) 9 | }) 10 | } 11 | var vendors = ['webkit', 'moz', 'ms', ''] 12 | vendors.forEach(prefix => { 13 | document.addEventListener(prefix + 'fullscreenchange', fullScreenChange, false) 14 | }) 15 | document.addEventListener('MSFullscreenChange', fullScreenChange, false) 16 | } 17 | export default FullScreenEvent 18 | -------------------------------------------------------------------------------- /src/client/components/resize.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This is a phaser 3 scaling strategy implementation from 3 | * https://github.com/yandeu/phaser3-scaling-resizing-example 4 | */ 5 | 6 | type scaleMode = 'FIT' | 'SMOOTH' 7 | 8 | const DEFAULT_WIDTH: number = 896 9 | const DEFAULT_HEIGHT: number = 504 10 | const MAX_WIDTH: number = DEFAULT_WIDTH * 1.5 11 | const MAX_HEIGHT: number = DEFAULT_HEIGHT * 1.5 12 | let SCALE_MODE: scaleMode = 'SMOOTH' // FIT OR SMOOTH 13 | 14 | const resize = (game: Phaser.Game) => { 15 | const w = window.innerWidth 16 | const h = window.innerHeight 17 | 18 | let width = DEFAULT_WIDTH 19 | let height = DEFAULT_HEIGHT 20 | let maxWidth = MAX_WIDTH 21 | let maxHeight = MAX_HEIGHT 22 | let scaleMode = SCALE_MODE 23 | 24 | let scale = Math.min(w / width, h / height) 25 | let newWidth = Math.min(w / scale, maxWidth) 26 | let newHeight = Math.min(h / scale, maxHeight) 27 | 28 | let defaultRatio = DEFAULT_WIDTH / DEFAULT_HEIGHT 29 | let maxRatioWidth = MAX_WIDTH / DEFAULT_HEIGHT 30 | let maxRatioHeight = DEFAULT_WIDTH / MAX_HEIGHT 31 | 32 | // smooth scaling 33 | let smooth = 1 34 | if (scaleMode === 'SMOOTH') { 35 | const maxSmoothScale = 1.15 36 | const normalize = (value: number, min: number, max: number) => { 37 | return (value - min) / (max - min) 38 | } 39 | if (width / height < w / h) { 40 | smooth = 41 | -normalize(newWidth / newHeight, defaultRatio, maxRatioWidth) / (1 / (maxSmoothScale - 1)) + maxSmoothScale 42 | } else { 43 | smooth = 44 | -normalize(newWidth / newHeight, defaultRatio, maxRatioHeight) / (1 / (maxSmoothScale - 1)) + maxSmoothScale 45 | } 46 | } 47 | 48 | // resize the game 49 | game.scale.resize(newWidth * smooth, newHeight * smooth) 50 | 51 | // scale the width and height of the css 52 | game.canvas.style.width = newWidth * scale + 'px' 53 | game.canvas.style.height = newHeight * scale + 'px' 54 | 55 | // center the game with css margin 56 | game.canvas.style.marginTop = `${(h - newHeight * scale) / 2}px` 57 | game.canvas.style.marginLeft = `${(w - newWidth * scale) / 2}px` 58 | } 59 | 60 | export default resize 61 | -------------------------------------------------------------------------------- /src/client/components/texts.ts: -------------------------------------------------------------------------------- 1 | import { MAX_PLAYERS_PER_ROOM } from '../../constants' 2 | 3 | const texts = [ 4 | { 5 | y: 230, 6 | fontSize: 28, 7 | type: 'server_running_time' 8 | }, 9 | { 10 | y: 260, 11 | fontSize: 28, 12 | type: 'the_room_id' 13 | }, 14 | { 15 | y: 290, 16 | fontSize: 28, 17 | type: 'show_connected_users' 18 | }, 19 | { 20 | y: 320, 21 | fontSize: 28, 22 | type: 'show_latency' 23 | }, 24 | { 25 | y: 350, 26 | fontSize: 28, 27 | type: 'show_fps' 28 | } 29 | ] 30 | 31 | export default class Texts { 32 | textObjects: { [key: string]: Phaser.GameObjects.Text } = {} 33 | hidden = false 34 | bug: Phaser.GameObjects.Image | undefined 35 | 36 | constructor(public scene: Phaser.Scene) { 37 | texts.forEach(text => { 38 | let theText = scene.add 39 | .text(scene.cameras.main.width / 2, text.y, '', { 40 | color: '#ffffff', 41 | fontSize: text.fontSize 42 | }) 43 | .setOrigin(0.5) 44 | .setResolution(window.devicePixelRatio) 45 | .setScrollFactor(0) 46 | .setDepth(100) 47 | 48 | this.textObjects[text.type] = theText 49 | }) 50 | 51 | this.makeBug() 52 | this.resize() 53 | this.toggleHidden() 54 | this.scene.events.on('update', this.update, this) 55 | } 56 | 57 | update() { 58 | if (this.hidden) return 59 | this.setFps(this.scene.game.loop.actualFps) 60 | } 61 | 62 | makeBug() { 63 | this.bug = this.scene.add 64 | .image(0, 0, 'bug') 65 | .setOrigin(0) 66 | .setScrollFactor(0) 67 | .setDepth(100) 68 | this.bug.setInteractive().on('pointerdown', () => { 69 | this.toggleHidden() 70 | }) 71 | } 72 | 73 | toggleHidden() { 74 | this.hidden = !this.hidden 75 | for (const key in this.textObjects) { 76 | this.textObjects[key].setAlpha(this.hidden ? 0 : 1) 77 | } 78 | } 79 | 80 | resize() { 81 | texts.forEach(text => { 82 | const textObj = this.textObjects[text.type] 83 | textObj.setPosition(this.scene.cameras.main.width / 2, text.y) 84 | }) 85 | if (this.bug) this.bug.setPosition(16, 16) 86 | } 87 | 88 | setConnectCounter(connectCounter: number) { 89 | this.textObjects['show_connected_users'].setText(`Connected users: ${connectCounter}/${MAX_PLAYERS_PER_ROOM}`) 90 | } 91 | 92 | setRoomId(roomId: string) { 93 | this.textObjects['the_room_id'].setText(`RoomId ${roomId}`) 94 | } 95 | 96 | setTime(time: number) { 97 | this.textObjects['server_running_time'].setText(`Server is running since ${new Date(time).toUTCString()}`) 98 | } 99 | 100 | setFps(fps: number) { 101 | this.textObjects['show_fps'].setText(`fps: ${Math.round(fps)}`) 102 | } 103 | 104 | setLatency(latency: Latency) { 105 | if (isNaN(latency.current)) return 106 | if (isNaN(latency.high) || latency.current > latency.high) latency.high = latency.current 107 | if (isNaN(latency.low) || latency.current < latency.low) latency.low = latency.current 108 | 109 | let sum = latency.history.reduce((previous, current) => (current += previous)) 110 | let avg = sum / latency.history.length 111 | 112 | this.textObjects['show_latency'].setText( 113 | `Latency ${latency.current}ms (avg ${Math.round(avg)}ms / low ${latency.low}ms / high ${latency.high}ms)` 114 | ) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/client/config.ts: -------------------------------------------------------------------------------- 1 | import PreloadScene from './scenes/preloadScene' 2 | import MenuScene from './scenes/menuScene' 3 | import MainScene from './scenes/mainScene' 4 | 5 | const DEFAULT_WIDTH = 1280 6 | const DEFAULT_HEIGHT = 720 7 | 8 | // the size of the world 9 | export const world = { 10 | x: 0, 11 | y: 0, 12 | width: 2560, 13 | height: 864 14 | } 15 | 16 | const config = { 17 | type: Phaser.WEBGL, 18 | backgroundColor: '#ffffff', 19 | scale: { 20 | parent: 'phaser-game', 21 | mode: Phaser.Scale.NONE, 22 | width: DEFAULT_WIDTH, 23 | height: DEFAULT_HEIGHT 24 | }, 25 | scene: [PreloadScene, MenuScene, MainScene], 26 | physics: { 27 | default: 'matter', 28 | matter: { 29 | gravity: { 30 | y: 0.8 31 | }, 32 | debug: false, 33 | debugBodyColor: 0xff00ff 34 | } 35 | } 36 | } 37 | export default config 38 | -------------------------------------------------------------------------------- /src/client/game.ts: -------------------------------------------------------------------------------- 1 | import config from './config' 2 | 3 | export default class Game extends Phaser.Game { 4 | constructor() { 5 | super(config) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | Document 11 | 12 | 13 | 22 |
23 | 24 | 25 | -------------------------------------------------------------------------------- /src/client/index.ts: -------------------------------------------------------------------------------- 1 | import 'phaser' 2 | import resize from './components/resize' 3 | import Game from './game' 4 | import FullScreenEvent from './components/fullscreenEvent' 5 | 6 | window.addEventListener('load', () => { 7 | let game = new Game() 8 | 9 | window.addEventListener('resize', () => { 10 | resize(game) 11 | }) 12 | 13 | FullScreenEvent(() => resize(game)) 14 | 15 | resize(game) 16 | }) 17 | -------------------------------------------------------------------------------- /src/client/scenes/mainScene.ts: -------------------------------------------------------------------------------- 1 | import Texts from '../components/texts' 2 | import Cursors from '../components/cursors' 3 | import { setDudeAnimation, setMummyAnimation } from '../components/animations' 4 | import fullscreenButton from '../components/fullscreenButton' 5 | import Controls from '../components/controls' 6 | import { world } from '../config' 7 | import Resize from '../components/resize' 8 | 9 | import SyncManager from '../../server/managers/syncManager' 10 | import { SKINS } from '../../constants' 11 | 12 | interface Objects { 13 | [key: string]: any 14 | } 15 | 16 | export default class MainScene extends Phaser.Scene { 17 | objects: Objects = {} 18 | sync: { initialState: boolean; objects: any[] } = { 19 | initialState: false, 20 | objects: [] 21 | } 22 | 23 | latency: Latency = { 24 | current: NaN, 25 | high: NaN, 26 | low: NaN, 27 | ping: NaN, 28 | id: '', 29 | canSend: true, 30 | history: [] 31 | } 32 | socket: Socket 33 | 34 | cursors: Cursors | undefined 35 | controls: Controls | undefined 36 | level: number = 0 37 | 38 | constructor() { 39 | super({ key: 'MainScene' }) 40 | } 41 | 42 | init(props: { scene: string; level: number; socket: Socket }) { 43 | const { scene, level = 0, socket } = props 44 | this.level = level 45 | this.socket = socket 46 | this.socket.emit('joinRoom', { scene, level }) 47 | } 48 | 49 | create() { 50 | const socket = this.socket 51 | 52 | let levelText = this.add 53 | .text(0, 0, `Level ${this.level + 1}`, { 54 | color: '#ffffff', 55 | fontSize: 42 56 | }) 57 | .setOrigin(0.5, 0) 58 | .setDepth(100) 59 | .setScrollFactor(0) 60 | 61 | let starfield = this.add.tileSprite(world.x, world.y, world.width, world.height, 'starfield').setOrigin(0) 62 | this.cursors = new Cursors(this, socket) 63 | this.controls = new Controls(this, socket) 64 | let texts = new Texts(this) 65 | let fullscreenBtn = fullscreenButton(this) 66 | 67 | this.cameras.main.setBounds(world.x, world.y, world.width, world.height) 68 | 69 | socket.on('getPong', (id: string) => { 70 | if (this.latency.id !== id) return 71 | this.latency.canSend = true 72 | this.latency.current = new Date().getTime() - this.latency.ping 73 | if (this.latency.history.length >= 200) this.latency.history.shift() 74 | this.latency.history.push(this.latency.current) 75 | texts.setLatency(this.latency) 76 | }) 77 | this.time.addEvent({ 78 | delay: 250, // max 4 times per second 79 | loop: true, 80 | callback: () => { 81 | if (!this.latency.canSend) return 82 | if (texts.hidden) return 83 | this.latency.ping = new Date().getTime() 84 | this.latency.id = Phaser.Math.RND.uuid() 85 | this.latency.canSend = false 86 | socket.emit('sendPing', this.latency.id) 87 | } 88 | }) 89 | 90 | socket.on('changingRoom', (data: { scene: string; level: number }) => { 91 | console.log('You are changing room') 92 | // destroy all objects and get new onces 93 | Object.keys(this.objects).forEach((key: string) => { 94 | this.objects[key].sprite.destroy() 95 | delete this.objects[key] 96 | }) 97 | socket.emit('getInitialState') 98 | this.level = data.level | 0 99 | levelText.setText(`Level ${this.level + 1}`) 100 | }) 101 | 102 | socket.on('S' /* short for syncGame */, (res: any) => { 103 | if (res.connectCounter) texts.setConnectCounter(res.connectCounter) 104 | if (res.time) texts.setTime(res.time) 105 | if (res.roomId) texts.setRoomId(res.roomId) 106 | 107 | // res.O (objects) contains only the objects that need to be updated 108 | if (res.O /* short for objects */) { 109 | res.O = SyncManager.decode(res.O) 110 | 111 | this.sync.objects = [...this.sync.objects, ...res.O] 112 | this.sync.objects.forEach((obj: any) => { 113 | // the if the player's dude is in the objects list the camera follows it sprite 114 | if (this.objects[obj.id] && obj.skin === SKINS.DUDE && obj.clientId && +obj.clientId === +socket.clientId) { 115 | this.cameras.main.setScroll(obj.x - this.cameras.main.width / 2, obj.y - this.cameras.main.height / 2) 116 | } 117 | 118 | // if the object does not exist, create a new one 119 | if (!this.objects[obj.id]) { 120 | let sprite = this.add 121 | .sprite(obj.x, obj.y, obj.skin.toString()) 122 | .setOrigin(0.5) 123 | .setRotation(obj.angle || 0) 124 | 125 | // add the sprite by id to the objects object 126 | this.objects[obj.id] = { 127 | sprite: sprite 128 | } 129 | } 130 | 131 | // set some properties to the sprite 132 | let sprite = this.objects[obj.id].sprite 133 | // set scale 134 | if (obj.scale) { 135 | sprite.setScale(obj.scale) 136 | } 137 | // set scale 138 | if (obj.tint) { 139 | sprite.setTint(obj.tint) 140 | } 141 | // set visibility 142 | sprite.setVisible(!obj.dead) 143 | }) 144 | } 145 | }) 146 | // request the initial state 147 | socket.emit('getInitialState') 148 | 149 | // request the initial state every 15 seconds 150 | // to make sure all objects are up to date 151 | // in case we missed one (network issues) 152 | // should be sent from the server side not the client 153 | // this.time.addEvent({ 154 | // delay: 15000, 155 | // loop: true, 156 | // callback: () => { 157 | // socket.emit('getInitialState') 158 | // } 159 | // }) 160 | 161 | // request the initial state if the game gets focus 162 | // e.g. if the users comes from another tab or window 163 | this.game.events.on('focus', () => socket.emit('getInitialState')) 164 | 165 | // this helps debugging 166 | this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => { 167 | console.log(pointer.worldX, pointer.worldY) 168 | }) 169 | 170 | const resize = () => { 171 | starfield.setScale(Math.max(this.cameras.main.height / starfield.height, 1)) 172 | texts.resize() 173 | if (this.controls) this.controls.resize() 174 | fullscreenBtn.setPosition(this.cameras.main.width - 16, 16) 175 | this.cameras.main.setScroll(this.cameras.main.worldView.x, world.height) 176 | levelText.setPosition(this.cameras.main.width / 2, 20) 177 | } 178 | 179 | this.scale.on('resize', (gameSize: any, baseSize: any, displaySize: any, resolution: any) => { 180 | this.cameras.resize(gameSize.width, gameSize.height) 181 | resize() 182 | }) 183 | Resize(this.game) 184 | } 185 | 186 | update(time: number, delta: number) { 187 | // update all objects 188 | if (this.sync.objects.length > 0) { 189 | this.sync.objects.forEach(obj => { 190 | if (this.objects[obj.id]) { 191 | let sprite = this.objects[obj.id].sprite 192 | if (obj.dead !== null) sprite.setVisible(!obj.dead) 193 | if (obj.x !== null) sprite.x = obj.x 194 | if (obj.y !== null) sprite.y = obj.y 195 | if (obj.angle !== null && typeof obj.angle !== 'undefined') sprite.angle = obj.angle 196 | if (obj.skin !== null) { 197 | if (obj.skin === SKINS.MUMMY) { 198 | if (obj.direction !== null) setMummyAnimation(sprite, obj.direction) 199 | } 200 | if (obj.skin === SKINS.DUDE) { 201 | if (obj.animation !== null) setDudeAnimation(sprite, obj.animation) 202 | } 203 | } 204 | } 205 | }) 206 | } 207 | this.sync.objects = [] 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /src/client/scenes/menuScene.ts: -------------------------------------------------------------------------------- 1 | import Resize from '../components/resize' 2 | 3 | export default class MenuScene extends Phaser.Scene { 4 | socket: Socket 5 | constructor() { 6 | super({ key: 'MenuScene' }) 7 | } 8 | 9 | init(props: { socket: Socket }) { 10 | const { socket } = props 11 | this.socket = socket 12 | } 13 | 14 | create() { 15 | const styles = { 16 | color: '#000000', 17 | align: 'center', 18 | fontSize: 52 19 | } 20 | 21 | let texts: any[] = [] 22 | 23 | texts.push(this.add.text(0, 0, 'Choose which Level\nyou want to play', styles).setOrigin(0.5, 0)) 24 | 25 | texts.push( 26 | this.add 27 | .text(0, 0, 'Matter Physics', styles) 28 | .setOrigin(0.5, 0) 29 | .setInteractive() 30 | .on('pointerdown', () => { 31 | this.scene.start('MainScene', { scene: 'MatterScene', level: 0, socket: this.socket }) 32 | }) 33 | ) 34 | 35 | texts.push( 36 | this.add 37 | .text(0, 0, 'Arcade Physics (Level 1)', styles) 38 | .setOrigin(0.5, 0) 39 | .setInteractive() 40 | .on('pointerdown', () => { 41 | this.scene.start('MainScene', { scene: 'ArcadeScene', level: 0, socket: this.socket }) 42 | }) 43 | ) 44 | 45 | texts.push( 46 | this.add 47 | .text(0, 0, 'Arcade Physics (Level 2)', styles) 48 | .setOrigin(0.5, 0) 49 | .setInteractive() 50 | .on('pointerdown', () => { 51 | this.scene.stop() 52 | this.scene.start('MainScene', { scene: 'ArcadeScene', level: 1, socket: this.socket }) 53 | }) 54 | ) 55 | 56 | texts.push( 57 | this.add 58 | .text(0, 0, 'Arcade Physics (Level 3)', styles) 59 | .setOrigin(0.5, 0) 60 | .setInteractive() 61 | .on('pointerdown', () => { 62 | this.scene.stop() 63 | this.scene.start('MainScene', { scene: 'ArcadeScene', level: 2, socket: this.socket }) 64 | }) 65 | ) 66 | 67 | const resize = () => { 68 | const { centerX, centerY } = this.cameras.main 69 | let posY = [20, centerY - 100, centerY - 10, centerY + 60, centerY + 130] 70 | texts.forEach((text, i) => { 71 | text.setPosition(centerX, posY[i]) 72 | }) 73 | } 74 | 75 | this.scale.on('resize', (gameSize: any, baseSize: any, displaySize: any, resolution: any) => { 76 | if (!this.scene.isActive()) return 77 | this.cameras.resize(gameSize.width, gameSize.height) 78 | resize() 79 | }) 80 | resize() 81 | Resize(this.game) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/client/scenes/preloadScene.ts: -------------------------------------------------------------------------------- 1 | import io from 'socket.io-client' 2 | import { SKINS } from '../../constants' 3 | import { createDudeAnimations, createMummyAnimation } from '../components/animations' 4 | 5 | export default class PreloadScene extends Phaser.Scene { 6 | constructor() { 7 | super({ key: 'PreloadScene' }) 8 | } 9 | 10 | preload() { 11 | this.load.setBaseURL('static/client') 12 | this.load.image(SKINS.BOX.toString(), 'assets/box.png') 13 | this.load.image(SKINS.STAR.toString(), 'assets/star.png') 14 | this.load.image('bug', 'assets/bug.png') 15 | this.load.image('starfield', 'assets/starfield.jpg') 16 | this.load.image('controls', 'assets/controls.png') 17 | this.load.spritesheet(SKINS.DUDE.toString(), 'assets/dude.png', { 18 | frameWidth: 32, 19 | frameHeight: 48 20 | }) 21 | this.load.spritesheet('fullscreen', 'assets/fullscreen.png', { 22 | frameWidth: 64, 23 | frameHeight: 64 24 | }) 25 | this.load.spritesheet(SKINS.MUMMY.toString(), 'assets/mummy37x45.png', { frameWidth: 37, frameHeight: 45 }) 26 | } 27 | 28 | create() { 29 | createDudeAnimations(this) 30 | createMummyAnimation(this) 31 | 32 | // connecting to socket.io 33 | const url = `${location.origin}/G` /* short for stats */ 34 | 35 | let socket = io.connect(url, { transports: ['websocket'] }) as Socket 36 | 37 | // on reconnection, reset the transports option, as the Websocket 38 | // connection may have failed (caused by proxy, firewall, browser, ...) 39 | socket.on('reconnect_attempt', () => { 40 | socket.io.opts.transports = ['polling', 'websocket'] 41 | }) 42 | 43 | socket.on('connect', () => { 44 | console.log("You're connected to socket.io") 45 | }) 46 | 47 | // we wait until we have a valid clientId, then start the MainScene 48 | socket.on('clientId', (clientId: number) => { 49 | socket.clientId = clientId 50 | console.log('Your client id', clientId) 51 | this.scene.start('MenuScene', { socket }) 52 | }) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const SKINS = { 2 | DUDE: 0, 3 | BOX: 1, 4 | STAR: 2, 5 | MUMMY: 3 6 | } 7 | 8 | export const MAX_PLAYERS_PER_ROOM = 4 9 | export const USER_KICK_TIMEOUT = 60_000 // 1 minute 10 | -------------------------------------------------------------------------------- /src/physics/game.ts: -------------------------------------------------------------------------------- 1 | import commonConfig, { arcadePhysics, matterPhysics } from '../server/game/config' 2 | 3 | import ArcadeScene from '../server/game/scenes/arcadeScene' 4 | import MatterScene from '../server/game/scenes/matterScene' 5 | 6 | class PhaserGame extends Phaser.Game { 7 | debug = true 8 | 9 | constructor(public io: SocketIO.Namespace, config: Phaser.Types.Core.GameConfig) { 10 | super(config) 11 | } 12 | } 13 | 14 | const Game = (io: SocketIO.Namespace) => { 15 | let config = { ...commonConfig } 16 | let href = location.href 17 | 18 | config.type = Phaser.AUTO 19 | config.scale = { 20 | mode: Phaser.Scale.FIT, 21 | autoCenter: Phaser.Scale.CENTER_BOTH 22 | } 23 | 24 | if (/arcade/.test(href)) { 25 | config.scene = [ArcadeScene] 26 | config.physics = arcadePhysics 27 | // @ts-ignore 28 | config.physics.arcade.debug = true 29 | } 30 | if (/matter/.test(href)) { 31 | config.scene = [MatterScene] 32 | config.physics = matterPhysics 33 | if (config.physics.matter) config.physics.matter.debug = true 34 | } 35 | 36 | return new PhaserGame(io, config) 37 | } 38 | export default Game 39 | -------------------------------------------------------------------------------- /src/physics/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 | 18 |
19 | 20 | 21 | -------------------------------------------------------------------------------- /src/physics/index.ts: -------------------------------------------------------------------------------- 1 | import Game from './game' 2 | 3 | // mock the socket.io 4 | const ioMock = { 5 | emit: () => {}, 6 | on: () => {}, 7 | in: () => {}, 8 | connected: 'connected' 9 | } 10 | 11 | window.addEventListener('load', () => { 12 | // @ts-ignore 13 | window.game = Game(ioMock) 14 | }) 15 | -------------------------------------------------------------------------------- /src/server/game/arcadeObjects/box.ts: -------------------------------------------------------------------------------- 1 | import { SKINS } from '../../../constants' 2 | 3 | export default class Box extends Phaser.Physics.Arcade.Sprite { 4 | skin = SKINS.BOX 5 | id: string 6 | sync = true 7 | 8 | constructor(scene: Phaser.Scene, id: number, x: number, y: number) { 9 | super(scene, x, y, '') 10 | scene.add.existing(this) 11 | scene.physics.add.existing(this, true) 12 | 13 | // @ts-ignore 14 | this.body 15 | .setSize(95, 95) 16 | // 32 is the default width an height for an sprite if the texture can not be loaded 17 | .setOffset(-32, -32) 18 | 19 | this.id = id.toString() 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/server/game/arcadeObjects/dude.ts: -------------------------------------------------------------------------------- 1 | import { SKINS } from '../../../constants' 2 | 3 | export default class Dude extends Phaser.Physics.Arcade.Sprite { 4 | skin = SKINS.DUDE 5 | clientId: number 6 | socketId: string 7 | id: string 8 | private updates: any = {} 9 | private shouldUpdate = true 10 | prevPosition = { 11 | x: -1, 12 | y: -1 13 | } 14 | dead = false 15 | prevDead = false 16 | color: number = 0xffffff 17 | prevColor: number = 0xffffff 18 | animation: string | undefined = undefined 19 | hit = false 20 | 21 | constructor(scene: Phaser.Scene, id: number, options: { socketId: string; clientId: number }) { 22 | super(scene, 0, 0, '') 23 | scene.add.existing(this) 24 | scene.physics.add.existing(this) 25 | 26 | this.setFrame(0) 27 | 28 | this.socketId = options.socketId 29 | this.clientId = options.clientId 30 | 31 | this.setNewPosition() 32 | this.setCollideWorldBounds(true).setOrigin(0) 33 | 34 | // @ts-ignore 35 | this.body.setSize(32, 48) 36 | 37 | // matterJS uses an id per object, so I do the same here to be consistent 38 | // @ts-ignore 39 | this.id = id.toString() 40 | } 41 | 42 | setNewPosition() { 43 | this.setPosition(Phaser.Math.RND.integerInRange(0, 1000), Phaser.Math.RND.integerInRange(100, 300)) 44 | } 45 | 46 | postUpdate() { 47 | this.prevPosition = { ...this.body.position } 48 | this.prevDead = this.dead 49 | this.prevColor = this.color 50 | } 51 | 52 | gotHit() { 53 | if (this.hit) return 54 | this.hit = true 55 | this.color = 0xff0000 56 | 57 | this.scene.time.addEvent({ 58 | delay: 3500, 59 | callback: () => { 60 | this.hit = false 61 | this.color = 0xffffff 62 | } 63 | }) 64 | } 65 | 66 | update() { 67 | if (!this.active) return 68 | if (!this.shouldUpdate) return 69 | this.shouldUpdate = false 70 | 71 | if (this.updates.left) this.setVelocityX(-400) 72 | else if (this.updates.right) this.setVelocityX(400) 73 | else this.setVelocityX(0) 74 | 75 | if (this.updates.up && this.body.blocked.down) this.setVelocityY(-600) 76 | 77 | this.animation = this.body.velocity.x >= 0.5 ? 'right' : this.body.velocity.x <= -0.5 ? 'left' : 'idle' 78 | 79 | this.updates = {} 80 | } 81 | 82 | revive(clientId: number, socketId: string) { 83 | this.setActive(true) 84 | this.dead = false 85 | this.setNewPosition() 86 | this.clientId = clientId 87 | this.socketId = socketId 88 | } 89 | 90 | kill() { 91 | this.setActive(false) 92 | this.dead = true 93 | } 94 | 95 | setUpdates(updates: any) { 96 | this.shouldUpdate = true 97 | this.updates = updates 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/server/game/arcadeObjects/map.ts: -------------------------------------------------------------------------------- 1 | export default class Map { 2 | margin: { x: number; y: number } 3 | tileSize = 95 4 | levels = [ 5 | [ 6 | ' XXX M M XXXXXX', 7 | ' XX X X ', 8 | ' XX X X XXXXX X ', 9 | ' M M M X G ', 10 | 'XX XXXX XXX X ', 11 | 'XXXX X X X ' 12 | ], 13 | [ 14 | ' ', 15 | ' M X X ', 16 | ' XX X XXXXX XXX X ', 17 | ' G M X ', 18 | 'XX XXXX M XXX X ', 19 | 'XXXX XXXXXXXXX X X ' 20 | ], 21 | [ 22 | ' M G ', 23 | ' XXXXXXX X X ', 24 | ' XX X XXXXX XXX X ', 25 | ' M X ', 26 | 'XX XXXX XXX X ', 27 | 'XXXX XXXXXXXXX X X ' 28 | ] 29 | ] 30 | 31 | constructor(public scene: Phaser.Scene, public world: any, public level: number) { 32 | this.margin = { 33 | y: 3 * this.tileSize + 11 + 45, // 45 is the half of a box 34 | x: world.x + 45 35 | } 36 | } 37 | 38 | private collideRect( 39 | rect1: { x: number; y: number; width: number; height: number }, 40 | rect2: { x: number; y: number; width: number; height: number } 41 | ) { 42 | return ( 43 | rect1.x < rect2.x + rect2.width && 44 | rect1.x + rect1.width > rect2.x && 45 | rect1.y < rect2.y + rect2.height && 46 | rect1.y + rect1.height > rect2.y 47 | ) 48 | } 49 | 50 | getTileByCoordinates(coordinates: { x: number; y: number }) { 51 | let { x: x1, y: y1 } = coordinates 52 | 53 | let tile = { tile: '', x: -1, y: -1 } 54 | 55 | this.getLevel().forEach((row, y) => { 56 | for (let x = 0; x < row.length; x++) { 57 | let x2 = x * this.tileSize + this.margin.x 58 | let y2 = y * this.tileSize + this.margin.y 59 | x2 -= 45 // minus the half of a the box 60 | y2 -= 45 // minus the half of a the box 61 | if ( 62 | this.collideRect( 63 | { x: x1, y: y1, width: 1, height: 1 }, 64 | { x: x2, y: y2, width: this.tileSize, height: this.tileSize } 65 | ) 66 | ) { 67 | tile = { tile: row[x], x: x2, y: y2 } 68 | break 69 | } 70 | } 71 | }) 72 | 73 | return tile 74 | } 75 | 76 | countTotalLevels() { 77 | return this.levels.length 78 | } 79 | 80 | getLevel() { 81 | return this.levels[this.level] 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/server/game/arcadeObjects/mummy.ts: -------------------------------------------------------------------------------- 1 | import { SKINS } from '../../../constants' 2 | 3 | export default class Mummy extends Phaser.Physics.Arcade.Sprite { 4 | skin = SKINS.MUMMY 5 | id: string 6 | direction: 'left' | 'right' 7 | dead: boolean = false 8 | 9 | constructor(scene: Phaser.Scene, id: number, x: number, y: number) { 10 | super(scene, x, y, '') 11 | scene.add.existing(this) 12 | scene.physics.add.existing(this) 13 | 14 | this.setFrame(0) 15 | 16 | this.direction = Math.random() > 0.5 ? 'left' : 'right' 17 | 18 | // @ts-ignore 19 | this.body.setSize(37, 45) 20 | 21 | this.id = id.toString() 22 | } 23 | 24 | kill() { 25 | if (this.dead) return 26 | this.dead = true 27 | this.scene.time.addEvent({ 28 | delay: 5000, 29 | callback: () => (this.dead = false) 30 | }) 31 | } 32 | 33 | getLookAhead() { 34 | let x = this.direction === 'right' ? this.body.right + 5 : this.body.left - 5 35 | let y = this.body.bottom + 10 36 | return { x, y } 37 | } 38 | 39 | changeDirection(tile: { tile: string; x: number; y: number }) { 40 | if (tile.tile !== 'X') { 41 | this.direction = this.direction === 'right' ? 'left' : 'right' 42 | } 43 | } 44 | 45 | move() { 46 | let velocity = this.direction === 'right' ? 35 : -35 47 | if (this.dead) velocity = 0 48 | this.setVelocityX(velocity) 49 | } 50 | 51 | update() { 52 | this.move() 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/server/game/arcadeObjects/star.ts: -------------------------------------------------------------------------------- 1 | import { SKINS } from '../../../constants' 2 | 3 | export default class Star extends Phaser.Physics.Arcade.Sprite { 4 | skin = SKINS.STAR 5 | id: string 6 | sync = true 7 | tint = 0x00ff00 8 | 9 | constructor(scene: Phaser.Scene, id: number, x: number, y: number) { 10 | super(scene, x, y, '') 11 | scene.add.existing(this) 12 | scene.physics.add.existing(this, true) 13 | 14 | // @ts-ignore 15 | this.body.setSize(24, 22, false) 16 | 17 | this.id = id.toString() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/server/game/config.ts: -------------------------------------------------------------------------------- 1 | import 'phaser' 2 | 3 | const config: Phaser.Types.Core.GameConfig = { 4 | type: Phaser.HEADLESS, 5 | parent: 'phaser-game', 6 | width: 1280, 7 | height: 720, 8 | banner: false, 9 | // @ts-ignore 10 | audio: false 11 | } 12 | export default config 13 | 14 | export const arcadePhysics = { 15 | default: 'arcade', 16 | arcade: { 17 | gravity: { y: 1500 } 18 | } 19 | } 20 | 21 | export const matterPhysics = { 22 | default: 'matter', 23 | matter: { 24 | gravity: { 25 | y: 2 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/server/game/game.ts: -------------------------------------------------------------------------------- 1 | import '@geckos.io/phaser-on-nodejs' 2 | import commonConfig, { arcadePhysics, matterPhysics } from './config' 3 | 4 | import ArcadeScene from './scenes/arcadeScene' 5 | import MatterScene from './scenes/matterScene' 6 | import RoomManager from '../managers/roomManager' 7 | 8 | export class PhaserGame extends Phaser.Game { 9 | constructor(config: Phaser.Types.Core.GameConfig) { 10 | super(config) 11 | } 12 | } 13 | 14 | const Game = (roomManager: RoomManager, roomId: string, options: { scene: string; level: number }) => { 15 | let config = { ...commonConfig } 16 | 17 | if (options.scene === 'ArcadeScene') { 18 | config.scene = [ArcadeScene] 19 | config.physics = arcadePhysics 20 | } 21 | if (options.scene === 'MatterScene') { 22 | config.scene = [MatterScene] 23 | config.physics = matterPhysics 24 | } 25 | 26 | // @ts-ignore 27 | config.customEnvironment = true 28 | 29 | // a very hackie trick to pass some custom data 30 | // but it work well :) 31 | config.callbacks = { 32 | preBoot: () => { 33 | return { level: +options.level, roomManager, roomId } 34 | } 35 | } 36 | 37 | return new PhaserGame(config) 38 | } 39 | export default Game 40 | -------------------------------------------------------------------------------- /src/server/game/matterObjects/box.ts: -------------------------------------------------------------------------------- 1 | import MatterGameObject from './matterGameObject' 2 | import { SKINS } from '../../../constants' 3 | 4 | export default class Box extends MatterGameObject { 5 | lifeTime: number 6 | 7 | constructor(public scene: Phaser.Scene, x: number, y: number) { 8 | super(scene, SKINS.BOX) 9 | 10 | this.addBody( 11 | this.Matter.Bodies.rectangle(x, y, 95, 95, { 12 | friction: 0.1, 13 | chamfer: { radius: 14 }, 14 | label: 'box', 15 | density: 0.000125 16 | }) 17 | ) 18 | 19 | this.lifeTime = Phaser.Math.RND.integerInRange(1000 * 15, 1000 * 45) 20 | this.setTimer() 21 | } 22 | 23 | setTimer() { 24 | this.scene.time.addEvent({ 25 | delay: this.lifeTime, 26 | callback: () => { 27 | this.kill() 28 | } 29 | }) 30 | } 31 | 32 | revive(x: number, y: number) { 33 | super.revive(x, y) 34 | this.setTimer() 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/server/game/matterObjects/dude.ts: -------------------------------------------------------------------------------- 1 | import MatterGameObject from './matterGameObject' 2 | import { SKINS } from '../../../constants' 3 | 4 | export default class Dude extends MatterGameObject { 5 | maxVelocity = { 6 | x: 6, 7 | y: 12 8 | } 9 | width = 32 10 | height = 48 11 | 12 | shouldUpdate = true 13 | 14 | sensors: any 15 | mainBody: any 16 | translateX = 0 17 | translateY = 0 18 | 19 | jumpLocked = false 20 | 21 | move = { 22 | leftAllowed: true, 23 | rightAllowed: true 24 | } 25 | touching = { 26 | left: false, 27 | right: false, 28 | bottom: false 29 | } 30 | updates: any = {} 31 | 32 | constructor(scene: Phaser.Scene, x: number, y: number, public clientId: number, public socketId: string) { 33 | super(scene, SKINS.DUDE) 34 | 35 | let h = this.height 36 | let w = this.width - 4 37 | 38 | console.log('clientId', clientId) 39 | 40 | this.mainBody = this.Matter.Bodies.rectangle(x, y, w, h, { 41 | density: 0.001, 42 | friction: 0.1, 43 | frictionStatic: 0.1, 44 | label: 'dude', 45 | chamfer: { radius: 10 } 46 | }) 47 | this.sensors = { 48 | bottom: this.Matter.Bodies.rectangle(x, y + h / 2 + 2 / 2, w * 0.35, 4, { 49 | isSensor: true 50 | }), 51 | left: this.Matter.Bodies.rectangle(x - w / 2 - 4 / 2, y + 0, 4, h * 0.9, { 52 | isSensor: true 53 | }), 54 | right: this.Matter.Bodies.rectangle(x + w / 2 + 4 / 2, y + 0, 4, h * 0.9, { 55 | isSensor: true 56 | }) 57 | } 58 | this.addBodies([this.mainBody, this.sensors.bottom, this.sensors.left, this.sensors.right]) 59 | 60 | this.setSensorLabel() 61 | 62 | this.Matter.Body.setInertia(this.body, Infinity) // setFixedRotation 63 | } 64 | 65 | setTranslate(x: number, y: number = 0) { 66 | this.translateX = x 67 | this.translateY = y 68 | } 69 | 70 | translate() { 71 | if (this.translateX !== 0 || this.translateY !== 0) { 72 | this.Matter.Body.setPosition(this.body, { 73 | x: this.body.position.x + this.translateX, 74 | y: this.body.position.y + this.translateY 75 | }) 76 | this.translateX = 0 77 | this.translateY = 0 78 | } 79 | } 80 | 81 | setSensorLabel() { 82 | this.sensors.bottom.label = `dudeBottomSensor_${this.clientId}` 83 | this.sensors.left.label = `dudeLeftSensor_${this.clientId}` 84 | this.sensors.right.label = `dudeRightSensor_${this.clientId}` 85 | } 86 | 87 | revive(x: number, y: number, clientId: number, socketId: string) { 88 | super.revive(x, y) 89 | this.clientId = clientId 90 | this.socketId = socketId 91 | this.setSensorLabel() 92 | } 93 | 94 | lockJump() { 95 | this.jumpLocked = true 96 | this.scene.time.addEvent({ 97 | delay: 250, 98 | callback: () => (this.jumpLocked = false) 99 | }) 100 | } 101 | 102 | setUpdates(updates: any) { 103 | this.shouldUpdate = true 104 | this.updates = updates 105 | } 106 | 107 | update(force = false) { 108 | this.animation = 'idle' 109 | 110 | if (!force && !this.shouldUpdate) return 111 | 112 | const updates = this.updates 113 | 114 | let x = updates.left && this.move.leftAllowed ? -0.01 : updates.right && this.move.rightAllowed ? 0.01 : 0 115 | let y = !this.jumpLocked && updates.up && this.touching.bottom ? -this.maxVelocity.y : 0 116 | if (y !== 0) this.lockJump() 117 | 118 | // We use setVelocity to jump and applyForce to move right and left 119 | 120 | // Jump 121 | if (y !== 0) this.Matter.Body.setVelocity(this.body, { x: this.body.velocity.x, y }) 122 | 123 | // Move 124 | this.Matter.Body.applyForce(this.body, { x: 0, y: 0 }, { x, y: 0 }) 125 | 126 | // check max velocity 127 | let maxVelocityX = 128 | this.body.velocity.x > this.maxVelocity.x ? 1 : this.body.velocity.x < -this.maxVelocity.x ? -1 : null 129 | if (maxVelocityX) 130 | this.Matter.Body.setVelocity(this.body, { x: this.maxVelocity.x * maxVelocityX, y: this.body.velocity.y }) 131 | 132 | // set velocity X to zero 133 | if (!updates.left && !updates.right) { 134 | this.Matter.Body.setVelocity(this.body, { x: this.body.velocity.x * 0.5, y: this.body.velocity.y }) 135 | } 136 | 137 | this.animation = this.body.velocity.x >= 0.5 ? 'right' : this.body.velocity.x <= -0.5 ? 'left' : 'idle' 138 | 139 | this.translate() 140 | 141 | this.touching = { 142 | left: false, 143 | right: false, 144 | bottom: false 145 | } 146 | this.move = { 147 | leftAllowed: true, 148 | rightAllowed: true 149 | } 150 | this.updates = {} 151 | this.shouldUpdate = false 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/server/game/matterObjects/matterGameObject.ts: -------------------------------------------------------------------------------- 1 | export default class MatterGameObject { 2 | Matter: any 3 | body: any 4 | clientId: number | undefined = undefined 5 | dead = false 6 | prevDead = false 7 | angle = 0 // in DEG 8 | prevAngle = -1 // in DEG 9 | animation: string = 'idle' 10 | prevAnimation: string = 'idle' 11 | tint = 0x000000 12 | 13 | constructor(public scene: Phaser.Scene, public skin: number) { 14 | this.Matter = Phaser.Physics.Matter.Matter 15 | } 16 | 17 | protected addBody(body: any) { 18 | this.body = body 19 | this.body.prevVelocity = { x: 0, y: 0 } 20 | this.scene.matter.world.add(this.body) 21 | } 22 | 23 | protected addBodies(bodies: any[]) { 24 | this.body = this.Matter.Body.create({ 25 | parts: bodies.map(body => body) 26 | }) 27 | this.body.prevVelocity = { x: 0, y: 0 } 28 | this.scene.matter.world.add(this.body) 29 | } 30 | 31 | preUpdate(arg: any = undefined) { 32 | this.angle = Phaser.Math.RadToDeg(this.body.angle) 33 | } 34 | 35 | update(arg: any = undefined) {} 36 | 37 | postUpdate(arg: any = undefined) { 38 | if (this.dead && !this.prevDead) this.prevDead = true 39 | else if (!this.dead && this.prevDead) this.prevDead = false 40 | 41 | this.body.prevVelocity = { ...this.body.velocity } 42 | this.prevAngle = this.angle 43 | this.prevAnimation = this.animation 44 | } 45 | 46 | revive(x: number, y: number, clientId: number | undefined = undefined, socketId: string | undefined = undefined) { 47 | this.kill(false) 48 | this.Matter.Body.setPosition(this.body, { x, y }) 49 | } 50 | 51 | kill(dead: boolean = true) { 52 | this.dead = dead 53 | if (dead) this.Matter.Body.setPosition(this.body, { x: -1000, y: -1000 }) 54 | this.Matter.Sleeping.set(this.body, dead) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/server/game/matterObjects/matterGameObjectGroup.ts: -------------------------------------------------------------------------------- 1 | import Dude from './dude' 2 | import Box from './box' 3 | import MatterGameObject from './matterGameObject' 4 | import Star from './star' 5 | import { SKINS } from '../../../constants' 6 | 7 | interface GameObjectGroupAddOptions { 8 | socketId?: string 9 | clientId?: number 10 | category?: string 11 | } 12 | 13 | export default class GameObjectGroup { 14 | Matter: any 15 | 16 | constructor(public scene: Phaser.Scene, public objects: MatterGameObject[]) { 17 | this.Matter = Phaser.Physics.Matter.Matter 18 | } 19 | 20 | killById(id: string) { 21 | this.objects.forEach((obj: any) => { 22 | if (obj.body.id === id) obj.kill() 23 | }) 24 | } 25 | 26 | getObjectById(id: string) { 27 | let object = undefined 28 | this.objects.forEach((obj: any) => { 29 | if (obj.body.id === id) object = obj 30 | }) 31 | return object 32 | } 33 | 34 | add(x: number, y: number, skin: number, options: GameObjectGroupAddOptions = {}) { 35 | let dead = this.objects.filter(obj => obj.dead && obj.skin === skin) 36 | let alive = this.objects.filter(obj => !obj.dead && obj.skin === skin) 37 | 38 | const { clientId, socketId, category } = options 39 | 40 | // allow not more than 100 alive objects per skin 41 | if (alive.length >= 100) return 42 | 43 | let object: MatterGameObject | null = null 44 | 45 | if (dead.length > 0) { 46 | // revive the first dead object and set its x and y 47 | object = dead[0] 48 | object.revive(x, y, clientId, socketId) 49 | } else { 50 | // create a new object and add it to the objects array 51 | if (skin === SKINS.BOX) object = new Box(this.scene, x, y) 52 | else if (skin === SKINS.STAR) object = new Star(this.scene, x, y, category) 53 | else if (typeof clientId !== 'undefined' && typeof socketId !== 'undefined') 54 | object = new Dude(this.scene, x, y, clientId, socketId) 55 | if (object) this.objects.push(object) 56 | } 57 | 58 | // Rotate the box 59 | // TODO(yandeu) this should be inside the boxObject class 60 | if (skin === SKINS.BOX && object) this.Matter.Body.rotate(object.body, Math.random() * 2) 61 | 62 | return object 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/server/game/matterObjects/star.ts: -------------------------------------------------------------------------------- 1 | import MatterGameObject from './matterGameObject' 2 | import { SKINS } from '../../../constants' 3 | 4 | export default class Star extends MatterGameObject { 5 | scale: number = 1 6 | 7 | constructor( 8 | public scene: Phaser.Scene, 9 | public x: number, 10 | public y: number, 11 | public category: string | undefined = undefined 12 | ) { 13 | super(scene, SKINS.STAR) 14 | 15 | if (category === 'big') { 16 | this.tint = 0xff7200 17 | this.scale = 3 18 | } 19 | if (category === 'medium') { 20 | this.scale = 2 21 | } 22 | 23 | this.addBody( 24 | this.Matter.Bodies.rectangle(x, y, 24 * this.scale, 22 * this.scale, { 25 | chamfer: { radius: 14 }, 26 | label: 'star', 27 | isStatic: true, 28 | isSensor: true 29 | }) 30 | ) 31 | } 32 | 33 | setReviveTimer() { 34 | this.scene.time.addEvent({ 35 | delay: 15000, 36 | callback: () => { 37 | super.revive(this.x, this.y) 38 | } 39 | }) 40 | } 41 | 42 | kill(dead: boolean = true) { 43 | this.dead = dead 44 | if (dead) this.Matter.Body.setPosition(this.body, { x: -1000, y: -1000 }) 45 | this.Matter.Sleeping.set(this.body, dead) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/server/game/scenes/arcadeScene.ts: -------------------------------------------------------------------------------- 1 | import { world } from '../../../client/config' 2 | import Box from '../arcadeObjects/box' 3 | import Dude from '../arcadeObjects/dude' 4 | import Cursors from '../../../client/components/cursors' 5 | import Star from '../arcadeObjects/star' 6 | import Mummy from '../arcadeObjects/mummy' 7 | import Map from '../arcadeObjects/map' 8 | import SyncManager from '../../managers/syncManager' 9 | import RoomManager from '../../managers/roomManager' 10 | import { SKINS } from '../../../constants' 11 | 12 | export default class MainScene extends Phaser.Scene { 13 | id = 0 14 | dudeGroup: Phaser.GameObjects.Group 15 | boxGroup: Phaser.GameObjects.Group 16 | mummyGroup: Phaser.GameObjects.Group 17 | star: Star 18 | debug: any = {} 19 | level = 0 20 | map: Map 21 | objectsToSync: any = {} 22 | tick = 0 23 | roomManager: RoomManager 24 | roomId: string 25 | 26 | constructor() { 27 | // @ts-ignore 28 | super({ key: 'MainScene', plugins: PHYSICS_DEBUG ? null : ['Clock'], active: false, cameras: null }) 29 | // see all scene plugins: 30 | // Phaser.Plugins.DefaultScene 31 | // https://github.com/photonstorm/phaser/blob/master/src/plugins/DefaultPlugins.js#L76 32 | } 33 | 34 | /** Create a new object id */ 35 | newId() { 36 | return this.id++ 37 | } 38 | 39 | init() { 40 | try { 41 | //@ts-ignore 42 | const { level = 0, roomId, roomManager } = this.game.config.preBoot() 43 | this.level = level 44 | this.roomManager = roomManager 45 | this.roomId = roomId 46 | } catch (error) { 47 | if (!PHYSICS_DEBUG) console.error('onInit() failed!') 48 | } 49 | } 50 | 51 | create() { 52 | // this will stop the scene 53 | this.events.addListener('stopScene', () => { 54 | this.roomManager.stats.removeTotalObjects(this.roomId) 55 | this.scene.stop() 56 | this.roomManager.stats.log(`Scene in roomId ${this.roomId} has stopped!`) 57 | }) 58 | 59 | this.physics.world.setBounds(world.x, world.y, world.width, world.height) 60 | this.dudeGroup = this.add.group() 61 | this.boxGroup = this.add.group() 62 | this.mummyGroup = this.add.group() 63 | this.map = new Map(this, world, this.level) 64 | const level = this.map.getLevel() 65 | 66 | // generate the level 67 | level.forEach((row, y) => { 68 | for (let x = 0; x < row.length; x++) { 69 | const xx = x * this.map.tileSize + this.map.margin.x 70 | const yy = y * this.map.tileSize + this.map.margin.y 71 | if (row[x] === 'X') this.boxGroup.add(new Box(this, this.newId(), xx, yy)) 72 | if (row[x] === 'G') this.star = new Star(this, this.newId(), xx, yy) 73 | if (row[x] === 'M') this.mummyGroup.add(new Mummy(this, this.newId(), xx, yy)) 74 | } 75 | }) 76 | 77 | if (PHYSICS_DEBUG) { 78 | this.add 79 | .text(24, 24, 'Physics Debugging Version\nMove with Arrow Keys', { 80 | fontSize: 36 81 | }) 82 | .setScrollFactor(0) 83 | .setOrigin(0) 84 | .setAlpha(0.6) 85 | // mock socket 86 | this.debug.socket = { emit: () => {} } 87 | this.debug.cursors = new Cursors(this, this.debug.socket) 88 | this.debug.dude = new Dude(this, this.newId(), { clientId: 55555, socketId: 'some-socket-id' }) 89 | this.dudeGroup.add(this.debug.dude) 90 | 91 | // this helps debugging 92 | this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => { 93 | console.log(pointer.worldX, pointer.worldY) 94 | console.log(this.map.getTileByCoordinates({ x: pointer.worldX, y: pointer.worldY })) 95 | }) 96 | } 97 | 98 | this.events.addListener('createDude', (clientId: number, socketId: string) => { 99 | let dude: Dude = this.dudeGroup.getFirstDead() 100 | if (dude) { 101 | dude.revive(clientId, socketId) 102 | } else { 103 | dude = new Dude(this, this.newId(), { clientId, socketId }) 104 | this.dudeGroup.add(dude) 105 | } 106 | }) 107 | 108 | this.events.addListener('U' /* short for updateDude */, (res: any) => { 109 | // @ts-ignore 110 | let dudes: Dude[] = this.dudeGroup.children.getArray().filter((dude: Dude) => { 111 | return dude.clientId && dude.clientId === res.clientId 112 | }) 113 | if (dudes[0]) { 114 | let b = res.updates 115 | let updates = { 116 | left: b === 1 || b === 5 ? true : false, 117 | right: b === 2 || b === 6 ? true : false, 118 | up: b === 4 || b === 6 || b === 5 ? true : false, 119 | none: b === 8 ? true : false 120 | } 121 | dudes[0].setUpdates(updates) 122 | } 123 | }) 124 | 125 | this.events.addListener('removeDude', (clientId: number) => { 126 | // @ts-ignore 127 | this.dudeGroup.children.iterate((dude: Dude) => { 128 | if (dude.clientId === clientId) { 129 | dude.kill() 130 | } 131 | }) 132 | }) 133 | 134 | this.physics.add.collider(this.dudeGroup, this.boxGroup) 135 | this.physics.add.collider(this.mummyGroup, this.boxGroup) 136 | // @ts-ignore 137 | this.physics.add.overlap(this.mummyGroup, this.dudeGroup, (mummy: Mummy, dude: Dude) => { 138 | if (mummy.dead) return 139 | if (mummy.body.touching.up && dude.body.touching.down) { 140 | dude.setVelocityY(-300) 141 | mummy.kill() 142 | } else { 143 | dude.gotHit() 144 | } 145 | }) 146 | // @ts-ignore 147 | this.physics.add.overlap(this.dudeGroup, this.star, (dude: Dude, star: Star) => { 148 | if (dude.dead) return 149 | dude.kill() 150 | 151 | let nextLevel = this.level + 1 >= this.map.countTotalLevels() ? 0 : this.level + 1 152 | let socket = this.roomManager.ioNspGame.sockets[dude.socketId] as any 153 | 154 | this.roomManager.changeRoom(socket, 'ArcadeScene', nextLevel) 155 | }) 156 | } 157 | 158 | /** Sends the initial state to the client */ 159 | getInitialState() { 160 | let objects: any[] = [] 161 | 162 | SyncManager.prepareFromPhaserGroup(this.boxGroup, objects) 163 | SyncManager.prepareFromPhaserGroup(this.dudeGroup, objects) 164 | SyncManager.prepareFromPhaserSprite(this.star, objects) 165 | 166 | return SyncManager.encode(objects) 167 | } 168 | 169 | update() { 170 | this.tick++ 171 | if (this.tick > 1000000) this.tick = 0 172 | 173 | // @ts-ignore 174 | this.mummyGroup.children.iterate((mummy: Mummy) => { 175 | let coordinates = mummy.getLookAhead() 176 | let tile = this.map.getTileByCoordinates(coordinates) 177 | mummy.changeDirection(tile) 178 | mummy.update() 179 | }) 180 | 181 | if (PHYSICS_DEBUG) { 182 | this.debug.cursors.update() 183 | let cursorsDown = this.debug.cursors.cursorsDown() 184 | let dude: Dude = this.debug.dude 185 | dude.setUpdates(cursorsDown) 186 | dude.update() 187 | this.cameras.main.setScroll( 188 | dude.body.position.x - this.cameras.main.width / 2, 189 | dude.body.position.y - this.cameras.main.height * 0.8 190 | ) 191 | } 192 | 193 | if (PHYSICS_DEBUG) return 194 | 195 | const prepareObjectToSync = (obj: any) => { 196 | let cleanObjectToSync = SyncManager.cleanObjectToSync(obj) 197 | this.objectsToSync = SyncManager.mergeObjectToSync(cleanObjectToSync, this.objectsToSync) 198 | } 199 | 200 | if (this.star && this.star.sync) { 201 | let starObj = { 202 | skin: this.star.skin, 203 | tint: this.star.tint, 204 | id: this.star.id, 205 | x: this.star.body.position.x + this.star.body.width / 2, 206 | y: this.star.body.position.y + this.star.body.height / 2 207 | } 208 | prepareObjectToSync(starObj) 209 | this.star.sync = false 210 | } 211 | 212 | // @ts-ignore 213 | this.mummyGroup.children.iterate((child: Mummy) => { 214 | let object = { 215 | skin: child.skin, 216 | direction: child.direction, 217 | id: child.id, 218 | x: child.body.position.x + child.body.width / 2, 219 | y: child.body.position.y + child.body.height / 2 220 | } 221 | prepareObjectToSync(object) 222 | }) 223 | 224 | // @ts-ignore 225 | this.boxGroup.children.iterate((child: Box) => { 226 | if (child.sync) { 227 | let object = { 228 | skin: child.skin, 229 | id: child.id, 230 | x: child.body.position.x + child.body.width / 2, 231 | y: child.body.position.y + child.body.height / 2 232 | } 233 | prepareObjectToSync(object) 234 | } 235 | child.sync = false 236 | }) 237 | // @ts-ignore 238 | this.dudeGroup.children.iterate((child: Dude) => { 239 | child.update() 240 | // we only update the dude if one if the 4 properties below have changed 241 | let x = child.prevPosition.x.toFixed(0) !== child.body.position.x.toFixed(0) 242 | let y = child.prevPosition.y.toFixed(0) !== child.body.position.y.toFixed(0) 243 | let dead = child.prevDead !== child.dead 244 | let color = child.prevColor.toString() !== child.color.toString() 245 | if (x || y || dead || color) { 246 | let object = { 247 | animation: child.animation, 248 | dead: child.dead, 249 | clientId: child.clientId, 250 | skin: child.skin, 251 | tint: child.color, 252 | id: child.id, 253 | x: child.body.position.x + child.body.width / 2, 254 | y: child.body.position.y + child.body.height / 2 255 | } 256 | prepareObjectToSync(object) 257 | } 258 | child.postUpdate() 259 | }) 260 | 261 | let send: any[] = [] 262 | 263 | Object.keys(this.objectsToSync).forEach(key => { 264 | // we only sync the mummies on every 3th frame 265 | if (this.objectsToSync[key].skin === SKINS.MUMMY) { 266 | if (this.tick % 3 === 0) { 267 | send.push(this.objectsToSync[key]) 268 | delete this.objectsToSync[key] 269 | } 270 | } else { 271 | send.push(this.objectsToSync[key]) 272 | delete this.objectsToSync[key] 273 | } 274 | }) 275 | 276 | if (send.length > 0) { 277 | // send the objects to sync to all connected clients in this.roomId 278 | this.roomManager.ioNspGame 279 | .in(this.roomId) 280 | .emit('S' /* short for syncGame */, { O /* short for objects */: SyncManager.encode(send) }) 281 | } 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /src/server/game/scenes/matterScene.ts: -------------------------------------------------------------------------------- 1 | import GameObjectGroup from '../matterObjects/matterGameObjectGroup' 2 | import MatterGameObject from '../matterObjects/matterGameObject' 3 | import Dude from '../matterObjects/dude' 4 | import Star from '../matterObjects/star' 5 | import { world } from '../../../client/config' 6 | 7 | // PHYSICS_DEBUG 8 | import Cursors from '../../../client/components/cursors' 9 | import SyncManager from '../../managers/syncManager' 10 | import RoomManager from '../../managers/roomManager' 11 | import { SKINS } from '../../../constants' 12 | 13 | export default class MainScene extends Phaser.Scene { 14 | objects: MatterGameObject[] = [] 15 | objectsToSync: any = {} 16 | debug: any = {} 17 | tick = 0 18 | level: number 19 | roomManager: RoomManager 20 | roomId: string 21 | 22 | constructor() { 23 | super({ key: 'MainScene', plugins: PHYSICS_DEBUG ? null : ['Clock'] }) 24 | // see all scene plugins: 25 | // Phaser.Plugins.DefaultScene 26 | // https://github.com/photonstorm/phaser/blob/master/src/plugins/DefaultPlugins.js#L76 27 | } 28 | 29 | init() { 30 | try { 31 | //@ts-ignore 32 | const { level = 0, roomId, roomManager } = this.game.config.preBoot() 33 | this.level = level 34 | this.roomManager = roomManager 35 | this.roomId = roomId 36 | } catch (error) { 37 | if (!PHYSICS_DEBUG) console.error('onInit() failed!') 38 | } 39 | } 40 | 41 | create() { 42 | const Matter = Phaser.Physics.Matter.Matter 43 | const worldCenterX = (world.x + world.width) / 2 44 | 45 | // add and modify the world bounds 46 | let bounds: any = this.matter.world.setBounds(world.x, world.y, world.width, world.height) 47 | Object.keys(bounds.walls).forEach((key: any) => { 48 | let body = bounds.walls[key] 49 | Matter.Body.set(body, { friction: 0.05, frictionStatic: 0.05, frictionAir: 0.01 }) 50 | // we do not need the top, so we set it to isSensor 51 | if (key === 'top') Matter.Body.set(body, { isSensor: true }) 52 | }) 53 | 54 | // instantiate the GameObjectGroup 55 | let gameObjectGroup = new GameObjectGroup(this, this.objects) 56 | 57 | // this will stop the scene 58 | this.events.addListener('stopScene', () => { 59 | this.objects.forEach(obj => { 60 | this.matter.world.remove(this.matter.world, obj.body) 61 | }) 62 | this.roomManager.stats.removeTotalObjects(this.roomId) 63 | this.scene.stop() 64 | this.roomManager.stats.log(`Scene in roomId ${this.roomId} has stopped!`) 65 | }) 66 | 67 | // creates a new dude, when a new user connects 68 | this.events.addListener('createDude', (clientId: number, socketId: string) => { 69 | let leftX = Phaser.Math.RND.integerInRange(world.x + 100, this.cameras.main.width / 2 - 640) 70 | let rightX = Phaser.Math.RND.integerInRange(this.cameras.main.width / 2 + 640, world.x + world.width - 100) 71 | let x = Math.random() > 0.5 ? leftX : rightX 72 | let y = -50 73 | gameObjectGroup.add(x, y, SKINS.DUDE, { clientId, socketId }) 74 | }) 75 | 76 | // updates the position of a dude 77 | this.events.addListener('U' /* short for updateDude */, (res: any) => { 78 | let dudes: Dude[] = this.objects.filter(obj => obj.clientId && obj.clientId === res.clientId) as any 79 | if (dudes[0]) { 80 | let b = res.updates 81 | let updates = { 82 | left: b === 1 || b === 5 ? true : false, 83 | right: b === 2 || b === 6 ? true : false, 84 | up: b === 4 || b === 6 || b === 5 ? true : false, 85 | none: b === 8 ? true : false 86 | } 87 | dudes[0].setUpdates(updates) 88 | } 89 | }) 90 | 91 | // removes a dude 92 | this.events.addListener('removeDude', (clientId: number) => { 93 | let dudes = this.objects.filter(obj => obj.clientId && obj.clientId === clientId) 94 | dudes.forEach(dude => dude.kill()) 95 | }) 96 | 97 | // adds another box every 1.2 seconds 98 | this.time.addEvent({ 99 | delay: 1200, 100 | loop: true, 101 | callback: () => { 102 | let x = Phaser.Math.RND.integerInRange(worldCenterX - 250 - 640, worldCenterX + 640 + 250) 103 | let y = 100 104 | gameObjectGroup.add(x, y, SKINS.BOX) 105 | } 106 | }) 107 | 108 | if (PHYSICS_DEBUG) { 109 | this.add 110 | .text(24, 24, 'Physics Debugging Version\nMove with Arrow Keys', { 111 | fontSize: 36 112 | }) 113 | .setScrollFactor(0) 114 | .setOrigin(0) 115 | .setAlpha(0.6) 116 | this.debug.socket = { emit: () => {} } // mock socket 117 | this.debug.cursors = new Cursors(this, this.debug.socket) 118 | this.debug.dude = gameObjectGroup.add(400, 400, SKINS.DUDE, { clientId: 55555, socketId: 'some-socket-id' }) 119 | } 120 | 121 | if (!PHYSICS_DEBUG) { 122 | this.time.addEvent({ 123 | delay: 5000, 124 | loop: true, 125 | callback: () => { 126 | this.roomManager.stats.setTotalObjects(this.roomId, this.objects.length) 127 | } 128 | }) 129 | } 130 | 131 | // add the big star 132 | gameObjectGroup.add(worldCenterX, world.height - 320 - 100 - 115, SKINS.STAR, { 133 | category: 'big' 134 | }) 135 | 136 | // add medium stars 137 | for (let x = worldCenterX - 128; x < worldCenterX + 128 + 64; x += 128) 138 | gameObjectGroup.add(x, world.height - 320 - 100, SKINS.STAR, { category: 'medium' }) 139 | 140 | // add yellow stars 141 | for (let x = worldCenterX - 160 - 80; x < worldCenterX + 320 + 80; x += 160) 142 | gameObjectGroup.add(x, world.height - 320, SKINS.STAR) 143 | 144 | // create 4 boxes at server start 145 | gameObjectGroup.add(1280, 640, SKINS.BOX) 146 | gameObjectGroup.add(1280, 640, SKINS.BOX) 147 | gameObjectGroup.add(1280, 640, SKINS.BOX) 148 | gameObjectGroup.add(1280, 640, SKINS.BOX) 149 | 150 | // check for collisions 151 | const collisionEvent = (event: any) => { 152 | event.pairs.forEach((pair: any) => { 153 | const { bodyA, bodyB } = pair 154 | const labels: string[] = [bodyA.label, bodyB.label] 155 | 156 | // Dude hits star 157 | if (labels.includes('dude') && labels.includes('star')) { 158 | let starBody = bodyA.label === 'star' ? bodyA : bodyB 159 | let star: Star = gameObjectGroup.getObjectById(starBody.id) as any 160 | if (star) { 161 | star.kill() 162 | star.setReviveTimer() 163 | } 164 | } 165 | 166 | // Dude's sensor hits another body 167 | if (/Sensor/.test(bodyA.label) || /Sensor/.test(bodyB.label)) { 168 | let sensorBody = /Sensor/.test(bodyA.label) ? bodyA : bodyB 169 | let otherBody = /Sensor/.test(bodyA.label) ? bodyB : bodyA 170 | if (otherBody.isSensor) return 171 | 172 | let dude: Dude = gameObjectGroup.getObjectById(sensorBody.parent.id) as any 173 | if (dude) { 174 | let sepPadding = 2 175 | if (otherBody.isStatic) { 176 | sepPadding = 0.1 177 | } 178 | 179 | let sep = pair.separation - sepPadding 180 | 181 | if (sensorBody === dude.sensors.left) { 182 | dude.move.leftAllowed = !otherBody.isStatic 183 | dude.touching.left = true 184 | if (pair.separation > sepPadding) { 185 | dude.setTranslate(sep) 186 | dude.translate() 187 | } 188 | } else if (sensorBody === dude.sensors.right) { 189 | dude.move.rightAllowed = !otherBody.isStatic 190 | dude.touching.right = true 191 | if (pair.separation > sepPadding) { 192 | dude.setTranslate(-sep) 193 | dude.translate() 194 | } 195 | } else if (sensorBody === dude.sensors.bottom) { 196 | dude.touching.bottom = true 197 | } 198 | } 199 | } 200 | }) 201 | } 202 | // https://itnext.io/modular-game-worlds-in-phaser-3-tilemaps-5-matter-physics-platformer-d14d1f614557 203 | this.matter.world.on('collisionstart', collisionEvent) 204 | this.matter.world.on('collisionactive', collisionEvent) 205 | } 206 | 207 | /** Sends the initial state to the client */ 208 | getInitialState() { 209 | let objects: any[] = [] 210 | SyncManager.prepareFromMatterGameObject(this.objects, objects) 211 | return SyncManager.encode(objects) 212 | } 213 | 214 | update(time: number, delta: number) { 215 | this.tick++ 216 | if (this.tick > 1000000) this.tick = 0 217 | 218 | if (PHYSICS_DEBUG) { 219 | this.debug.cursors.update() 220 | let cursorsDown = this.debug.cursors.cursorsDown() 221 | let dude: Dude = this.debug.dude 222 | dude.setUpdates(cursorsDown) 223 | dude.update() 224 | this.cameras.main.setScroll( 225 | dude.body.position.x - this.cameras.main.width / 2, 226 | dude.body.position.y - this.cameras.main.height * 0.8 227 | ) 228 | } 229 | 230 | if (!PHYSICS_DEBUG) { 231 | this.objects.forEach(obj => { 232 | if (obj.body.position.y > world.height) obj.kill() 233 | 234 | obj.preUpdate() 235 | obj.update() 236 | 237 | const roundToEvenNumber = (number: number) => { 238 | try { 239 | return +(Math.round(number / 2) * 2).toFixed(0) 240 | } catch (e) { 241 | return 0 242 | } 243 | } 244 | 245 | // only send the object to the client if one of these properties have changed 246 | let dead = obj.dead != obj.prevDead 247 | let x = obj.body.position.x.toFixed(0) != obj.body.positionPrev.x.toFixed(0) 248 | let y = obj.body.position.y.toFixed(0) != obj.body.positionPrev.y.toFixed(0) 249 | let angle = roundToEvenNumber(obj.angle) != roundToEvenNumber(obj.prevAngle) 250 | let animation = obj.animation !== obj.prevAnimation 251 | if (dead || x || y || angle || animation) { 252 | let theObj: { [key: string]: any } = { 253 | // it always needs to have an id! 254 | id: obj.body.id, 255 | x: +obj.body.position.x.toFixed(0), 256 | y: +obj.body.position.y.toFixed(0), 257 | angle: angle ? roundToEvenNumber(obj.angle) : null, 258 | dead: dead ? obj.dead : null, 259 | animation: obj.animation ? obj.animation : null, 260 | clientId: obj.clientId ? obj.clientId : null, 261 | skin: obj.skin 262 | } 263 | let cleanObjectToSync = SyncManager.cleanObjectToSync(theObj) 264 | this.objectsToSync = SyncManager.mergeObjectToSync(cleanObjectToSync, this.objectsToSync) 265 | } 266 | 267 | // call the postUpdate function on all gameObjects 268 | obj.postUpdate() 269 | }) 270 | 271 | let send: any[] = [] 272 | Object.keys(this.objectsToSync).forEach(key => { 273 | // this syncs the dude on every frame 274 | // but the boxes only on every second frame 275 | // (safes a lot of bandwidth) 276 | if (this.objectsToSync[key].skin === SKINS.BOX) { 277 | if (this.tick % 2 === 0) { 278 | send.push(this.objectsToSync[key]) 279 | delete this.objectsToSync[key] 280 | } 281 | } else { 282 | send.push(this.objectsToSync[key]) 283 | delete this.objectsToSync[key] 284 | } 285 | }) 286 | 287 | if (send.length > 0) { 288 | // send the objects to sync to all connected clients in this.roomId 289 | this.roomManager.ioNspGame 290 | .in(this.roomId) 291 | .emit('S' /* short for syncGame */, { O /* short for objects */: SyncManager.encode(send) }) 292 | } 293 | } 294 | } 295 | } 296 | -------------------------------------------------------------------------------- /src/server/managers/roomManager.ts: -------------------------------------------------------------------------------- 1 | interface GameScene extends Phaser.Scene { 2 | objects: any 3 | } 4 | interface User { 5 | id: string 6 | lastUpdate: number 7 | clientId: number 8 | roomId: string 9 | } 10 | interface Users { 11 | [userId: string]: User 12 | } 13 | interface Room { 14 | roomId: string 15 | // I am not sure if it is safe to publish the roomId to the client 16 | // so we only create a second id 17 | // - If you know if it is safe or not, please tell me :) - 18 | //publicRoomId: string 19 | game: Phaser.Game 20 | scene: GameScene 21 | removing: boolean 22 | users: Users 23 | level: number 24 | sceneKey: string 25 | } 26 | interface Rooms { 27 | [room: string]: Room 28 | } 29 | import Game, { PhaserGame } from '../game/game' 30 | import { Math as phaserMath } from 'phaser' 31 | import { MAX_PLAYERS_PER_ROOM, USER_KICK_TIMEOUT } from '../../constants' 32 | 33 | let randomDataGenerator = new phaserMath.RandomDataGenerator() 34 | 35 | import { v4 as uuidv4 } from 'uuid' 36 | import Stats from '../socket/ioStats' 37 | 38 | export default class RoomManager { 39 | rooms: Rooms = {} 40 | 41 | constructor(public ioNspGame: SocketIO.Namespace, public stats: Stats) { 42 | setInterval(() => { 43 | this.removeInactiveRooms() 44 | this.removeInactiveUsers() 45 | }, 10000) 46 | } 47 | 48 | generateClientId(socket: Socket) { 49 | let clientId = randomDataGenerator.integerInRange(100000, 100000000) 50 | socket.clientId = clientId 51 | socket.emit('clientId', clientId) 52 | } 53 | 54 | // the 2 functions below should be better 55 | async joinRoom(socket: Socket, scene: string, level: number) { 56 | if (typeof scene !== 'string' || typeof level !== 'number') { 57 | console.error('level or scene is not defined in ioGame.ts') 58 | return 59 | } 60 | socket.room = this.chooseRoom({ scene: scene, level: +level }) 61 | 62 | // create a new game instance if this room does not exist yet 63 | if (!this.rooms[socket.room]) { 64 | await this.createRoom(socket.room, scene, +level) 65 | } 66 | 67 | this.addUser(socket) 68 | this.rooms[socket.room].scene.events.emit('createDude', socket.clientId, socket.id) 69 | } 70 | 71 | leaveRoom(socket: Socket) { 72 | this.removeUser(socket.room, socket.id) 73 | this.ioNspGame 74 | .in(socket.room) 75 | .emit('S' /* short for syncGame */, { connectCounter: this.getRoomUsersArray(socket.room).length }) 76 | 77 | if (this.isRemoving(socket.room)) return 78 | this.rooms[socket.room].scene.events.emit('removeDude', socket.clientId) 79 | } 80 | 81 | async changeRoom(socket: Socket, scene: string, level: number) { 82 | this.leaveRoom(socket) 83 | await this.joinRoom(socket, scene, +level) 84 | socket.emit('changingRoom', { scene: scene, level: +level }) 85 | } 86 | 87 | addUser(socket: Socket) { 88 | let newUsers: Users = { 89 | [socket.id]: { 90 | roomId: socket.room, 91 | lastUpdate: Date.now(), 92 | clientId: socket.clientId, 93 | id: socket.id 94 | } 95 | } 96 | 97 | this.rooms[socket.room].users = { 98 | ...this.rooms[socket.room].users, 99 | ...newUsers 100 | } 101 | // join the socket room 102 | socket.join(socket.room) 103 | } 104 | 105 | /** Removed the user from the room */ 106 | removeUser(roomId: string, userId: string, log: boolean = true) { 107 | if (this.ioNspGame.sockets[userId]) this.ioNspGame.sockets[userId].leave(roomId) 108 | 109 | if (this.userExists(roomId, userId)) { 110 | delete this.rooms[roomId].users[userId] 111 | if (log) this.stats.log(`User ${userId} disconnected!`) 112 | return true 113 | } 114 | return false 115 | } 116 | 117 | /** Check if this user exists */ 118 | userExists(roomId: string, userId: string) { 119 | if (this.roomExists(roomId) && this.rooms[roomId].users && this.rooms[roomId].users[userId]) return true 120 | return false 121 | } 122 | 123 | /** Check if this room exists */ 124 | roomExists(roomId: string) { 125 | if (this.rooms && this.rooms[roomId]) return true 126 | return false 127 | } 128 | 129 | isRemoving(roomId: string) { 130 | if (!!!this.rooms[roomId] || this.rooms[roomId].removing) return true 131 | else return false 132 | } 133 | 134 | createRoom = async (roomId: string, scene: string, level: number) => { 135 | this.stats.log(`Create new room ${roomId}`) 136 | 137 | let game: PhaserGame = await Game(this, roomId, { scene, level }) 138 | 139 | this.rooms[roomId] = { 140 | sceneKey: scene, 141 | level: +level, 142 | roomId: roomId, 143 | users: {}, 144 | game: game, 145 | // @ts-ignore 146 | scene: game.scene.keys['MainScene'], 147 | removing: false 148 | } 149 | 150 | this.stats.log(`Room ${roomId} created!`) 151 | } 152 | 153 | removeRoom = async (roomId: string) => { 154 | if (this.rooms[roomId].removing) return 155 | this.stats.log(`Removing room ${roomId}`) 156 | this.rooms[roomId].removing = true 157 | this.rooms[roomId].scene.events.emit('stopScene') 158 | 159 | setTimeout(async () => { 160 | await this.rooms[roomId].game.destroy(true, true) 161 | // @ts-ignore 162 | this.rooms[roomId].game = null 163 | delete this.rooms[roomId] 164 | 165 | this.stats.log(`Room ${roomId} has been removed!`) 166 | this.stats.log(`Remaining rooms: ${Object.keys(this.rooms).length}`) 167 | }, 5000) 168 | } 169 | 170 | chooseRoom = (props: { scene: string; level: number }): string => { 171 | const { scene, level } = props 172 | 173 | let rooms = Object.keys(this.rooms) 174 | 175 | if (rooms.length === 0) return uuidv4() 176 | 177 | // check for the next room with 1 or more free spaces 178 | let chosenRoom = null 179 | for (let i = 0; i < Object.keys(this.rooms).length; i++) { 180 | let room = this.rooms[rooms[i]] 181 | let count = Object.keys(room.users).length 182 | if ( 183 | count < MAX_PLAYERS_PER_ROOM && 184 | room.sceneKey === scene && 185 | room.level === level && 186 | !this.isRemoving(rooms[i]) 187 | ) { 188 | chosenRoom = rooms[i] 189 | break 190 | } 191 | } 192 | if (chosenRoom) return chosenRoom 193 | 194 | // create a new room with a new uuidv4 id 195 | return uuidv4() 196 | } 197 | 198 | getRoomsArray() { 199 | let rooms: Room[] = [] 200 | Object.keys(this.rooms).forEach((roomId) => { 201 | rooms.push(this.rooms[roomId]) 202 | }) 203 | return rooms 204 | } 205 | 206 | /** Returns an Array of all users in a specific room */ 207 | getRoomUsersArray(roomId: string) { 208 | let users: User[] = [] 209 | 210 | if (!this.roomExists(roomId)) return users 211 | 212 | Object.keys(this.rooms[roomId].users).forEach((userId) => { 213 | users.push(this.rooms[roomId].users[userId]) 214 | }) 215 | return users 216 | } 217 | 218 | /** Returns an Array of all users in all rooms */ 219 | getAllUsersArray() { 220 | let users: User[] = [] 221 | Object.keys(this.rooms).forEach((roomId) => { 222 | Object.keys(this.rooms[roomId].users).forEach((userId) => { 223 | users.push(this.rooms[roomId].users[userId]) 224 | }) 225 | }) 226 | return users 227 | } 228 | 229 | disconnectUser(userId: string) { 230 | if (this.ioNspGame.connected && this.ioNspGame.connected[userId]) { 231 | this.ioNspGame.connected[userId].disconnect(true) 232 | return true 233 | } 234 | return false 235 | } 236 | 237 | removeInactiveRooms() { 238 | this.getRoomsArray().forEach((room: Room) => { 239 | if (!room.users || Object.keys(room.users).length === 0) this.removeRoom(room.roomId) 240 | }) 241 | } 242 | 243 | removeInactiveUsers() { 244 | this.getAllUsersArray().forEach((user: User) => { 245 | if (Date.now() - user.lastUpdate > USER_KICK_TIMEOUT) { 246 | let removed = this.removeUser(user.roomId, user.id, false) 247 | let disconnected = this.disconnectUser(user.id) 248 | if (removed && disconnected) { 249 | this.stats.log(`Kick user ${user.clientId} from room ${user.roomId}`) 250 | } 251 | } 252 | }) 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /src/server/managers/syncManager.ts: -------------------------------------------------------------------------------- 1 | import MatterGameObject from '../game/matterObjects/matterGameObject' 2 | 3 | /** Helps preparing the object to sync with the client */ 4 | export default class SyncManager { 5 | constructor() {} 6 | 7 | static prepareFromPhaserGroup(group: Phaser.GameObjects.Group, objects: any) { 8 | group.children.iterate((sprite: any) => { 9 | SyncManager.prepareFromPhaserSprite(sprite, objects) 10 | }) 11 | } 12 | 13 | static prepareFromPhaserSprite(sprite: any, objects: any) { 14 | let obj = { 15 | ...sprite, 16 | ...this.getXY(sprite) 17 | } 18 | objects.push(SyncManager.cleanObjectToSync(obj)) 19 | } 20 | 21 | static prepareFromMatterGameObject(gameObjects: MatterGameObject[], objects: any) { 22 | gameObjects.forEach(obj => { 23 | objects.push(SyncManager.cleanObjectToSync(obj)) 24 | }) 25 | } 26 | 27 | static getXY(child: any) { 28 | return { x: child.body.position.x + child.body.width / 2, y: child.body.position.y + child.body.height / 2 } 29 | } 30 | 31 | static mergeObjectToSync(obj: any, mergeTo: any[]) { 32 | let merged = false 33 | Object.keys(mergeTo).forEach(o => { 34 | if (o === obj.id) { 35 | mergeTo[obj.id] = { 36 | ...mergeTo[obj.id], 37 | ...obj 38 | } 39 | merged = true 40 | } 41 | }) 42 | if (!merged) 43 | mergeTo = { 44 | ...mergeTo, 45 | [obj.id]: obj 46 | } 47 | return mergeTo 48 | } 49 | 50 | static cleanObjectToSync(obj: any) { 51 | const addToObjectToSync = (key: string, prop: any) => { 52 | if (prop !== null) objectToSync = { ...objectToSync, [key]: prop } 53 | } 54 | 55 | let objectToSync: { [key: string]: any } = {} 56 | 57 | addToObjectToSync('id', obj.id || obj.body.id) 58 | addToObjectToSync('x', obj.x || obj.body.position.x || null) 59 | addToObjectToSync('y', obj.y || obj.body.position.y || null) 60 | addToObjectToSync('angle', obj.angle !== 'undefined' ? obj.angle : null) 61 | addToObjectToSync('dead', obj.dead !== 'undefined' ? obj.dead : null) 62 | addToObjectToSync('skin', obj.skin !== 'undefined' ? obj.skin : null) 63 | addToObjectToSync('animation', obj.animation || null) 64 | addToObjectToSync('direction', obj.direction || null) 65 | addToObjectToSync('scale', obj.scale && obj.scale !== 1 ? obj.scale : null) 66 | addToObjectToSync('tint', obj.tint ? obj.tint : null) 67 | addToObjectToSync('clientId', obj.clientId || null) 68 | addToObjectToSync('category', obj.category || null) 69 | 70 | // Object.keys(objectToSync).forEach(key => objectToSync[key] == null && delete objectToSync[key]) 71 | 72 | return objectToSync 73 | } 74 | 75 | static get keys() { 76 | // sort these based on most used 77 | return ['id', 'x', 'y', 'angle', 'dead', 'skin', 'animation', 'direction', 'scale', 'tint', 'clientId', 'category'] 78 | } 79 | 80 | static decode(data: any) { 81 | const keys = SyncManager.keys 82 | let decodedArray: any[] = [] 83 | 84 | let obj: any = {} 85 | data.split(',').forEach((value: string, index: number) => { 86 | let key = keys[index % keys.length] 87 | 88 | // id (radix 36) 89 | if (key === 'id') { 90 | obj[key] = parseInt(value, 36).toString() 91 | } 92 | // numbers 93 | else if (['skin', 'scale'].includes(key)) { 94 | obj[key] = value !== '' ? parseInt(value) : null 95 | } 96 | // numbers (radix 36) 97 | else if (['x', 'y', 'angle', 'clientId'].includes(key)) { 98 | obj[key] = value !== '' ? parseInt(value, 36) : null 99 | } 100 | // booleans 101 | else if (['dead'].includes(key)) { 102 | obj[key] = value === '0' ? false : value === '1' ? true : null 103 | } 104 | // strings 105 | else obj[key] = value !== '' ? value : null 106 | 107 | if (index % keys.length === keys.length - 1) { 108 | decodedArray.push({ ...obj }) 109 | obj = {} 110 | } 111 | }) 112 | 113 | return decodedArray 114 | } 115 | 116 | static encode(objs: any[]) { 117 | const keys = SyncManager.keys 118 | 119 | let encodedString = '' 120 | objs.forEach((obj: any) => { 121 | keys.forEach(key => { 122 | if (typeof obj[key] !== 'undefined') { 123 | let value = obj[key] 124 | 125 | // booleans 126 | if (typeof obj[key] === 'boolean') value = obj[key] === false ? 0 : 1 127 | // some numbers to radix 36 128 | else if (['id', 'x', 'y', 'angle', 'clientId'].includes(key)) { 129 | value = +value 130 | value = +value.toFixed(0) 131 | value = value.toString(36) 132 | } 133 | 134 | encodedString += `${value},` 135 | } else encodedString += ',' 136 | }) 137 | }) 138 | 139 | return encodedString.slice(0, -1) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/server/routes/routes.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import pidusage from 'pidusage' 3 | import path from 'path' 4 | import RoomManager from '../managers/roomManager' 5 | import IoStats from '../socket/ioStats' 6 | 7 | export default class Routes { 8 | router: express.Router 9 | time = new Date() 10 | 11 | constructor(public roomManager: RoomManager, public ioStats: IoStats) { 12 | this.router = express.Router() 13 | 14 | this.router.get('/', (req, res) => { 15 | res.send(` 16 | 17 | 18 | 19 | 20 | 21 | 22 | Phaser 3: Multiplayer Example 23 | 24 | 25 | 43 |

Phaser 3: Real-Time Multiplayer Game with Physics

44 | Play the Game 45 | Debug the Physics 46 | View Server Stats 47 | `) 48 | }) 49 | 50 | this.router.get('/play', (req, res) => { 51 | res.sendFile(path.join(__dirname, '../../dist/client/index.html')) 52 | }) 53 | 54 | this.router.get('/physics', (req, res) => { 55 | res.send(` 56 | 57 | 58 | 59 | 60 | 61 | 62 | Document 63 | 64 | 65 | 73 | 77 | `) 78 | }) 79 | 80 | this.router.get('/matter', (req, res) => { 81 | res.sendFile(path.join(__dirname, '../../dist/physics/index.html')) 82 | }) 83 | this.router.get('/arcade', (req, res) => { 84 | res.sendFile(path.join(__dirname, '../../dist/physics/index.html')) 85 | }) 86 | 87 | this.router.get('/stats', (req, res) => { 88 | res.sendFile(path.join(__dirname, '../../dist/stats/index.html')) 89 | }) 90 | 91 | this.router.get('/stats/get', (req, res) => { 92 | pidusage(process.pid, (err, stats) => { 93 | if (err) return res.status(500).json({ err: err }) 94 | 95 | let objects = ioStats.getTotalObjects() 96 | 97 | let payload = { 98 | ...stats, 99 | users: roomManager.getAllUsersArray().length, 100 | rooms: roomManager.getRoomsArray().length, 101 | objects: objects, 102 | time: this.time 103 | } 104 | res.json({ payload }) 105 | }) 106 | }) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/server/server.ts: -------------------------------------------------------------------------------- 1 | import 'source-map-support/register' 2 | 3 | import express from 'express' 4 | import helmet from 'helmet' 5 | import compression from 'compression' 6 | import path from 'path' 7 | 8 | const app = express() 9 | const server = require('http').Server(app) 10 | import SocketIOStatic from 'socket.io' 11 | const io = SocketIOStatic(server) 12 | 13 | import RoomManager from './managers/roomManager' 14 | import Routes from './routes/routes' 15 | import IoStats from './socket/ioStats' 16 | import IoGame from './socket/ioGame' 17 | 18 | const port = process.env.PORT || 3000 19 | 20 | // create 2 socket.io namespaces 21 | const ioNspGame = io.of('/G' /* short for stats */) 22 | const ioNspStats = io.of('/S' /* short for stats */) 23 | 24 | const ioStats = new IoStats(ioNspStats) 25 | const roomManager = new RoomManager(ioNspGame, ioStats) 26 | const ioGame = new IoGame(ioNspGame, ioStats, roomManager) 27 | 28 | app.use(helmet()) 29 | app.use(compression()) 30 | 31 | app.use('/static', express.static(path.join(__dirname, '../'))) 32 | app.use('/', new Routes(roomManager, ioStats).router) 33 | 34 | server.listen(port, () => { 35 | console.log('App is listening on http://localhost:' + port) 36 | }) 37 | -------------------------------------------------------------------------------- /src/server/socket/ioGame.ts: -------------------------------------------------------------------------------- 1 | import RoomManager from '../managers/roomManager' 2 | import IoStats from './ioStats' 3 | 4 | /** Handles all the communication for /game namespace (ioNspGame) */ 5 | export default class IoGame { 6 | time = new Date() 7 | 8 | constructor(public ioNspGame: SocketIO.Namespace, public ioStats: IoStats, public roomManager: RoomManager) { 9 | ioNspGame.on('connection', async (socket: Socket) => { 10 | roomManager.generateClientId(socket) 11 | 12 | socket.on('joinRoom', async (data: { scene: string; level: number }) => { 13 | const { scene, level } = data 14 | await roomManager.joinRoom(socket, scene, +level) 15 | ioStats.log(`New user ${socket.id} connected! to room ${socket.room}`) 16 | }) 17 | 18 | socket.on('disconnect', () => { 19 | roomManager.leaveRoom(socket) 20 | }) 21 | 22 | socket.on('changeRoom', (data: { scene: string; level: number }) => { 23 | roomManager.changeRoom(socket, data.scene, +data.level) 24 | }) 25 | 26 | socket.on('sendPing', (id: string) => { 27 | socket.emit('getPong', id) 28 | }) 29 | 30 | socket.on('U' /* short for updateDude */, (updates: any) => { 31 | if (roomManager.isRemoving(socket.room)) return 32 | if (!roomManager.userExists(socket.room, socket.id)) return 33 | 34 | roomManager.rooms[socket.room].users[socket.id].lastUpdate = Date.now() 35 | roomManager.rooms[socket.room].scene.events.emit('U' /* short for updateDude */, { 36 | clientId: socket.clientId, 37 | updates 38 | }) 39 | }) 40 | 41 | socket.on('getInitialState', () => { 42 | if (roomManager.isRemoving(socket.room)) return 43 | if (!roomManager.roomExists(socket.room)) return 44 | 45 | let payload = { 46 | time: this.time, 47 | // @ts-ignore 48 | O /* short for objects */: roomManager.rooms[socket.room].scene.getInitialState(), 49 | connectCounter: roomManager.getRoomUsersArray(socket.room).length, 50 | initialState: true, 51 | roomId: socket.room 52 | } 53 | 54 | socket.emit('S' /* short for syncGame */, payload) 55 | // ioNspGame.in(socket.room).emit('S' /* short for syncGame */, payload) 56 | }) 57 | }) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/server/socket/ioStats.ts: -------------------------------------------------------------------------------- 1 | /** Handles all the communication for /stats namespace (ioNspGame) */ 2 | export default class IoStats { 3 | totalObjects: { [roomId: string]: { count: number } } = {} 4 | 5 | constructor(public ioNspStats: SocketIO.Namespace) {} 6 | 7 | /** This function will console.log and send it to the ioStats */ 8 | log(log: string, logInNode = false) { 9 | if (logInNode) console.log('LOG: ' + log) 10 | this.ioNspStats.emit('getLog', { date: new Date(), log: log }) 11 | } 12 | 13 | /** Get the total of objects in the game */ 14 | getTotalObjects() { 15 | let count = 0 16 | Object.keys(this.totalObjects).forEach(roomId => { 17 | count += this.totalObjects[roomId].count 18 | }) 19 | return count 20 | } 21 | 22 | setTotalObjects(roomId: string, count: number) { 23 | this.totalObjects = { ...this.totalObjects, [roomId]: { count: count } } 24 | } 25 | 26 | removeTotalObjects(roomId: string) { 27 | if (this.totalObjects && this.totalObjects[roomId]) { 28 | delete this.totalObjects[roomId] 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/stats/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Stats Page 8 | 9 | 10 | 24 |

Stats

25 |
26 | 27 | 28 | 29 | 30 | 31 | 32 |
33 |

Logs

34 |
35 | 36 |
37 | 38 | 39 | -------------------------------------------------------------------------------- /src/stats/index.ts: -------------------------------------------------------------------------------- 1 | import io from 'socket.io-client' 2 | import axios from 'axios' 3 | import moment from 'moment' 4 | 5 | let url = `${location.origin}/S` /* short for stats */ 6 | let stats = io.connect(url, { transports: ['websocket'] }) 7 | 8 | let statsUl = document.getElementById('logs-ul') 9 | 10 | // on reconnection, reset the transports option, as the Websocket 11 | // connection may have failed (caused by proxy, firewall, browser, ...) 12 | stats.on('reconnect_attempt', () => { 13 | stats.io.opts.transports = ['polling', 'websocket'] 14 | }) 15 | 16 | stats.on('connect', () => { 17 | console.log("You're connected") 18 | }) 19 | 20 | stats.on('getLog', (res: { date: Date; log: string }) => { 21 | if (statsUl) { 22 | let li = document.createElement('li') 23 | li.innerHTML = `${moment(res.date).format('h:mm:ss a')}: ${res.log}` 24 | statsUl.appendChild(li) 25 | } 26 | }) 27 | 28 | const setInnerHTML = (id: string, text: string | number) => { 29 | let el = document.getElementById(id) 30 | if (el) el.innerHTML = text.toString() 31 | } 32 | 33 | const getNewServerStats = async () => { 34 | try { 35 | let res = await axios.get('/stats/get') 36 | if (!res || !res.data) throw new Error() 37 | 38 | const { payload } = res.data 39 | 40 | const { time, cpu, memory, rooms, users, objects } = payload 41 | setInnerHTML('cpu', `CPU: ${Math.round(cpu)}%`) 42 | setInnerHTML('memory', `Memory: ${Math.round(memory / 1000000)}mb`) 43 | setInnerHTML('rooms', `Rooms: ${rooms}`) 44 | setInnerHTML('users', `Users: ${users}`) 45 | setInnerHTML('objects', `Objects: ${objects}`) 46 | setInnerHTML('time', `Server started ${moment(time).fromNow()}`) 47 | } catch (error) { 48 | console.error(error.message) 49 | } 50 | } 51 | setInterval(getNewServerStats, 2000) 52 | getNewServerStats() 53 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "esnext", 5 | "lib": ["esnext", "dom"], 6 | "allowJs": false, 7 | "checkJs": false, 8 | "strict": true, 9 | "sourceMap": true, 10 | "noImplicitAny": true, 11 | "esModuleInterop": true, 12 | "moduleResolution": "node", 13 | "strictPropertyInitialization": false, 14 | "skipLibCheck": true 15 | }, 16 | "include": ["src/**/*"], 17 | "exclude": ["node_modules"] 18 | } 19 | -------------------------------------------------------------------------------- /typings/custom.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace Phaser { 2 | namespace Physics { 3 | namespace Matter { 4 | export const Matter: any 5 | export const matter: any 6 | } 7 | } 8 | } 9 | 10 | interface Window { 11 | game: Phaser.Game 12 | } 13 | 14 | interface Socket extends SocketIOClient.Socket { 15 | join: (roomId: string) => {} 16 | leave: (roomId: string) => {} 17 | scene: Phaser.Scene 18 | id: string 19 | clientId: number 20 | room: string 21 | } 22 | 23 | declare const PHYSICS_DEBUG: boolean 24 | 25 | interface Latency { 26 | current: number 27 | high: number 28 | low: number 29 | ping: number 30 | id: string 31 | canSend: boolean 32 | history: any[] 33 | } 34 | -------------------------------------------------------------------------------- /webpack/webpack.client.cjs: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const HtmlWebpackPlugin = require('html-webpack-plugin') 3 | const CopyWebpackPlugin = require('copy-webpack-plugin') 4 | const webpack = require('webpack') 5 | 6 | module.exports = { 7 | mode: 'development', 8 | stats: 'errors-warnings', 9 | entry: ['./src/client/index.ts'], 10 | output: { 11 | publicPath: 'static/client', 12 | path: path.resolve(__dirname, '../dist/client'), 13 | filename: '[name].bundle.js', 14 | chunkFilename: '[name].chunk.js' 15 | }, 16 | resolve: { 17 | extensions: ['.ts', '.tsx', '.js'] 18 | }, 19 | module: { 20 | rules: [ 21 | { 22 | test: /\.tsx?$/, 23 | loader: 'ts-loader', 24 | options: { 25 | transpileOnly: true 26 | } 27 | } 28 | ] 29 | }, 30 | optimization: { 31 | splitChunks: { 32 | cacheGroups: { 33 | commons: { 34 | test: /[\\/]node_modules[\\/]/, 35 | name: 'vendors', 36 | chunks: 'all', 37 | filename: '[name].bundle.js' 38 | } 39 | } 40 | } 41 | }, 42 | plugins: [ 43 | new HtmlWebpackPlugin({ 44 | template: 'src/client/index.html' 45 | }), 46 | new CopyWebpackPlugin({ patterns: [{ from: 'src/client/assets', to: 'assets' }] }), 47 | new webpack.DefinePlugin({ 48 | PHYSICS_DEBUG: JSON.stringify(false) 49 | }) 50 | ] 51 | } 52 | -------------------------------------------------------------------------------- /webpack/webpack.client.prod.cjs: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge') 2 | const common = require('./webpack.client') 3 | const webpack = require('webpack') 4 | 5 | const prod = { 6 | mode: 'production', 7 | output: { 8 | filename: '[name].[contenthash].bundle.js', 9 | chunkFilename: '[name].[contenthash].chunk.js' 10 | }, 11 | optimization: { 12 | splitChunks: { 13 | cacheGroups: { 14 | commons: { 15 | filename: '[name].[contenthash].bundle.js' 16 | } 17 | } 18 | } 19 | }, 20 | plugins: [ 21 | new webpack.DefinePlugin({ 22 | 'process.env.NODE_ENV': JSON.stringify('production'), 23 | PHYSICS_DEBUG: JSON.stringify(false) 24 | }) 25 | ] 26 | } 27 | 28 | module.exports = merge(common, prod) 29 | -------------------------------------------------------------------------------- /webpack/webpack.physics.cjs: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | const HtmlWebpackPlugin = require('html-webpack-plugin') 4 | 5 | module.exports = { 6 | mode: 'development', 7 | stats: 'errors-warnings', 8 | entry: ['./src/physics/index.ts'], 9 | output: { 10 | publicPath: 'static/physics', 11 | path: path.resolve(__dirname, '../dist/physics'), 12 | filename: '[name].bundle.js', 13 | chunkFilename: '[name].chunk.js' 14 | }, 15 | resolve: { 16 | extensions: ['.ts', '.tsx', '.js'] 17 | }, 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.tsx?$/, 22 | loader: 'ts-loader', 23 | options: { 24 | transpileOnly: true 25 | } 26 | } 27 | ] 28 | }, 29 | optimization: { 30 | splitChunks: { 31 | cacheGroups: { 32 | commons: { 33 | test: /[\\/]node_modules[\\/]/, 34 | name: 'vendors', 35 | chunks: 'all', 36 | filename: '[name].bundle.js' 37 | } 38 | } 39 | } 40 | }, 41 | plugins: [ 42 | new HtmlWebpackPlugin({ 43 | template: 'src/physics/index.html' 44 | }), 45 | new webpack.DefinePlugin({ 46 | PHYSICS_DEBUG: JSON.stringify(true) 47 | }) 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /webpack/webpack.physics.prod.cjs: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge') 2 | const common = require('./webpack.physics') 3 | const webpack = require('webpack') 4 | 5 | const prod = { 6 | mode: 'production', 7 | output: { 8 | filename: '[name].[contenthash].bundle.js', 9 | chunkFilename: '[name].[contenthash].chunk.js' 10 | }, 11 | optimization: { 12 | splitChunks: { 13 | cacheGroups: { 14 | commons: { 15 | filename: '[name].[contenthash].bundle.js' 16 | } 17 | } 18 | } 19 | }, 20 | plugins: [ 21 | new webpack.DefinePlugin({ 22 | 'process.env.NODE_ENV': JSON.stringify('production'), 23 | PHYSICS_DEBUG: JSON.stringify(true) 24 | }) 25 | ] 26 | } 27 | 28 | module.exports = merge(common, prod) 29 | -------------------------------------------------------------------------------- /webpack/webpack.server.cjs: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const nodeExternals = require('webpack-node-externals') 3 | const webpack = require('webpack') 4 | 5 | module.exports = { 6 | mode: 'development', 7 | stats: 'errors-warnings', 8 | devtool: 'inline-source-map', 9 | target: 'node', 10 | node: { 11 | __dirname: false 12 | }, 13 | entry: './src/server/server.ts', 14 | output: { 15 | filename: 'server.js', 16 | path: path.resolve(__dirname, '../dist/server') 17 | }, 18 | resolve: { 19 | extensions: ['.ts', '.tsx', '.js'] 20 | }, 21 | module: { 22 | rules: [ 23 | { 24 | test: /\.tsx?$/, 25 | include: path.join(__dirname, '../src'), 26 | loader: 'ts-loader', 27 | options: { 28 | transpileOnly: true 29 | } 30 | } 31 | ] 32 | }, 33 | plugins: [ 34 | new webpack.DefinePlugin({ 35 | 'process.env.NODE_ENV': JSON.stringify('production'), 36 | PHYSICS_DEBUG: JSON.stringify(false) 37 | }) 38 | ], 39 | externals: [nodeExternals()] 40 | } 41 | -------------------------------------------------------------------------------- /webpack/webpack.stats.cjs: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const HtmlWebpackPlugin = require('html-webpack-plugin') 3 | 4 | module.exports = { 5 | mode: 'development', 6 | stats: 'errors-warnings', 7 | entry: ['./src/stats/index.ts'], 8 | output: { 9 | publicPath: 'static/stats', 10 | path: path.resolve(__dirname, '../dist/stats'), 11 | filename: '[name].bundle.js', 12 | chunkFilename: '[name].chunk.js' 13 | }, 14 | resolve: { 15 | extensions: ['.ts', '.tsx', '.js'] 16 | }, 17 | module: { 18 | rules: [ 19 | { 20 | test: /\.tsx?$/, 21 | loader: 'ts-loader', 22 | options: { 23 | transpileOnly: true 24 | } 25 | } 26 | ] 27 | }, 28 | optimization: { 29 | splitChunks: { 30 | cacheGroups: { 31 | commons: { 32 | test: /[\\/]node_modules[\\/]/, 33 | name: 'vendors', 34 | chunks: 'all', 35 | filename: '[name].bundle.js' 36 | } 37 | } 38 | } 39 | }, 40 | plugins: [ 41 | new HtmlWebpackPlugin({ 42 | template: 'src/stats/index.html' 43 | }) 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /webpack/webpack.stats.prod.cjs: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const HtmlWebpackPlugin = require('html-webpack-plugin') 3 | 4 | module.exports = { 5 | mode: 'production', 6 | stats: 'errors-warnings', 7 | entry: ['./src/stats/index.ts'], 8 | output: { 9 | publicPath: 'static/stats', 10 | path: path.resolve(__dirname, '../dist/stats'), 11 | filename: '[name].[contenthash].bundle.js', 12 | chunkFilename: '[name].[contenthash].chunk.js' 13 | }, 14 | resolve: { 15 | extensions: ['.ts', '.tsx', '.js'] 16 | }, 17 | module: { 18 | rules: [ 19 | { 20 | test: /\.tsx?$/, 21 | loader: 'ts-loader', 22 | options: { 23 | transpileOnly: true 24 | } 25 | } 26 | ] 27 | }, 28 | optimization: { 29 | splitChunks: { 30 | cacheGroups: { 31 | commons: { 32 | test: /[\\/]node_modules[\\/]/, 33 | name: 'vendors', 34 | chunks: 'all', 35 | filename: '[name].[contenthash].bundle.js' 36 | } 37 | } 38 | } 39 | }, 40 | plugins: [ 41 | new HtmlWebpackPlugin({ 42 | template: 'src/stats/index.html' 43 | }) 44 | ] 45 | } 46 | --------------------------------------------------------------------------------