├── .gitignore ├── LICENSE.MD ├── Readme.md ├── client ├── .gitignore ├── package-lock.json ├── package.json ├── public │ ├── assets │ │ ├── audio │ │ │ └── sounds │ │ │ │ ├── jump_ground.wav │ │ │ │ ├── jump_human.wav │ │ │ │ ├── run.wav │ │ │ │ └── stomp.wav │ │ ├── images │ │ │ └── gunsight.jpg │ │ ├── models │ │ │ ├── crouch.glb │ │ │ └── stand.glb │ │ ├── scenes │ │ │ ├── Level1.babylon │ │ │ ├── Stone.png │ │ │ ├── Wood.png │ │ │ ├── blender │ │ │ │ ├── Level1.blend │ │ │ │ ├── Level1.blend1 │ │ │ │ ├── templateLevel.blend │ │ │ │ └── templateLevel.blend1 │ │ │ ├── floor.png │ │ │ └── templateLevel.babylon │ │ └── textures │ │ │ ├── Stone.png │ │ │ ├── Wood.png │ │ │ ├── floor.png │ │ │ ├── skybox_nx.jpg │ │ │ ├── skybox_ny.jpg │ │ │ ├── skybox_nz.jpg │ │ │ ├── skybox_px.jpg │ │ │ ├── skybox_py.jpg │ │ │ └── skybox_pz.jpg │ ├── css │ │ └── main.css │ └── index.html ├── src │ ├── Config.ts │ ├── Entry.ts │ ├── Game.ts │ ├── levels │ │ ├── AudioManager.ts │ │ ├── Level.ts │ │ └── components │ │ │ ├── CheckPoint.ts │ │ │ ├── GoalMesh.ts │ │ │ ├── Spawn.ts │ │ │ └── Timer.ts │ ├── networking │ │ ├── rooms │ │ │ ├── ChatRoom.ts │ │ │ └── GameRoom.ts │ │ └── schema │ │ │ ├── PlayerSchema.ts │ │ │ └── StateHandlerSchema.ts │ ├── player │ │ ├── AbstractPlayer.ts │ │ ├── OtherPlayer.ts │ │ ├── Player.ts │ │ ├── PlayerAnimator.ts │ │ ├── PlayerControls.ts │ │ ├── PlayerMesh.ts │ │ └── camera │ │ │ └── PlayerCamera.ts │ └── ui │ │ └── FullScreenUI.ts ├── tsconfig.json ├── webpack.common.js ├── webpack.dev.js └── webpack.prod.js ├── package-lock.json ├── play.gif └── server ├── .gitignore ├── Procfile ├── package-lock.json ├── package.json ├── src ├── loadtester │ └── LoadTester.ts ├── rooms │ ├── ChatRoom.ts │ └── GameRoom.ts ├── schema │ ├── PlayerSchema.ts │ └── StateHandlerSchema.ts └── server.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | node_modules -------------------------------------------------------------------------------- /LICENSE.MD: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Fadi Bunni and Jacob Pjetursson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # KZ Climbing - A BabylonJS and Colyseus game prototype 2 | This project is a multiplayer 3d platformer game prototype. The game is similar to "KZ" maps in Counter Strike. It utilises the [BabylonJS](https://www.babylonjs.com/) framework for rendering and the [Colyseus](https://www.colyseus.io/) framework for the multiplayer aspect. It is a very strong boilerplate that can be used by beginners. 3 | 4 | The project is written in TypeScript, HTML and CSS. 5 | 6 | An online version of the game can be found here: https://kzclimbing.netlify.app 7 | 8 | ![Demo](play.gif) 9 | 10 | What this project includes: 11 | 1. A well documented cliet side BabylonJS prototype 12 | - Animation, lightning and shadows, camera (1. and 3. person), 13 | - UI and Audio 14 | - Homade "physics" and built-in collision system. 15 | - Easy and understandable gamme loop code setup. 16 | 2. A well decumented server side Colyseus prototype 17 | - Handling data from multiple clients 18 | - Implementation for cloyseus schema (client and server side) 19 | - Simple chat functionality 20 | - Colyseus monitor and loadtest for serverside 21 | 3. Contains a [config.ts](https://github.com/BabylonJSGames/BabylonJS-Platformer-Game-Prototype/blob/master/client/src/Config.ts) file used for changing important parameters 22 | 4. A guide on how to create your own level and import it to Babylon from blender. 23 | 5. Examples for how to setup webpack.xxx.js, tsconfig.json and package.json files 24 | 6. An easy guild on how to deploy the game locally and online using netlify with heroku 25 | 26 | What this project does not include: 27 | 1. Client-side Liner interplation(LERP) funcitonality 28 | 2. BabylonJS NullEngine on server side for collision detection 29 | - Used to prevent clients from cheating 30 | 4. A Lobby 31 | 5. Leader scoreboard 32 | 6. Login authentication 33 | 34 | ## How to setup project locally 35 | 1. Download and install [Nodejs](https://nodejs.org/en/). 36 | 2. Download and install [Typescript](https://www.typescriptlang.org/). 37 | 3. Clone this repo to your local machine. 38 | 4. Open a terminal and navigate to the *server* directory and run the command `npm install` followed by `npm run dev` to start the server 39 | 5. Open a new terminal and navigate to the *client* directory and repeat the process above to start the client. 40 | 6. A browser window should now automatically open. If not, go to [localhost:8080](http://localhost:8080/). 41 | 42 | ### Colysues monitor 43 | 7. Go http://localhost:8081/colyseus/#/. You can now monitor clients in any room, read more about it her: https://docs.colyseus.io/tools/monitor/. 44 | 45 | ### Colysues Load testing 46 | 8. Open a terminal and navigate to the root folder *BabylonJS-Platformer-Game-Prototype*. 47 | 9. Install npx by running the command `npm install -g npx`. 48 | 10. Run the command `npx colyseus-loadtest server/src/loadtester/LoadTester.ts --room GameRoom --numClients 50 --endpoint ws://localhost:8081`. 49 | - You can adjust the numer of clients connected to the server by changing *--numClients*. 50 | - Go to *http://localhost:8081/colyseus/#/* to check out the number of clients connected in any rooms. 51 | 52 | ## Setup using netlify(client side) and heroku(server side). 53 | 1. Create an account both on netlify and heroku. 54 | 2. Fork this [repo](https://github.com/BabylonJSGames/BabylonJS-Platformer-Game-Prototype.git) to your own github. 55 | 56 | ### Heroku (Server side) - Heroku is not free anymore, you need to buy dynos 57 | 3. Create a new app and give it a name, then choose a region. 58 | 4. Choose github as deploy method and find the forked repo on your github then press *connect*. 59 | 5. Make sure that the variable *socketAddressProduction* in the *config.ts* file inside the client folder says `wss://NAMEOFYOURSERICE.herokuapp.com` 60 | - the name of your service is the name you gave the app. 61 | 6. Now press on *settings* tab and the press on *reveal config vars*. 62 | 7. Key should be `PROJECT_PATH` and value should be `server`, then press *add*. 63 | 8. Now press *add build pack* and add this: `https://github.com/timanovsky/subdir-heroku-buildpack.git` then press *save changes*. 64 | 9. Again press *add build pack* this time also add nodejs then press *save changes*. 65 | - Make sure that `https://github.com/timanovsky/subdir-heroku-buildpack.git` is highest up in the build pack!!! 66 | 10. Now go press on *deploy* tab and sroll down and press on *deploy branch* 67 | - You should see it build and after a few seconds it should be finished. 68 | 11. Now scroll up and press on *more* and choose *view logs*. 69 | - If everything is well you should be able to spot the output *listening on http://localhost:xxxx* in the logs 70 | 71 | ### Or Render (Server side) 72 | 12. Make sure to connect to your github to be abe to use the forked repo. 73 | 13. Make sure that the variable *socketAddressProduction* in the *config.ts* file inside the client folder says `wss://NAMEOFYOURSERICE.onrender.com` 74 | - the name of your service is the name you gave the Web Service. 75 | 14. Create a new Web Service and connect to the forked repo. 76 | 15. Give it a name, make sure the region is closest to your contry, branch is master, and root directory is `server`. 77 | 16. Make sure the Runtime is "node". 78 | 17. Build command is `npm run buld` and the start command is `npm run start`. 79 | 18. Finally press *Create Web Service*. 80 | 19. Find the service you crated and press *Manual Deploy* 81 | 82 | ### Netlify (client side) 83 | 11. Create a new site *new site from git* and connect to your github choosing the forked repo. 84 | 12. Press *deploy site*. 85 | 13. Go to *site settings* and press *change site name* and change it to what ever you like then press *save*. 86 | - The site will be deployed at https://WhatEverYouNamedIt.netlify.app 87 | 14. Then press on *build and deploy*. 88 | 15. Press *edit build settings*. 89 | 16. *Base birectory* should be set to *client*, *build command* should be *npm run build* and *publish directory* should be *client/public*, then press *save*. 90 | 17. Go to *deploy* and press *Trigger deploy* then choose *clear cashe and deploy site*. 91 | - Now client side should be up and running. 92 | - If you go to https://WhatEverYouNamedIt.netlify.app in your browser you should be able to play the game with friends now. 93 | 94 | ## Guide to making your own maps in Blender 95 | 1. Download and install [Blender](https://www.blender.org/). 96 | 2. Download and install the [Blender to .babylonjs exporter](https://doc.babylonjs.com/extensions/Exporters/Blender). In there you will also find additional information about the features that are supported by the exporter. 97 | - Make sure the exporter is compatible with your version of Blender. 98 | 3. Open the file *client/public/assets/scenes/blender/templateLevel.blend* in Blender. 99 | 4. You will create the level by placing new meshes. Feel free to use any mesh shape as you like. The following naming conventions for meshes are used to define the type of mesh: 100 | - Mesh contains the word __Collider__, e.g. *ColliderBox*, *SphereCollider*: The mesh will be part of the Babylonjs collision engine and collisions between the player and this mesh will be detected. 101 | - Mesh is named __Spawn__: The player will spawn at the position of this mesh. The mesh will be deleted from the scene on load. This mesh is required for the level to be valid. 102 | - Mesh is named __Goal__: When the player collides with this mesh, the level has been completed. This mesh is also required for the level to be valid. 103 | - Mesh is named __LookAt__: At spawn, the player will initially look towards the position of this mesh. The mesh will be deleted from the scene on load. If this mesh is not found, lookAt will default to the zero vector. 104 | - Mesh does not follow any of the naming rules above: These are decorative meshes and they won't be part of the collision system. 105 | 5. Feel free to add light source(s) and shadows as you see fit. If the scene does not include a light source, a hemispheric light will automatically be inserted after the level import. 106 | 6. Create Shadows by selecting meshes and checking the cast shadows and recieve shadows properties in blender. 107 | 7. Export your level by selecting *File->Export->Babylon.js ver 2.xx.x* (or whichever version you have). 108 | 8. Move your exported .babylonjs file into the project at *client/public/assets/scenes/*. 109 | 9. In the `Config.ts` file, set the `levelName` variable to the name of your exported file, e.g. if your file is located at *client/public/assets/scenes/level.babylonjs*, put in *level.babylonjs*. 110 | 10. If doing this locally, then just reload the page. 111 | 11. If on netlify push the changes to yor forked github repo and rebuild netlify, see step 17 under section Netlify (client side) 112 | - No need to update heroku. 113 | 12. You should now be able to play your level through. 114 | 115 | ### Additional notes: 116 | - After importing the level, the following will be added to the scene: 117 | - Two cameras (First-person and third-person view). 118 | - A hemispheric ambient light (if no light source exists in the imported file). 119 | - You are free to use whichever mesh shapes, sizes and textures as you please. This includes the Goal mesh and the Collider meshes as well. 120 | - The player has a width of 4 and a height of 8. The player can crouch to a height of 4 instead. Take this into account when creating your level. The cylinder mesh in `templateLevel.blend` has these metrics, so you can use this for reference. 121 | - You will very likely have to play around with the level to ensure the player controls match well with the level design and layout. 122 | - ~~Some lights and shadows functionalities is missing for the blender to BabylonJS exporter, this might help you out: https://forum.babylonjs.com/t/exporting-shadows-from-blender-to-babylon-js/18580/5. ~~ EDIT!! This seems to have been solved in the 2.93 version of the blendet exporter. 123 | - For best shadows and light, look into the [level1.BABYLON](https://github.com/BabylonJSGames/BabylonJS-Platformer-Game-Prototype/blob/master/client/public/assets/scenes/Level1.babylon) for inspiration, scroll down to *Lights* and *shadowGenerators*, also check out the forum link above. 124 | 125 | ## Disclaimer 126 | The project is work-in-progress, and contains several incomplete features and bugs. The project is only meant as a public demonstration of Babylonjs and Colyseus. 127 | 128 | There is no intention of any future work on this project. 129 | 130 | ## Inspiration and thanks 131 | Some inspiration for how to generally use Colyseus: 132 | 1. https://github.com/endel/colyseus-babylonjs-boilerplate 133 | 2. https://github.com/colyseus/unity-demo-shooting-gallery 134 | 3. https://github.com/endel/mazmorra 135 | 4. https://github.com/creationspirit/multiplayer-browser-game-boilerplate 136 | 5. https://www.youtube.com/watch?v=s3ZrQbI5o_k&ab_channel=Moby 137 | 6. https://www.youtube.com/watch?v=x-bbflZvuXE&ab_channel=Ourcade 138 | 7. https://www.youtube.com/watch?v=M9RDYkFs-EQ 139 | 140 | Thanks to all of those who created the repos and youtube video. 141 | 142 | Thanks to especially the Colyseus and BabylonJS community. 143 | 144 | ## License 145 | This project is made by [Jacob Pjetursoon](https://github.com/JacobPjetursson) and [Fadi Bunni](https://github.com/orgs/BabylonJSGames/people/FadiBunni) 146 | 147 | See the [LICENSE](https://github.com/BabylonJSGames/BabylonJS-Platformer-Game-Prototype/blob/master/LICENSE.MD) file for license rights and limitations (MIT). 148 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | public/js 3 | *.log 4 | *.blender1 5 | *.manifest -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kz-climbing", 3 | "version": "1.0.0", 4 | "description": "A 3d browser game prototype based on the classic Counter Strike KZ mode", 5 | "main": "Entry.ts", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "npx webpack serve --config webpack.dev.js", 9 | "build": "npx webpack --config webpack.prod.js", 10 | "dev": "npm run start" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/JacobPjetursson/KZClimbing.git" 15 | }, 16 | "author": "Jacob Pjetursson and Fadi Bunni", 17 | "license": "ISC", 18 | "homepage": "https://github.com/JacobPjetursson/KZClimbing#readme", 19 | "dependencies": { 20 | "@babylonjs/core": "5.7.0", 21 | "@babylonjs/gui": "5.7.0", 22 | "@babylonjs/inspector": "5.7.0", 23 | "@babylonjs/loaders": "5.7.0", 24 | "@babylonjs/materials": "5.7.0", 25 | "colyseus.js": "0.14.13" 26 | }, 27 | "devDependencies": { 28 | "html-webpack-plugin": "^5.5.0", 29 | "npm-run-all": "^4.1.5", 30 | "path": "^0.12.7", 31 | "terser-webpack-plugin": "^5.3.1", 32 | "ts-loader": "^9.3.0", 33 | "ts-node": "^10.8.0", 34 | "typescript": "^4.6.4", 35 | "webpack": "^5.72.1", 36 | "webpack-cli": "^4.9.0", 37 | "webpack-dev-server": "^4.9.0", 38 | "webpack-merge": "^5.8.0" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /client/public/assets/audio/sounds/jump_ground.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BabylonJSGames/BabylonJS-Platformer-Game-Prototype/c6ee1ac74310d7da43d9878a8df6e857a7cbc5dc/client/public/assets/audio/sounds/jump_ground.wav -------------------------------------------------------------------------------- /client/public/assets/audio/sounds/jump_human.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BabylonJSGames/BabylonJS-Platformer-Game-Prototype/c6ee1ac74310d7da43d9878a8df6e857a7cbc5dc/client/public/assets/audio/sounds/jump_human.wav -------------------------------------------------------------------------------- /client/public/assets/audio/sounds/run.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BabylonJSGames/BabylonJS-Platformer-Game-Prototype/c6ee1ac74310d7da43d9878a8df6e857a7cbc5dc/client/public/assets/audio/sounds/run.wav -------------------------------------------------------------------------------- /client/public/assets/audio/sounds/stomp.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BabylonJSGames/BabylonJS-Platformer-Game-Prototype/c6ee1ac74310d7da43d9878a8df6e857a7cbc5dc/client/public/assets/audio/sounds/stomp.wav -------------------------------------------------------------------------------- /client/public/assets/images/gunsight.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BabylonJSGames/BabylonJS-Platformer-Game-Prototype/c6ee1ac74310d7da43d9878a8df6e857a7cbc5dc/client/public/assets/images/gunsight.jpg -------------------------------------------------------------------------------- /client/public/assets/models/crouch.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BabylonJSGames/BabylonJS-Platformer-Game-Prototype/c6ee1ac74310d7da43d9878a8df6e857a7cbc5dc/client/public/assets/models/crouch.glb -------------------------------------------------------------------------------- /client/public/assets/models/stand.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BabylonJSGames/BabylonJS-Platformer-Game-Prototype/c6ee1ac74310d7da43d9878a8df6e857a7cbc5dc/client/public/assets/models/stand.glb -------------------------------------------------------------------------------- /client/public/assets/scenes/Stone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BabylonJSGames/BabylonJS-Platformer-Game-Prototype/c6ee1ac74310d7da43d9878a8df6e857a7cbc5dc/client/public/assets/scenes/Stone.png -------------------------------------------------------------------------------- /client/public/assets/scenes/Wood.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BabylonJSGames/BabylonJS-Platformer-Game-Prototype/c6ee1ac74310d7da43d9878a8df6e857a7cbc5dc/client/public/assets/scenes/Wood.png -------------------------------------------------------------------------------- /client/public/assets/scenes/blender/Level1.blend: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BabylonJSGames/BabylonJS-Platformer-Game-Prototype/c6ee1ac74310d7da43d9878a8df6e857a7cbc5dc/client/public/assets/scenes/blender/Level1.blend -------------------------------------------------------------------------------- /client/public/assets/scenes/blender/Level1.blend1: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BabylonJSGames/BabylonJS-Platformer-Game-Prototype/c6ee1ac74310d7da43d9878a8df6e857a7cbc5dc/client/public/assets/scenes/blender/Level1.blend1 -------------------------------------------------------------------------------- /client/public/assets/scenes/blender/templateLevel.blend: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BabylonJSGames/BabylonJS-Platformer-Game-Prototype/c6ee1ac74310d7da43d9878a8df6e857a7cbc5dc/client/public/assets/scenes/blender/templateLevel.blend -------------------------------------------------------------------------------- /client/public/assets/scenes/blender/templateLevel.blend1: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BabylonJSGames/BabylonJS-Platformer-Game-Prototype/c6ee1ac74310d7da43d9878a8df6e857a7cbc5dc/client/public/assets/scenes/blender/templateLevel.blend1 -------------------------------------------------------------------------------- /client/public/assets/scenes/floor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BabylonJSGames/BabylonJS-Platformer-Game-Prototype/c6ee1ac74310d7da43d9878a8df6e857a7cbc5dc/client/public/assets/scenes/floor.png -------------------------------------------------------------------------------- /client/public/assets/textures/Stone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BabylonJSGames/BabylonJS-Platformer-Game-Prototype/c6ee1ac74310d7da43d9878a8df6e857a7cbc5dc/client/public/assets/textures/Stone.png -------------------------------------------------------------------------------- /client/public/assets/textures/Wood.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BabylonJSGames/BabylonJS-Platformer-Game-Prototype/c6ee1ac74310d7da43d9878a8df6e857a7cbc5dc/client/public/assets/textures/Wood.png -------------------------------------------------------------------------------- /client/public/assets/textures/floor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BabylonJSGames/BabylonJS-Platformer-Game-Prototype/c6ee1ac74310d7da43d9878a8df6e857a7cbc5dc/client/public/assets/textures/floor.png -------------------------------------------------------------------------------- /client/public/assets/textures/skybox_nx.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BabylonJSGames/BabylonJS-Platformer-Game-Prototype/c6ee1ac74310d7da43d9878a8df6e857a7cbc5dc/client/public/assets/textures/skybox_nx.jpg -------------------------------------------------------------------------------- /client/public/assets/textures/skybox_ny.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BabylonJSGames/BabylonJS-Platformer-Game-Prototype/c6ee1ac74310d7da43d9878a8df6e857a7cbc5dc/client/public/assets/textures/skybox_ny.jpg -------------------------------------------------------------------------------- /client/public/assets/textures/skybox_nz.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BabylonJSGames/BabylonJS-Platformer-Game-Prototype/c6ee1ac74310d7da43d9878a8df6e857a7cbc5dc/client/public/assets/textures/skybox_nz.jpg -------------------------------------------------------------------------------- /client/public/assets/textures/skybox_px.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BabylonJSGames/BabylonJS-Platformer-Game-Prototype/c6ee1ac74310d7da43d9878a8df6e857a7cbc5dc/client/public/assets/textures/skybox_px.jpg -------------------------------------------------------------------------------- /client/public/assets/textures/skybox_py.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BabylonJSGames/BabylonJS-Platformer-Game-Prototype/c6ee1ac74310d7da43d9878a8df6e857a7cbc5dc/client/public/assets/textures/skybox_py.jpg -------------------------------------------------------------------------------- /client/public/assets/textures/skybox_pz.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BabylonJSGames/BabylonJS-Platformer-Game-Prototype/c6ee1ac74310d7da43d9878a8df6e857a7cbc5dc/client/public/assets/textures/skybox_pz.jpg -------------------------------------------------------------------------------- /client/public/css/main.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | overflow: hidden; 3 | width : 100%; 4 | height : 100%; 5 | margin : 0; 6 | padding : 0; 7 | } 8 | 9 | #renderCanvas { 10 | width : 100%; 11 | height : 100%; 12 | touch-action: none; 13 | } 14 | 15 | #fps_label{ 16 | position: absolute; 17 | background-color: grey; 18 | opacity: 0.8; 19 | text-align: center; 20 | font-size: 16px; 21 | color: white; 22 | top: 15px; 23 | right: 10px; 24 | width: 60px; 25 | height: 20px; 26 | } 27 | 28 | #chat-box { 29 | position:absolute; 30 | border: 2px solid grey; 31 | width: 500px; 32 | height: 200px; 33 | overflow-y: scroll; 34 | left: 0px; 35 | bottom: 30px; 36 | } 37 | #chat-form { 38 | position:absolute; 39 | border: 2px solid grey; 40 | width: 500px; 41 | height: 30px; 42 | left: 0px; 43 | bottom: 0px; 44 | } 45 | 46 | #chat-input{ 47 | width: 430px; 48 | height: 20px; 49 | margin-top: 3px; 50 | background: transparent; 51 | } 52 | 53 | #chat-btn { 54 | background-color: green; 55 | border-radius: 5px; 56 | } -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | KZ Climbing 6 | 7 | 8 | 9 |
10 |
11 | 12 | 13 |
14 |
- fps
15 | 16 | 17 | -------------------------------------------------------------------------------- /client/src/Config.ts: -------------------------------------------------------------------------------- 1 | export default class Config { 2 | // debugPlayer will draw the collision ellipsoid for the player 3 | public static debugPlayer = false; 4 | // showInspector will open up the BabylonJS inspector: https://doc.babylonjs.com/toolsAndResources/tools/inspector 5 | public static showInspector = false; 6 | // useNetworking can be disabled if you want to only develop client-side 7 | public static useNetworking = true; 8 | // quick way to target any level made in blender, see public/assets/scenes. 9 | public static levelName = "Level1.babylon"; 10 | // this address currently hosts the game 11 | public static socketAddressProduction = "wss://kzclimbing.onrender.com"; 12 | } 13 | -------------------------------------------------------------------------------- /client/src/Entry.ts: -------------------------------------------------------------------------------- 1 | import {Engine} from "@babylonjs/core/Engines"; 2 | import Game from './Game'; 3 | 4 | const app = { 5 | init() { 6 | const canvasElement = "renderCanvas"; 7 | const game = new Game(canvasElement); 8 | game.start(); 9 | } 10 | } 11 | 12 | //Initialize Game 13 | window.addEventListener('DOMContentLoaded', () => { 14 | document.onreadystatechange = () => { 15 | if (document.readyState === 'complete') { 16 | if (Engine.isSupported()) { 17 | app.init(); 18 | } else { 19 | console.error("BabylonJS engine not supported"); 20 | } 21 | } else { 22 | console.error("Expected document state 'complete' but received state: " + document.readyState); 23 | } 24 | } 25 | }); 26 | -------------------------------------------------------------------------------- /client/src/Game.ts: -------------------------------------------------------------------------------- 1 | import {Engine} from "@babylonjs/core/Engines/engine"; 2 | import {Client} from "colyseus.js"; 3 | 4 | import Level from "./levels/Level"; 5 | import GameRoom from "./networking/rooms/GameRoom"; 6 | import ChatRoom from "./networking/rooms/ChatRoom"; 7 | import Config from "./Config"; 8 | 9 | export default class Game { 10 | public static canvas: HTMLCanvasElement; 11 | public static engine: Engine; 12 | public static client: Client; 13 | public static currentLevel: Level; 14 | 15 | private gameRoom: GameRoom; 16 | private chatRoom: ChatRoom; 17 | 18 | constructor(canvasElement: string) { 19 | Game.canvas = document.getElementById(canvasElement) as HTMLCanvasElement; 20 | Game.engine = new Engine(Game.canvas, true); 21 | 22 | this.init(); 23 | }; 24 | 25 | public init() { 26 | this.setupListeners(); 27 | this.setupSockets(); 28 | Game.currentLevel = new Level(Config.levelName); 29 | Game.canvas.focus(); 30 | } 31 | 32 | public async start() { 33 | await Game.currentLevel.build(); 34 | 35 | if (Config.useNetworking) { 36 | this.gameRoom = new GameRoom(Game.currentLevel); 37 | this.chatRoom = new ChatRoom(); 38 | await Promise.all([this.gameRoom.connect(), this.chatRoom.connect()]); 39 | } 40 | 41 | this.startGameLoop(); 42 | } 43 | 44 | private startGameLoop() { 45 | Game.engine.runRenderLoop(() => { 46 | Game.currentLevel.update(); 47 | 48 | if (Config.useNetworking) { 49 | this.gameRoom.updatePlayerToServer(); 50 | } 51 | 52 | let fpsLabel = document.getElementById("fps_label"); 53 | fpsLabel.innerHTML = Game.engine.getFps().toFixed() + "FPS"; 54 | }); 55 | } 56 | 57 | private setupListeners() { 58 | window.addEventListener("resize", function () { 59 | Game.engine.resize(); 60 | }); 61 | } 62 | 63 | private setupSockets() { 64 | const hostDevelopment = location.host.replace(/:.*/, ''); // localhost 65 | const portDevelopment = location.port.slice(0, -1) + 1; // 8081 66 | let socketAddressDevelopment = location.protocol.replace("http", "ws") + "//" + hostDevelopment; 67 | if (portDevelopment) { 68 | socketAddressDevelopment += ':' + portDevelopment; 69 | } 70 | if (hostDevelopment === "localhost") { 71 | Game.client = new Client(socketAddressDevelopment); 72 | } else { 73 | Game.client = new Client(Config.socketAddressProduction); 74 | } 75 | 76 | console.log("DEV HOST: " +hostDevelopment); 77 | console.log("DEV PORT: " + portDevelopment); 78 | console.log("DEV SOCKET: " + socketAddressDevelopment); 79 | console.log("PROD SOCKET: " + Config.socketAddressProduction); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /client/src/levels/AudioManager.ts: -------------------------------------------------------------------------------- 1 | import { Sound } from "@babylonjs/core/Audio/sound" 2 | import { Scene } from "@babylonjs/core/scene"; 3 | 4 | export default class AudioManager { 5 | private scene: Scene 6 | // name of various sounds 7 | private run: Sound; 8 | private crouchWalk: Sound; 9 | private jump: Sound; 10 | 11 | constructor(scene: Scene) { 12 | this.scene = scene; 13 | } 14 | 15 | public async loadAudio() { 16 | this.run = new Sound("RunSound", "assets/audio/sounds/run.wav", this.scene, null, {loop: true, autoplay: false}); 17 | this.run.setVolume(0.2); 18 | 19 | this.crouchWalk = new Sound("CrouchSound", "assets/audio/sounds/run.wav", this.scene, null, {loop: true, autoplay: false}); 20 | this.crouchWalk.setPlaybackRate(0.6); 21 | this.crouchWalk.setVolume(0.1); 22 | 23 | this.jump = new Sound("JumpSound", "assets/audio/sounds/jump_ground.wav", this.scene, null, {loop: false, autoplay: false}); 24 | this.jump.setVolume(1.2); 25 | } 26 | 27 | public playRun(play: boolean) { this.playSound(this.run, play); } 28 | public playCrouchWalk(play: boolean) { this.playSound(this.crouchWalk, play); } 29 | public playJump(play: boolean) { this.playSound(this.jump, play); } 30 | 31 | private playSound(sound: Sound, play: boolean) { 32 | if (play && sound.isPlaying) { 33 | return; 34 | } 35 | if (play) { 36 | sound.play(); 37 | } else { 38 | sound.pause(); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /client/src/levels/Level.ts: -------------------------------------------------------------------------------- 1 | import { Scene } from "@babylonjs/core/scene"; 2 | import { SceneLoader } from "@babylonjs/core/Loading/sceneLoader"; 3 | import { MeshBuilder } from "@babylonjs/core/Meshes/meshBuilder"; 4 | import { DirectionalLight } from "@babylonjs/core/Lights/directionalLight"; 5 | import { Vector3 } from "@babylonjs/core/Maths/math.vector"; 6 | import { Color3 } from "@babylonjs/core/Maths/math.color"; 7 | import { StandardMaterial } from "@babylonjs/core/Materials/standardMaterial"; 8 | import { Texture } from "@babylonjs/core/Materials/Textures"; 9 | import { CubeTexture } from "@babylonjs/core/Materials/Textures/cubeTexture"; 10 | import "@babylonjs/loaders"; 11 | import "@babylonjs/inspector"; 12 | 13 | import Player from "../player/Player"; 14 | import FullScreenUI from "../ui/FullScreenUI"; 15 | import GoalMesh from "./components/GoalMesh"; 16 | import OtherPlayer from "../player/OtherPlayer"; 17 | import Game from "../Game"; 18 | import Config from "../Config"; 19 | import { PlayerSchema } from "../networking/schema/PlayerSchema"; 20 | import Timer from "./components/Timer"; 21 | import Spawn from "./components/Spawn"; 22 | import AudioManager from "./AudioManager"; 23 | import { HemisphericLight, SubMesh } from "@babylonjs/core"; 24 | 25 | export default class Level { 26 | public scene: Scene; 27 | public player: Player; 28 | public ui: FullScreenUI; 29 | public audioManager: AudioManager; 30 | public spawn: Spawn; 31 | public goal: GoalMesh; 32 | public otherPlayersMap: Map; 33 | public startLevelTimer: Timer; 34 | public checkPointLimit: number; 35 | 36 | private isFrozen: boolean 37 | private fileName: string; 38 | 39 | constructor(fileName: string) { 40 | this.initializeScene(); 41 | this.setupListeners(); 42 | 43 | this.isFrozen = false; 44 | this.fileName = fileName; 45 | this.otherPlayersMap = new Map; 46 | this.checkPointLimit = 5; // how many checkpoints can the player make per level 47 | this.player = new Player(this); 48 | this.audioManager = new AudioManager(this.scene); 49 | } 50 | 51 | private initializeScene() { 52 | this.scene = new Scene(Game.engine); 53 | this.scene.collisionsEnabled = true; 54 | if (Config.showInspector) { 55 | this.scene.debugLayer.show(); 56 | } 57 | } 58 | 59 | public async build() { 60 | await this.importLevel(); 61 | // add a skybox internally to the level 62 | this.createSkyBox(); 63 | await Promise.all([this.player.build(), this.audioManager.loadAudio()]); 64 | this.ui = new FullScreenUI(); 65 | 66 | this.startLevelTimer = new Timer(this.ui); 67 | this.startLevelTimer.start(); 68 | } 69 | 70 | private async importLevel() { 71 | await SceneLoader.AppendAsync("assets/scenes/", this.fileName, this.scene); 72 | this.applyModifiers(); 73 | } 74 | 75 | private applyModifiers() { 76 | this.scene.meshes.forEach(mesh => { 77 | // set colliders and whether we can pick mesh with raycast 78 | const isCollider = mesh.name.includes("Collider"); 79 | mesh.checkCollisions = isCollider; 80 | mesh.isPickable = isCollider; 81 | }); 82 | 83 | // If no lightning is added from blender add it manually 84 | if (this.scene.lights.length == 0) { 85 | this.setupLighting(); 86 | 87 | } else { 88 | 89 | } 90 | 91 | this.setupSpawn(); 92 | this.setupGoal(); 93 | } 94 | 95 | private setupSpawn() { 96 | let spawnMesh = this.scene.getMeshByName("Spawn"); 97 | if (spawnMesh == null) { 98 | throw new Error("No mesh in scene with a 'Spawn' ID!"); 99 | } 100 | const spawnPos = spawnMesh.position.clone(); 101 | // get lookAt mesh for initial player view direction 102 | let lookAtMesh = this.scene.getMeshByName("LookAt"); 103 | let lookAt = Vector3.Zero(); 104 | if (lookAtMesh != null) { 105 | lookAt = lookAtMesh.position.clone(); 106 | } 107 | this.spawn = new Spawn(spawnPos, lookAt); 108 | // destroy spawnMesh and lookAtMesh after they have been retrieved 109 | spawnMesh.dispose(); 110 | spawnMesh = null; 111 | // dispose only if LookAt exists as a mesh inside scene 112 | if(lookAtMesh){ 113 | lookAtMesh.dispose(); 114 | lookAtMesh = null; 115 | } 116 | } 117 | 118 | // todo - verify that there is only a single goal mesh 119 | private setupGoal() { 120 | const goalMesh = this.scene.getMeshByID("Goal"); 121 | if (goalMesh == null) { 122 | throw new Error("No mesh in scene with a 'Goal' ID!"); 123 | } 124 | this.goal = new GoalMesh(this, goalMesh); 125 | } 126 | 127 | private setupLighting() { 128 | // setup light 129 | new HemisphericLight("HemiLight", new Vector3(0, 1, 0), this.scene); 130 | } 131 | 132 | // called after finishing level 133 | public setFrozen(frozen: boolean) { 134 | // player can no longer move if frozen 135 | this.isFrozen = frozen; 136 | this.startLevelTimer.setPaused(frozen); 137 | if (frozen) { 138 | this.exitPointerLock(); 139 | } 140 | } 141 | 142 | public async addNewOtherPlayer(playerSchema: PlayerSchema) { 143 | const otherPlayer = new OtherPlayer(playerSchema.sessionId, this); 144 | await otherPlayer.build(); 145 | otherPlayer.update(playerSchema); 146 | this.otherPlayersMap.set(playerSchema.sessionId, otherPlayer); 147 | } 148 | 149 | public removeOtherPlayer(playerSchema: PlayerSchema) { 150 | this.otherPlayersMap.get(playerSchema.sessionId).dispose(); 151 | this.otherPlayersMap.delete(playerSchema.sessionId); 152 | } 153 | 154 | public updateOtherPlayer(playerSchema: PlayerSchema) { 155 | const otherPlayer = this.otherPlayersMap.get(playerSchema.sessionId); 156 | if(otherPlayer) { 157 | otherPlayer.update(playerSchema); 158 | } 159 | } 160 | 161 | public update() { 162 | this.scene.render(); 163 | } 164 | 165 | public restart() { 166 | this.player.respawn(); 167 | this.startLevelTimer.restart(); 168 | } 169 | 170 | private setupListeners() { 171 | // Lock cursor 172 | Game.canvas.addEventListener("click", () => { 173 | if (!this.isFrozen) { 174 | this.requestPointerLock(); 175 | } 176 | }, false); 177 | 178 | // update function for level components 179 | this.scene.registerBeforeRender(() => { 180 | if (!this.isFrozen) { 181 | this.player.update(); 182 | this.goal.update(); 183 | } 184 | }); 185 | } 186 | 187 | private requestPointerLock() { 188 | if (Game.canvas.requestPointerLock) { 189 | Game.canvas.requestPointerLock(); 190 | } 191 | } 192 | 193 | private exitPointerLock() { 194 | document.exitPointerLock(); 195 | } 196 | 197 | private createSkyBox() { 198 | // creating skybox 199 | let skybox = MeshBuilder.CreateBox("skyBox", { size: 10000.0 }, this.scene); 200 | let skyboxMaterial = new StandardMaterial("skyboxMaterial", this.scene); 201 | skyboxMaterial.backFaceCulling = false; 202 | skyboxMaterial.reflectionTexture = new CubeTexture("assets/textures/skybox", this.scene); 203 | skyboxMaterial.reflectionTexture.coordinatesMode = Texture.SKYBOX_MODE; 204 | skyboxMaterial.diffuseColor = new Color3(0, 0, 0); 205 | skyboxMaterial.specularColor = new Color3(0, 0, 0); 206 | skybox.material = skyboxMaterial; 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /client/src/levels/components/CheckPoint.ts: -------------------------------------------------------------------------------- 1 | import { Vector3 } from "@babylonjs/core/Maths/math.vector"; 2 | import { Control, TextBlock } from "@babylonjs/gui/2D"; 3 | 4 | import Player from "../../player/Player"; 5 | 6 | export default class CheckPoint { 7 | public position: Vector3; 8 | public cameraRotation: Vector3; 9 | 10 | private player: Player; 11 | private count: number; 12 | private limit: number 13 | private timeoutHandle: number; 14 | private uiText: TextBlock; 15 | 16 | constructor(player: Player) { 17 | this.player = player; 18 | this.count = 0; 19 | this.limit = this.player.level.checkPointLimit; 20 | this.set(); 21 | } 22 | 23 | private set() { 24 | this.position = this.player.mesh.get().position.clone(); 25 | this.cameraRotation = this.player.camera.get().rotation.clone(); 26 | } 27 | 28 | public save(onGround: boolean) { 29 | // do not make checkpoint if player can not make more checkpoints 30 | if (this.count >= this.limit) { 31 | this.showMessage("You have no more checkpoints left!", true); 32 | } 33 | // do not allow checkpoint if player is not standing on the ground 34 | else if (!onGround) { 35 | this.showMessage("You must be on the ground!", true); 36 | } else { 37 | this.set(); 38 | this.count++; 39 | this.showMessage("Created a new checkpoint (" + this.count + "/" + this.limit + ")"); 40 | } 41 | } 42 | 43 | public load() { 44 | this.player.mesh.get().position = this.position.clone(); 45 | this.player.camera.get().position = this.position.clone(); 46 | this.player.camera.get().rotation = this.cameraRotation.clone(); 47 | this.player.hSpeed = 0; 48 | this.player.vSpeed = 0; 49 | this.showMessage("Loaded your latest checkpoint!"); 50 | } 51 | 52 | private showMessage(message: string, isError = false) { 53 | if (this.uiText) { 54 | this.uiText.dispose(); 55 | } 56 | this.uiText = new TextBlock("checkpointText"); 57 | this.uiText.color =(isError) ? "red" : "green"; 58 | this.uiText.fontSize = 32; 59 | this.uiText.widthInPixels = 1000; 60 | this.uiText.heightInPixels = 100; 61 | this.uiText.fontFamily = "Helvetica"; 62 | this.uiText.text = message; 63 | this.uiText.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER; 64 | this.uiText.verticalAlignment = Control.VERTICAL_ALIGNMENT_CENTER; 65 | this.uiText.top = -100; 66 | this.player.level.ui.advancedTexture.addControl(this.uiText); 67 | 68 | window.clearTimeout(this.timeoutHandle); 69 | this.timeoutHandle = window.setTimeout(() => { 70 | this.uiText.dispose(); 71 | this.uiText = null; 72 | }, 5000); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /client/src/levels/components/GoalMesh.ts: -------------------------------------------------------------------------------- 1 | import { MeshBuilder, } from "@babylonjs/core/Meshes/meshBuilder"; 2 | import { AbstractMesh } from "@babylonjs/core/Meshes/abstractMesh"; 3 | import { AdvancedDynamicTexture, Button, Container, Control, Rectangle, StackPanel, TextBlock } from "@babylonjs/gui/2D"; 4 | 5 | import Level from "../Level"; 6 | 7 | export default class GoalMesh { 8 | private mesh: AbstractMesh; 9 | private goalTextMesh: AbstractMesh; 10 | private level: Level; 11 | 12 | constructor(level: Level, mesh: AbstractMesh) { 13 | this.level = level; 14 | this.mesh = mesh; 15 | this.addGoalUI(); 16 | } 17 | 18 | private addGoalUI() { 19 | const dim = this.mesh.getBoundingInfo().maximum.multiply(this.mesh.scaling); 20 | const verticalOffset = 3; // 2 units above top of mesh 21 | this.goalTextMesh = MeshBuilder.CreatePlane("goalTextPlane", {width: dim.x*4, height: dim.y*4}, this.level.scene); 22 | this.goalTextMesh.position.set(this.mesh.position.x, this.mesh.position.y + dim.y + verticalOffset, this.mesh.position.z); 23 | // render this mesh in front of everything 24 | this.goalTextMesh.renderingGroupId = 1; 25 | const ui = AdvancedDynamicTexture.CreateForMesh(this.goalTextMesh, 1024, 1024, false); 26 | const text = new TextBlock(); 27 | text.text = "Goal"; 28 | text.color = "white"; 29 | text.fontSize = 400; 30 | ui.addControl(text); 31 | } 32 | 33 | private showGoalPopup() { 34 | // popup window 35 | const rectangle = new Rectangle(); 36 | rectangle.background = "#878BFF"; 37 | rectangle.color = "black"; 38 | rectangle.cornerRadius = 20; 39 | rectangle.thickness = 5; 40 | rectangle.widthInPixels = 600; 41 | rectangle.heightInPixels = 400; 42 | this.level.ui.advancedTexture.addControl(rectangle); 43 | 44 | // stack panel 45 | const stackPanel = new StackPanel("goalStackpanel"); 46 | stackPanel.verticalAlignment = Control.VERTICAL_ALIGNMENT_TOP; 47 | rectangle.addControl(stackPanel); 48 | 49 | // popup text 50 | const goalText = new TextBlock("goalText"); 51 | goalText.paddingBottomInPixels = 20; 52 | goalText.fontFamily = "Helvetica"; 53 | goalText.textWrapping = true; 54 | goalText.lineSpacing = 15; 55 | goalText.text = "Congratulations, you beat the map!\nYour final time was " + 56 | this.level.startLevelTimer.timeSpent.toFixed(1) + " seconds.\nYour current position on the leaderboard: 1st"; 57 | goalText.color = "white"; 58 | goalText.fontSize = 24; 59 | goalText.widthInPixels = 550; 60 | goalText.heightInPixels = 200; 61 | stackPanel.addControl(goalText); 62 | 63 | // panel for buttons 64 | const panel = new StackPanel("goalButtonPanel"); 65 | panel.width = stackPanel.width; 66 | panel.widthInPixels = 400; 67 | panel.heightInPixels = 100; 68 | panel.verticalAlignment = Control.VERTICAL_ALIGNMENT_TOP; 69 | panel.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER; 70 | panel.isVertical = false; 71 | stackPanel.addControl(panel); 72 | 73 | // restart button 74 | const restartButton = this.createButton("restartButton", "Restart", panel); 75 | restartButton.paddingRightInPixels = 15; 76 | restartButton.onPointerClickObservable.add(() => { 77 | this.level.restart(); 78 | this.level.setFrozen(false); 79 | rectangle.dispose(); 80 | }); 81 | 82 | // view leaderboard button 83 | const boardButton = this.createButton("boardButton", "View Leaderboard", panel); 84 | boardButton.paddingLeftInPixels = 15; 85 | boardButton.onPointerClickObservable.add(() => { 86 | // TODO - leaderboard does not exist yet 87 | }); 88 | 89 | // back-to-lobby button 90 | const backButton = this.createButton("backButton", "Back to lobby", stackPanel); 91 | backButton.onPointerClickObservable.add(() => { 92 | // TODO - lobby does not exist yet 93 | }); 94 | } 95 | 96 | private createButton(name: string, text: string, parent?: Container): Button { 97 | const button = Button.CreateSimpleButton(name, text); 98 | button.widthInPixels = 200; 99 | button.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER; 100 | button.verticalAlignment = Control.VERTICAL_ALIGNMENT_CENTER; 101 | button.heightInPixels = 60; 102 | button.cornerRadius = 20; 103 | button.thickness = 4; 104 | button.children[0].color = "white"; 105 | button.children[0].fontSize = 20; 106 | button.color = "black"; 107 | button.background = "#3FA938"; 108 | parent.addControl(button); 109 | return button; 110 | } 111 | 112 | public update() { 113 | // use onGround and onCeiling since apparently intersectsMesh doesn't work very well 114 | const playerMesh = this.level.player.mesh; 115 | const onGround = playerMesh.isOnGround() && playerMesh.groundCollisionInfo.pickedMesh.uniqueId == this.mesh.uniqueId; 116 | const onCeiling = playerMesh.isOnCeiling() && playerMesh.ceilingCollisionInfo.pickedMesh.uniqueId == this.mesh.uniqueId; 117 | if (onGround || onCeiling || this.mesh.intersectsMesh(playerMesh.get())) { 118 | this.level.setFrozen(true); 119 | this.showGoalPopup(); 120 | } 121 | // rotate goalTextMesh to always face towards the player 122 | const target = this.goalTextMesh.position.scale(2).subtract(playerMesh.get().position); 123 | this.goalTextMesh.lookAt(target); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /client/src/levels/components/Spawn.ts: -------------------------------------------------------------------------------- 1 | import { Vector3 } from "@babylonjs/core/Maths/math.vector"; 2 | 3 | export default class Spawn { 4 | public lookAt: Vector3; 5 | public spawnPoint: Vector3; 6 | 7 | constructor(spawnPoint: Vector3, lookAt: Vector3) { 8 | this.lookAt = lookAt; 9 | this.spawnPoint = spawnPoint; 10 | } 11 | 12 | public clone(): Spawn { 13 | return new Spawn(this.spawnPoint.clone(), this.lookAt.clone()); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /client/src/levels/components/Timer.ts: -------------------------------------------------------------------------------- 1 | import { Control, TextBlock } from "@babylonjs/gui/2D"; 2 | 3 | import FullScreenUI from "../../ui/FullScreenUI"; 4 | 5 | export default class Timer { 6 | public timeSpent: number; 7 | 8 | private ui: FullScreenUI; 9 | private timerText: TextBlock; 10 | private paused: boolean; 11 | 12 | constructor(ui: FullScreenUI) { 13 | this.timeSpent = 0.0; 14 | this.ui = ui; 15 | this.show(); 16 | } 17 | 18 | public restart() { 19 | this.timeSpent = 0; 20 | } 21 | 22 | public start() { 23 | window.setInterval(() => { 24 | if (!this.paused) { 25 | this.timeSpent += 0.1; 26 | this.timerText.text = this.timeSpent.toFixed(1); 27 | } 28 | }, 100); 29 | } 30 | 31 | public setPaused(paused: boolean) { 32 | this.paused = paused; 33 | } 34 | 35 | private show() { 36 | const adt = this.ui.advancedTexture; 37 | 38 | // popup text 39 | this.timerText = new TextBlock("timerText"); 40 | this.timerText.color = "white"; 41 | this.timerText.fontSize = 32; 42 | this.timerText.widthInPixels = 200; 43 | this.timerText.heightInPixels = 100; 44 | this.timerText.fontFamily = "Helvetica"; 45 | this.timerText.text = "0.0"; 46 | this.timerText.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT; 47 | this.timerText.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM; 48 | adt.addControl(this.timerText); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /client/src/networking/rooms/ChatRoom.ts: -------------------------------------------------------------------------------- 1 | import {Room} from "colyseus.js" 2 | 3 | import Game from "../../Game"; 4 | 5 | export default class ChatRoom { 6 | public room: Room; 7 | private chatBox: HTMLElement = document.getElementById("chat-box"); 8 | private chatInput: HTMLElement = document.getElementById("chat-input"); 9 | private chatForm: HTMLElement = document.getElementById("chat-form"); 10 | 11 | constructor() { 12 | }; 13 | 14 | public async connect() { 15 | await this.initJoinOrCreateRoom(); 16 | this.onsend(this.chatForm, this.chatInput ); 17 | this.onMessage(this.chatBox); 18 | } 19 | 20 | private async initJoinOrCreateRoom() { 21 | this.room = await Game.client.joinOrCreate("ChatRoom"); 22 | console.log(this.room.sessionId, " joined ", this.room.name); 23 | } 24 | 25 | private onsend(chatForm: HTMLElement, chatInput:HTMLInputElement) { 26 | // prevent player movement while typing in chat 27 | chatInput.addEventListener('keydown', (evt) => evt.stopPropagation()); 28 | chatInput.addEventListener('keyup', (evt) => evt.stopPropagation()); 29 | 30 | chatForm.onsubmit = (evt) => { 31 | evt.preventDefault(); 32 | this.room.send("message", chatInput.value); 33 | chatInput.value = ' '; 34 | //unfocus the input box 35 | chatInput.blur(); 36 | } 37 | } 38 | 39 | private onMessage(chatBox: HTMLElement){ 40 | this.room.onMessage("messages", (message) => { 41 | let xH = chatBox.scrollHeight; 42 | chatBox.innerHTML += '

' + message + '

'; 43 | chatBox.scrollTo(0,xH); 44 | }); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /client/src/networking/rooms/GameRoom.ts: -------------------------------------------------------------------------------- 1 | import {Room} from "colyseus.js" 2 | import Game from "../../Game"; 3 | import Player from "../../player/Player"; 4 | import {StateHandlerSchema} from "../schema/StateHandlerSchema" 5 | import {PlayerSchema} from "../schema/PlayerSchema"; 6 | import Level from "../../levels/Level"; 7 | 8 | export default class GameRoom { 9 | private room: Room; 10 | private clientPlayer: Player; 11 | private level: Level; 12 | 13 | constructor(level: Level) { 14 | this.level = level; 15 | this.clientPlayer = level.player; 16 | }; 17 | 18 | public async connect() { 19 | await this.initJoinOrCreateRoom(); 20 | this.onMessage(); 21 | this.onStateChange(); 22 | this.onAddPlayers(); 23 | this.onRemovePlayers(); 24 | } 25 | 26 | private async initJoinOrCreateRoom() { 27 | this.room = await Game.client.joinOrCreate("GameRoom") 28 | console.log(this.room.sessionId, "joined", this.room.name); 29 | } 30 | 31 | private onMessage(){ 32 | this.room.onMessage("key", (message) => { 33 | console.log(message); 34 | }); 35 | } 36 | 37 | private onStateChange() { 38 | this.room.onStateChange((state: StateHandlerSchema) => { 39 | state.players.forEach((player: PlayerSchema, key: string) => { 40 | //updates other player if key does not equal to sessionID 41 | if (key !== this.room.sessionId) { 42 | this.level.updateOtherPlayer(player); 43 | } 44 | }); 45 | }); 46 | }; 47 | 48 | private onAddPlayers(){ 49 | this.room.state.players.onAdd = (player: PlayerSchema) => { 50 | console.log(player, "has been added at", player.sessionId); 51 | if(player.sessionId !== this.room.sessionId){ 52 | this.level.addNewOtherPlayer(player); 53 | } 54 | }; 55 | } 56 | 57 | private onRemovePlayers() { 58 | this.room.state.players.onRemove = (player: PlayerSchema) => { 59 | console.log(player, "has been removed", player.sessionId); 60 | this.level.removeOtherPlayer(player); 61 | }; 62 | } 63 | 64 | 65 | public updatePlayerToServer(){ 66 | const pos = this.clientPlayer.getPosition(); 67 | const dir = this.clientPlayer.getDirection(); 68 | const keys = this.clientPlayer.controls.keys; 69 | 70 | this.room.send("playerPosition", {x: pos.x, y: pos.y, z: pos.z}); 71 | this.room.send("playerDirection", {rotationY: dir.y}); 72 | this.room.send("playerKey", {up: keys.up, right: keys.right, down: keys.down, left: keys.left, jump: keys.jump}); 73 | this.room.send("playerCrouching", {crouching: this.clientPlayer.crouching}); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /client/src/networking/schema/PlayerSchema.ts: -------------------------------------------------------------------------------- 1 | import {Schema, type} from '@colyseus/schema'; 2 | 3 | export class PlayerDirectionSchema extends Schema { 4 | @type('number') public rotationY: number; 5 | } 6 | 7 | export class PlayerCrouchSchema extends Schema { 8 | @type('boolean') public crouching: boolean; 9 | } 10 | 11 | export class PlayerKeySchema extends Schema { 12 | @type('boolean') public up: boolean; 13 | @type('boolean') public right: boolean; 14 | @type('boolean') public down: boolean; 15 | @type('boolean') public left: boolean; 16 | @type('boolean') public jump: boolean; 17 | } 18 | 19 | export class PlayerPositionSchema extends Schema { 20 | @type('number') public x: number; 21 | @type('number') public y: number; 22 | @type('number') public z: number; 23 | } 24 | 25 | export class PlayerSchema extends Schema { 26 | 27 | @type('string') public sessionId: string; 28 | @type(PlayerPositionSchema) public playerPosition = new PlayerPositionSchema(); 29 | @type(PlayerDirectionSchema) public playerDirection = new PlayerDirectionSchema(); 30 | @type(PlayerKeySchema) public playerKey = new PlayerKeySchema(); 31 | @type(PlayerCrouchSchema) public playerCrouch = new PlayerCrouchSchema(); 32 | 33 | } 34 | -------------------------------------------------------------------------------- /client/src/networking/schema/StateHandlerSchema.ts: -------------------------------------------------------------------------------- 1 | import {Schema, type, MapSchema} from '@colyseus/schema'; 2 | 3 | import {PlayerCrouchSchema, PlayerDirectionSchema, PlayerKeySchema, PlayerPositionSchema, PlayerSchema} from './PlayerSchema'; 4 | 5 | export class StateHandlerSchema extends Schema { 6 | 7 | @type({map: PlayerSchema}) 8 | players = new MapSchema(); 9 | 10 | addPlayer(sessionId: string) { 11 | this.players.set(sessionId, new PlayerSchema().assign({sessionId: sessionId})); 12 | } 13 | 14 | getPlayer(sessionId: string): PlayerSchema { 15 | return this.players.get(sessionId); 16 | } 17 | 18 | removePlayer(sessionId: string) { 19 | this.players.delete(sessionId); 20 | } 21 | 22 | setDirection(sessionId: string, direction: PlayerDirectionSchema) { 23 | this.getPlayer(sessionId).playerDirection.rotationY = direction.rotationY; 24 | } 25 | 26 | setKeys(sessionId: string, keys: PlayerKeySchema) { 27 | const player = this.getPlayer(sessionId); 28 | player.playerKey.up = keys.up; 29 | player.playerKey.right = keys.right; 30 | player.playerKey.down = keys.down; 31 | player.playerKey.left = keys.left; 32 | player.playerKey.jump = keys.jump; 33 | } 34 | 35 | setPosition(sessionId: string, position: PlayerPositionSchema) { 36 | const player = this.getPlayer(sessionId); 37 | player.playerPosition.x = position.x; 38 | player.playerPosition.y = position.y; 39 | player.playerPosition.z = position.z; 40 | } 41 | 42 | setCrouching(sessionId: string, crouchSchema: PlayerCrouchSchema) { 43 | this.getPlayer(sessionId).playerCrouch.crouching = crouchSchema.crouching; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /client/src/player/AbstractPlayer.ts: -------------------------------------------------------------------------------- 1 | import Level from "../levels/Level"; 2 | import { IMoveKeys } from "./PlayerControls"; 3 | import PlayerMesh from "./PlayerMesh"; 4 | 5 | export enum MoveDirection { 6 | FORWARD, 7 | FORWARD_LEFT, 8 | FORWARD_RIGHT, 9 | BACK, 10 | BACK_LEFT, 11 | BACK_RIGHT, 12 | LEFT, 13 | RIGHT, 14 | IDLE 15 | }; 16 | 17 | export default abstract class AbstractPlayer { 18 | public static readonly HEIGHT = 8; 19 | public static readonly DIAMETER = 4; // of cylinder 20 | public static readonly CROUCH_Y_SCALING = 0.65; 21 | 22 | public level: Level; 23 | public mesh: PlayerMesh; 24 | public crouching: boolean; 25 | 26 | protected standMesh: PlayerMesh; 27 | protected crouchMesh: PlayerMesh; 28 | 29 | constructor(level: Level, isOtherPlayer: boolean) { 30 | this.level = level; 31 | 32 | const modelScaling = 4.5; 33 | const standAnimSpeedRatio = 1; 34 | const crouchAnimSpeedRatio = 1.3; 35 | this.standMesh = new PlayerMesh("playerStand", "stand.glb", 36 | AbstractPlayer.HEIGHT, AbstractPlayer.DIAMETER, modelScaling, standAnimSpeedRatio, this.level.scene, isOtherPlayer); 37 | this.crouchMesh = new PlayerMesh("playerCrouch", "crouch.glb", 38 | AbstractPlayer.HEIGHT * AbstractPlayer.CROUCH_Y_SCALING, AbstractPlayer.DIAMETER, modelScaling, crouchAnimSpeedRatio, this.level.scene, isOtherPlayer); 39 | } 40 | 41 | public async build() { 42 | await Promise.all([this.standMesh.build(), this.crouchMesh.build()]); 43 | 44 | // start by standing up 45 | this.mesh = this.standMesh; 46 | this.crouchMesh.setEnabled(false); 47 | this.mesh.get().position = this.level.spawn.spawnPoint.clone(); 48 | } 49 | 50 | public respawn() { 51 | // set position and switch mesh 52 | this.standMesh.get().position = this.level.spawn.spawnPoint.clone(); 53 | this.crouchMesh.get().position = this.level.spawn.spawnPoint.clone(); 54 | this.switchMesh(false); 55 | this.crouching = false; 56 | } 57 | 58 | protected switchMesh(doCrouch: boolean) { 59 | // if standing, switch to the crouching mesh and vica versa 60 | const tempMesh = (doCrouch) ? this.crouchMesh : this.standMesh; 61 | tempMesh.get().position = this.mesh.get().position.clone(); 62 | tempMesh.get().rotation = this.mesh.get().rotation.clone(); 63 | this.mesh.setEnabled(false); 64 | tempMesh.setEnabled(true); 65 | this.mesh = tempMesh; 66 | } 67 | 68 | public dispose() { 69 | this.standMesh.dispose(); 70 | this.crouchMesh.dispose(); 71 | } 72 | 73 | public getMoveDirection(keys: IMoveKeys): MoveDirection { 74 | if (keys.up && keys.left && !keys.down && !keys.right) { 75 | return MoveDirection.FORWARD_LEFT; 76 | } else if (keys.up && keys.right && !keys.down && !keys.left) { 77 | return MoveDirection.FORWARD_RIGHT; 78 | } else if (keys.down && keys.left && !keys.up && !keys.right) { 79 | return MoveDirection.BACK_LEFT; 80 | } else if (keys.down && keys.right && !keys.up && !keys.left) { 81 | return MoveDirection.BACK_RIGHT; 82 | } 83 | 84 | 85 | if (keys.up && !keys.down) { 86 | return MoveDirection.FORWARD; 87 | } else if (keys.down && !keys.up) { 88 | return MoveDirection.BACK; 89 | } else if (keys.left && !keys.right) { 90 | return MoveDirection.LEFT; 91 | } else if (keys.right && !keys.left) { 92 | return MoveDirection.RIGHT; 93 | } 94 | return MoveDirection.IDLE; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /client/src/player/OtherPlayer.ts: -------------------------------------------------------------------------------- 1 | import Level from "../levels/Level"; 2 | import {PlayerSchema} from "../networking/schema/PlayerSchema"; 3 | import AbstractPlayer, { MoveDirection } from "./AbstractPlayer"; 4 | import { IMoveKeys } from "./PlayerControls"; 5 | 6 | export default class OtherPlayer extends AbstractPlayer { 7 | public sessionId: string; 8 | 9 | constructor(sessionId: string, level: Level) { 10 | super(level, true); 11 | this.sessionId = sessionId; 12 | } 13 | 14 | public update(playerSchema: PlayerSchema) { 15 | const newPos = playerSchema.playerPosition; 16 | const newDir = playerSchema.playerDirection; 17 | const newKeys = playerSchema.playerKey; 18 | const newCrouch = playerSchema.playerCrouch.crouching; 19 | 20 | this.mesh.update(); 21 | const onGround = this.mesh.isOnGround(); 22 | const moveDirection = this.getMoveDirection(newKeys); 23 | this.mesh.get().position.set(newPos.x, newPos.y, newPos.z); 24 | this.mesh.get().rotation.y = newDir.rotationY; 25 | 26 | this.handleKeys(newKeys, newCrouch, onGround, moveDirection); 27 | } 28 | 29 | private handleKeys(keys: IMoveKeys, newCrouch: boolean, onGround: boolean, moveDirection: MoveDirection) { 30 | 31 | // animations 32 | this.mesh.animator.update(moveDirection, onGround); 33 | 34 | // crouch 35 | if (newCrouch != this.crouching) { 36 | this.crouching = newCrouch; 37 | this.switchMesh(newCrouch); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /client/src/player/Player.ts: -------------------------------------------------------------------------------- 1 | import {Vector3, Matrix} from "@babylonjs/core/Maths/math.vector"; 2 | import {Axis} from "@babylonjs/core/Maths/math.axis" 3 | 4 | import Controls, { IKeys } from "./PlayerControls"; 5 | import PlayerCamera from "./camera/PlayerCamera"; 6 | import Config from "../Config"; 7 | import AbstractPlayer, { MoveDirection } from "./AbstractPlayer"; 8 | import Spawn from "../levels/components/Spawn"; 9 | import Level from "../levels/Level"; 10 | import CheckPoint from "../levels/components/CheckPoint"; 11 | import AudioManager from "../levels/AudioManager"; 12 | 13 | // configurables 14 | const SPEED_DEFAULT = 0.7; 15 | const SPEED_CROUCH = SPEED_DEFAULT * 0.5; 16 | const SPEED_JUMP = 0.4; 17 | const GRAVITY = 0.016; 18 | const GRAVITY_LIMIT = - (5 * SPEED_JUMP); 19 | 20 | export default class Player extends AbstractPlayer { 21 | public spawn: Spawn; 22 | public controls: Controls; 23 | public camera: PlayerCamera; 24 | public hSpeed: number; 25 | public vSpeed: number; 26 | 27 | // locks are used to prevent user from spamming the key by holding it 28 | private jumpingLock: boolean; 29 | private checkPointLock: boolean; 30 | private gotoCheckPointLock: boolean; 31 | private restartLock: boolean; 32 | private checkPoint: CheckPoint; 33 | private audioManager: AudioManager; 34 | private prevOnGroundDir: MoveDirection; // used to make player move same direction when jumping/falling 35 | 36 | constructor(level: Level) { 37 | super(level, false); 38 | this.hSpeed = 0; 39 | this.vSpeed = 0; 40 | this.prevOnGroundDir = MoveDirection.IDLE; 41 | this.controls = new Controls(); 42 | } 43 | 44 | public async build() { 45 | await super.build(); 46 | this.spawn = this.level.spawn; 47 | this.camera = new PlayerCamera(this); 48 | this.checkPoint = new CheckPoint(this); 49 | this.audioManager = this.level.audioManager; 50 | } 51 | 52 | public respawn() { 53 | super.respawn(); 54 | this.hSpeed = 0; 55 | this.vSpeed = 0; 56 | this.prevOnGroundDir = MoveDirection.IDLE; 57 | this.camera.reset(); 58 | this.checkPoint = new CheckPoint(this); 59 | } 60 | 61 | public setVisible(visible: boolean) { 62 | this.standMesh.setVisible(visible); 63 | this.crouchMesh.setVisible(visible); 64 | } 65 | 66 | public update() { 67 | // mesh.update casts new rays for collision detection (ground and ceiling) 68 | this.mesh.update(); 69 | // define constants to be used below 70 | const keys = this.controls.keys; 71 | const deltaTime = this.level.scene.getAnimationRatio(); 72 | const canStand = this.canStand(); 73 | const onGround = this.mesh.isOnGround(); 74 | const onCeiling = this.mesh.isOnCeiling(); 75 | const moveDirection = this.getMoveDirection(keys); 76 | 77 | this.handlePlayerMovement(keys, deltaTime, canStand, onGround, onCeiling, moveDirection); 78 | 79 | // handle checkpoint keys 80 | if (keys.checkpoint && !this.checkPointLock) { 81 | this.checkPoint.save(onGround); 82 | } 83 | this.checkPointLock = keys.checkpoint; 84 | if (keys.gotoCheckpoint && !this.gotoCheckPointLock) { 85 | this.checkPoint.load(); 86 | } 87 | this.gotoCheckPointLock = keys.gotoCheckpoint; 88 | 89 | // handle restart key 90 | if (keys.restart && !this.restartLock) { 91 | this.level.restart(); 92 | } 93 | this.restartLock = keys.restart; 94 | 95 | // handle camera keys 96 | if (keys.selectFirstPersonCamera) { 97 | this.camera.selectFirstPerson(); 98 | } else if (keys.selectThirdPersonCamera) { 99 | this.camera.selectThirdPerson(); 100 | } 101 | 102 | // update animations 103 | this.mesh.animator.update(moveDirection, onGround); 104 | 105 | // play sounds based on movement 106 | this.updatePlayerSounds(onGround, moveDirection); 107 | 108 | // update camera position and rotation 109 | this.camera.update(); 110 | 111 | // update collision-mesh position, if enabled 112 | if (Config.debugPlayer) { 113 | this.mesh.ellipsoidMesh.position = 114 | this.mesh.get().position.add(this.mesh.get().ellipsoidOffset); 115 | } 116 | } 117 | 118 | private handlePlayerMovement(keys: IKeys, deltaTime: number, canStand: boolean, onGround: boolean, onCeiling: boolean, moveDirection: MoveDirection) { 119 | // rotate mesh based on camera movement 120 | this.mesh.get().rotation.y = this.camera.get().rotation.y; 121 | 122 | const moveVector = Vector3.Zero(); 123 | this.setHorizontalMovement(moveVector, moveDirection, onGround); 124 | this.setVerticalMovement(moveVector, onGround, canStand, onCeiling, deltaTime, keys); 125 | 126 | // change mesh height if crouching 127 | if (keys.crouch != this.crouching) { 128 | this.crouch(keys.crouch, onGround, canStand); 129 | } 130 | 131 | // perform the movement 132 | this.mesh.get().moveWithCollisions(moveVector); 133 | } 134 | 135 | private setHorizontalMovement(moveVector: Vector3, currMoveDir: MoveDirection, onGround: boolean) { 136 | let moveDir; 137 | if (onGround) { 138 | moveDir = currMoveDir; 139 | this.prevOnGroundDir = currMoveDir; 140 | } else { 141 | moveDir = this.prevOnGroundDir; 142 | } 143 | 144 | const temp = this.getMoveVectorFromMoveDir(moveDir); 145 | moveVector.set(temp.x, temp.y, temp.z); 146 | 147 | // set hSpeed according to crouch 148 | if (this.crouching) { 149 | this.hSpeed = SPEED_CROUCH; 150 | } else { 151 | this.hSpeed = SPEED_DEFAULT; 152 | } 153 | 154 | // change to local space 155 | const m = Matrix.RotationAxis(Axis.Y, this.mesh.get().rotation.y); 156 | Vector3.TransformCoordinatesToRef(moveVector, m, moveVector); 157 | 158 | // Ensure diagonal is not faster than straight 159 | moveVector.normalize().scaleInPlace(this.hSpeed); 160 | } 161 | 162 | private setVerticalMovement(moveVector: Vector3, onGround: boolean, canStand: boolean, onCeiling: boolean, deltaTime: number, keys: IKeys) { 163 | if (onGround && this.vSpeed <= 0) { // don't trigger if moving upwards 164 | // landing 165 | if (this.vSpeed < 0) { 166 | this.vSpeed = 0; 167 | } 168 | // change vertical speed if jumping 169 | if (keys.jump && !this.jumpingLock && canStand) { 170 | this.vSpeed = SPEED_JUMP; 171 | this.audioManager.playJump(true); 172 | } 173 | this.jumpingLock = keys.jump; 174 | 175 | } else { // not on ground 176 | if (onCeiling && this.vSpeed >= 0) { // don't trigger if falling 177 | this.vSpeed = 0; 178 | } 179 | // apply gravity (multiply with deltaTime cause it's an acceleration) 180 | this.vSpeed -= (GRAVITY * deltaTime); 181 | // clamp vSpeed 182 | if (this.vSpeed < (GRAVITY_LIMIT)) { 183 | this.vSpeed = GRAVITY_LIMIT; 184 | } 185 | moveVector.y = this.vSpeed; 186 | // scale movement with delta time 187 | moveVector.scaleInPlace(deltaTime); 188 | } 189 | } 190 | 191 | private getMoveVectorFromMoveDir(moveDir: MoveDirection): Vector3 { 192 | switch (moveDir) { 193 | case MoveDirection.FORWARD: return new Vector3(0, 0, 1); 194 | case MoveDirection.FORWARD_LEFT: return new Vector3(-1, 0, 1); 195 | case MoveDirection.FORWARD_RIGHT: return new Vector3(1, 0, 1); 196 | case MoveDirection.LEFT: return new Vector3(-1, 0, 0); 197 | case MoveDirection.RIGHT: return new Vector3(1, 0, 0); 198 | case MoveDirection.BACK: return new Vector3(0, 0, -1); 199 | case MoveDirection.BACK_LEFT: return new Vector3(-1, 0, -1); 200 | case MoveDirection.BACK_RIGHT: return new Vector3(1, 0, -1); 201 | } 202 | return Vector3.Zero(); 203 | } 204 | 205 | private updatePlayerSounds(onGround: boolean, moveDirection: MoveDirection) { 206 | const isRunning = this.isRunning(onGround, moveDirection); 207 | const isCrouchWalking = this.isCrouchWalking(onGround, moveDirection); 208 | this.audioManager.playRun(isRunning && !isCrouchWalking); 209 | this.audioManager.playCrouchWalk(!isRunning && isCrouchWalking); 210 | } 211 | 212 | private isRunning(isOnGround: boolean, moveDirection: MoveDirection) { 213 | if (this.crouching || !isOnGround) { 214 | return false; 215 | } 216 | return moveDirection != MoveDirection.IDLE; 217 | } 218 | 219 | private isCrouchWalking(isOnGround: boolean, moveDirection: MoveDirection) { 220 | if (!this.crouching || !isOnGround) { 221 | return false; 222 | } 223 | return moveDirection != MoveDirection.IDLE; 224 | } 225 | 226 | private canStand(): boolean { 227 | if (!this.crouching) 228 | return true; 229 | const offset = AbstractPlayer.CROUCH_Y_SCALING * AbstractPlayer.HEIGHT; 230 | return !this.mesh.isOnCeiling(offset); 231 | } 232 | 233 | private crouch(doCrouch: boolean, onGround: boolean, canStand: boolean) { 234 | if (this.crouching == doCrouch) { 235 | return; 236 | } 237 | if (!doCrouch && onGround && !canStand) { // standing up would place us inside a mesh 238 | return; 239 | } 240 | this.crouching = doCrouch; 241 | this.switchMesh(doCrouch); 242 | 243 | // adjust mesh position, which depends on whether we are on the ground 244 | // we want mesh height to change top-down if standing on the ground and bottom-up if airborne 245 | let changeY = AbstractPlayer.HEIGHT * (1 - AbstractPlayer.CROUCH_Y_SCALING) * 0.5; 246 | if (onGround == doCrouch) { 247 | changeY = -changeY; 248 | } 249 | this.mesh.get().position.y += changeY; 250 | 251 | // if we stand up just before hitting the ground, the mesh will be stuck in ground 252 | // we fix this by doing a new onGround raycast and reset vertical position 253 | if (!doCrouch && !onGround) { // this is only an issue when we are not already on the ground 254 | this.mesh.setToGroundLevel(); 255 | } 256 | 257 | this.camera.setCrouch(doCrouch); 258 | } 259 | 260 | public getPosition(): Vector3 { 261 | return this.mesh.get().position; 262 | } 263 | 264 | public getDirection(): Vector3 { 265 | return this.mesh.get().rotation; 266 | } 267 | } 268 | -------------------------------------------------------------------------------- /client/src/player/PlayerAnimator.ts: -------------------------------------------------------------------------------- 1 | import { Vector3 } from "@babylonjs/core/Maths/math.vector"; 2 | import { AnimationGroup } from "@babylonjs/core/Animations/animationGroup"; 3 | import { AbstractMesh } from "@babylonjs/core/Meshes/abstractMesh"; 4 | import { Scene } from "@babylonjs/core/scene"; 5 | import { SceneLoader} from "@babylonjs/core/Loading/sceneLoader"; 6 | 7 | import PlayerMesh from "./PlayerMesh"; 8 | import { MoveDirection } from "./AbstractPlayer"; 9 | 10 | export default class PlayerAnimator { 11 | private playerMesh: PlayerMesh; 12 | private animatorMesh: AbstractMesh; 13 | private name: string; 14 | private fileName: string; 15 | private scene: Scene; 16 | private scaling: number; 17 | private speedRatio: number; 18 | 19 | private forwardAnim: AnimationGroup; 20 | private leftAnim: AnimationGroup; 21 | private rightAnim: AnimationGroup; 22 | private backAnim: AnimationGroup; 23 | private idleAnim: AnimationGroup; 24 | private currentAnimation: AnimationGroup; 25 | 26 | constructor(playerMesh: PlayerMesh, name: string, fileName: string, scaling: number, speedRatio: number) { 27 | this.playerMesh = playerMesh; 28 | this.scene = playerMesh.scene; 29 | this.name = name; 30 | this.fileName = fileName; 31 | this.scaling = scaling; 32 | this.speedRatio = speedRatio; 33 | } 34 | 35 | public async build() { 36 | // Animation file is built in blender according to: https://doc.babylonjs.com/divingDeeper/animation/animatedCharacter 37 | const animations = await this.loadAnimations(this.fileName); 38 | 39 | this.forwardAnim = animations.find(anim => anim.name == "Forward"); 40 | this.idleAnim = animations.find(anim => anim.name == "Idle"); 41 | this.leftAnim = animations.find(anim => anim.name == "Left"); 42 | this.rightAnim = animations.find(anim => anim.name == "Right"); 43 | this.backAnim = animations.find(anim => anim.name == "Back"); 44 | 45 | // start with idle animation 46 | this.currentAnimation = this.idleAnim; 47 | this.currentAnimation.play(true); 48 | } 49 | 50 | private async loadAnimations(fileName: string) : Promise { 51 | const result = await SceneLoader.ImportMeshAsync("", "assets/models/", fileName, this.scene); 52 | this.animatorMesh = result.meshes[0]; 53 | this.animatorMesh.name = "animation" + this.name; 54 | this.animatorMesh.scaling.scaleInPlace(this.scaling); 55 | const height = this.playerMesh.get().getBoundingInfo().boundingBox.maximum.y; 56 | this.animatorMesh.position.y -= height; 57 | this.animatorMesh.parent = this.playerMesh.get(); 58 | // apply modifiers 59 | this.animatorMesh.checkCollisions = false; 60 | this.animatorMesh.isPickable = false; 61 | // apply to all children as well 62 | this.animatorMesh.getChildMeshes().forEach(child => { 63 | child.checkCollisions = false 64 | child.isPickable = false; 65 | }); 66 | 67 | const animations = result.animationGroups; 68 | 69 | // stop all animations and apply speed ratio 70 | animations.forEach(animation => { 71 | animation.stop(); 72 | animation.speedRatio = this.speedRatio; 73 | }); 74 | 75 | return animations; 76 | } 77 | 78 | public update(direction: MoveDirection, onGround: boolean) { 79 | // do not play animations when in air 80 | if (!onGround) { 81 | this.currentAnimation.pause(); 82 | return; 83 | } 84 | 85 | switch (direction) { 86 | case MoveDirection.FORWARD: 87 | case MoveDirection.FORWARD_LEFT: 88 | case MoveDirection.FORWARD_RIGHT: 89 | this.playAnimation(this.forwardAnim); 90 | break; 91 | case MoveDirection.BACK: 92 | case MoveDirection.BACK_LEFT: 93 | case MoveDirection.BACK_RIGHT: 94 | this.playAnimation(this.backAnim); 95 | break; 96 | case MoveDirection.LEFT: 97 | this.playAnimation(this.leftAnim); 98 | break; 99 | case MoveDirection.RIGHT: 100 | this.playAnimation(this.rightAnim); 101 | break; 102 | default: 103 | this.playAnimation(this.idleAnim); 104 | } 105 | 106 | // rotate mesh for diagonal movement (instead of replacing animation) 107 | if (direction == MoveDirection.FORWARD_LEFT || direction == MoveDirection.BACK_RIGHT) { 108 | this.animatorMesh.rotation = new Vector3(0, 0.75 * Math.PI, 0); 109 | } 110 | else if (direction == MoveDirection.FORWARD_RIGHT || direction == MoveDirection.BACK_LEFT) { 111 | this.animatorMesh.rotation = new Vector3(0, -0.75 * Math.PI, 0); 112 | } 113 | else { 114 | this.animatorMesh.rotation = new Vector3(0, Math.PI, 0); 115 | } 116 | } 117 | 118 | public setEnabled(enabled: boolean) { 119 | if (!enabled) { 120 | this.currentAnimation.stop(); 121 | } 122 | this.animatorMesh.setEnabled(enabled); 123 | } 124 | 125 | private playAnimation(animation: AnimationGroup, loop = true) { 126 | if (animation.isPlaying) { 127 | return; 128 | } 129 | this.currentAnimation.stop(); 130 | this.currentAnimation = animation; 131 | animation.play(loop); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /client/src/player/PlayerControls.ts: -------------------------------------------------------------------------------- 1 | export interface IKeys { 2 | up: boolean; 3 | down: boolean; 4 | left: boolean; 5 | right: boolean; 6 | jump: boolean; 7 | crouch: boolean; 8 | checkpoint: boolean; 9 | gotoCheckpoint: boolean; 10 | restart: boolean; 11 | selectFirstPersonCamera: boolean; 12 | selectThirdPersonCamera: boolean; 13 | } 14 | 15 | // used for receiving other player keys from server where we don't care about all keys 16 | export interface IMoveKeys { 17 | up: boolean; 18 | down: boolean; 19 | left: boolean; 20 | right: boolean; 21 | jump: boolean; 22 | } 23 | 24 | export default class PlayerControls { 25 | 26 | public keys: IKeys = { 27 | up: false, 28 | down: false, 29 | left: false, 30 | right: false, 31 | jump: false, 32 | crouch: false, 33 | checkpoint: false, 34 | gotoCheckpoint: false, 35 | restart: false, 36 | selectFirstPersonCamera: false, 37 | selectThirdPersonCamera: false 38 | } 39 | 40 | constructor() { 41 | this.setupListeners(); 42 | } 43 | 44 | private setupListeners() { 45 | window.onkeydown = (e: KeyboardEvent) => this.handleKey(e.code, true); 46 | window.onkeyup = (e: KeyboardEvent) => this.handleKey(e.code, false); 47 | } 48 | 49 | private handleKey(code: string, keydown: boolean) { 50 | switch (code) { 51 | case "KeyW": 52 | case "ArrowUp": 53 | this.keys.up = keydown; 54 | break; 55 | case "KeyA": 56 | case "ArrowLeft": 57 | this.keys.left = keydown; 58 | break; 59 | case "KeyS": 60 | case "ArrowDown": 61 | this.keys.down = keydown; 62 | break; 63 | case "KeyD": 64 | case "ArrowRight": 65 | this.keys.right = keydown; 66 | break; 67 | case "Space": 68 | this.keys.jump = keydown; 69 | break; 70 | case "KeyC": 71 | this.keys.crouch = keydown; 72 | break; 73 | case "KeyT": 74 | this.keys.checkpoint = keydown; 75 | break; 76 | case "KeyV": 77 | this.keys.gotoCheckpoint = keydown; 78 | break; 79 | case "KeyP": 80 | this.keys.restart = keydown; 81 | break; 82 | case "Digit1": 83 | this.keys.selectFirstPersonCamera = keydown; 84 | break; 85 | case "Digit2": 86 | this.keys.selectThirdPersonCamera = keydown; 87 | break; 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /client/src/player/PlayerMesh.ts: -------------------------------------------------------------------------------- 1 | import { AbstractMesh } from "@babylonjs/core/Meshes/abstractMesh"; 2 | import { Mesh } from "@babylonjs/core/Meshes/mesh"; 3 | import { MeshBuilder, } from "@babylonjs/core/Meshes/meshBuilder"; 4 | import { Vector3 } from "@babylonjs/core/Maths/math.vector"; 5 | import { Color3 } from "@babylonjs/core/Maths/math.color"; 6 | import { PickingInfo } from "@babylonjs/core/Collisions/pickingInfo"; 7 | import { Ray } from "@babylonjs/core/Culling/ray"; 8 | import { Scene } from "@babylonjs/core/scene"; 9 | import { StandardMaterial } from "@babylonjs/core/Materials/standardMaterial"; 10 | 11 | import Config from "../Config"; 12 | import PlayerAnimator from "./PlayerAnimator"; 13 | 14 | // How many units away from roof/ground before we detect a collision? 15 | const ROOF_COLLISION_THRESHOLD = 0.1; 16 | const GROUND_COLLISION_THRESHOLD = 0.1; 17 | 18 | // this class is a wrapper for the mesh class, and you can get the base mesh object by calling get() 19 | export default class PlayerMesh { 20 | public scene: Scene; 21 | public ellipsoidMesh: Mesh; 22 | public animator: PlayerAnimator; 23 | public groundCollisionInfo: PickingInfo; 24 | public ceilingCollisionInfo: PickingInfo; 25 | 26 | private mesh: AbstractMesh; 27 | private isOtherPlayer: boolean; 28 | private height: number; 29 | private width: number; 30 | private name: string; 31 | 32 | constructor(name: string, modelFileName: string, height: number, width: number, modelScaling: number, animationSpeedRatio: number, scene: Scene, isOtherPlayer: boolean) { 33 | this.scene = scene; 34 | this.height = height; 35 | this.width = width; 36 | this.name = name; 37 | this.isOtherPlayer = isOtherPlayer; 38 | this.animator = new PlayerAnimator(this, name, modelFileName, modelScaling, animationSpeedRatio); 39 | } 40 | 41 | public async build() { 42 | this.mesh = MeshBuilder.CreateCylinder(this.name, {height: this.height, diameter: this.width}); 43 | this.mesh.isPickable = false; 44 | this.mesh.checkCollisions = !this.isOtherPlayer; 45 | this.mesh.isVisible = false; 46 | 47 | // babylonjs uses ellipsoids to simulate mesh collisions when moving with camera, see: https://doc.babylonjs.com/divingDeeper/cameras/camera_collisions 48 | // sets the ellipsoid of this mesh to its bounding box 49 | if (!this.isOtherPlayer) { 50 | this.setEllipsoidToBoundingBox(); 51 | if (Config.debugPlayer) { 52 | this.drawCollisionEllipsoid(); 53 | } 54 | } 55 | 56 | // import and build animated models 57 | await this.animator.build(); 58 | } 59 | 60 | public update() { 61 | this.groundCollisionInfo = this.castRayToGround(); 62 | this.ceilingCollisionInfo = this.castRayToCeiling(); 63 | } 64 | 65 | public get(): AbstractMesh { 66 | return this.mesh; 67 | } 68 | 69 | public setVisible(visible: boolean) { 70 | // set visibility for all children (and not the parent mesh) 71 | this.mesh.getChildMeshes().forEach(child => { 72 | child.isVisible = visible; 73 | }); 74 | } 75 | 76 | public isOnGround(verticalOffset = 0): boolean { 77 | let onGround = false; 78 | const compareWith = this.mesh.getBoundingInfo().minimum.y + this.mesh.position.y + verticalOffset; 79 | if(this.groundCollisionInfo && this.groundCollisionInfo.hit) { 80 | const pickedY = this.groundCollisionInfo.pickedPoint.y; 81 | onGround = (pickedY + GROUND_COLLISION_THRESHOLD) >= compareWith; 82 | 83 | } 84 | return onGround; 85 | } 86 | 87 | public isOnCeiling(verticalOffset = 0): boolean { 88 | let onCeiling = false; 89 | const compareWith = this.mesh.getBoundingInfo().maximum.y + this.mesh.position.y + verticalOffset; 90 | if(this.ceilingCollisionInfo && this.ceilingCollisionInfo.hit) { 91 | const pickedY = this.ceilingCollisionInfo.pickedPoint.y; 92 | onCeiling = pickedY < (compareWith + ROOF_COLLISION_THRESHOLD); 93 | 94 | } 95 | return onCeiling; 96 | } 97 | 98 | public setToGroundLevel() { 99 | this.groundCollisionInfo = this.castRayToGround(); 100 | if (this.isOnGround()) { 101 | const pickedY = this.groundCollisionInfo.pickedPoint.y; 102 | this.mesh.position.y = 103 | pickedY + GROUND_COLLISION_THRESHOLD - this.mesh.getBoundingInfo().minimum.y; 104 | } 105 | } 106 | 107 | private castRayToGround(): PickingInfo { 108 | // we want to cast from top of mesh to ensure the pickedY is the correct mesh 109 | // otherwise you will sometimes experience the ray cast to go straight through 110 | // we do something similar with castRayToCeiling 111 | const castFrom = this.mesh.position.clone(); 112 | castFrom.y += this.mesh.getBoundingInfo().maximum.y - 0.2; 113 | const ray = new Ray(castFrom, new Vector3(0, -1, 0)); 114 | return this.mesh.getScene().pickWithRay(ray); 115 | } 116 | 117 | private castRayToCeiling(): PickingInfo { 118 | const castFrom = this.mesh.position.clone(); 119 | castFrom.y += this.mesh.getBoundingInfo().minimum.y + 0.2; 120 | const ray = new Ray(castFrom, new Vector3(0, 1, 0)); 121 | return this.mesh.getScene().pickWithRay(ray); 122 | } 123 | 124 | public setEnabled(enabled: boolean) { 125 | this.mesh.setEnabled(enabled); 126 | this.animator.setEnabled(enabled); 127 | if (this.ellipsoidMesh != null) { 128 | this.ellipsoidMesh.setEnabled(enabled); 129 | } 130 | } 131 | 132 | private drawCollisionEllipsoid() { 133 | this.mesh.refreshBoundingInfo(); 134 | 135 | const ellipsoidMesh = MeshBuilder.CreateSphere("collisionEllipsoid", { 136 | diameterX: this.mesh.ellipsoid.x * 2, 137 | diameterZ: this.mesh.ellipsoid.z * 2, 138 | diameterY: this.mesh.ellipsoid.y * 2 139 | }, this.mesh.getScene()); 140 | 141 | ellipsoidMesh.position = this.mesh.getAbsolutePosition().add(this.mesh.ellipsoidOffset); 142 | 143 | const material = new StandardMaterial("collider", this.mesh.getScene()); 144 | material.wireframe = true; 145 | material.diffuseColor = Color3.Yellow(); 146 | ellipsoidMesh.material = material; 147 | ellipsoidMesh.visibility = .3; 148 | 149 | ellipsoidMesh.isPickable = false; 150 | ellipsoidMesh.checkCollisions = false; 151 | this.ellipsoidMesh = ellipsoidMesh; 152 | } 153 | 154 | private setEllipsoidToBoundingBox() { 155 | const bb = this.mesh.getBoundingInfo().boundingBox; 156 | this.mesh.ellipsoid = bb.maximumWorld.subtract(bb.minimumWorld).scale(0.5); 157 | } 158 | 159 | public dispose() { 160 | this.mesh.dispose(); 161 | if (this.ellipsoidMesh != null){ 162 | this.ellipsoidMesh.dispose(); 163 | } 164 | this.mesh = null; 165 | this.ellipsoidMesh = null; 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /client/src/player/camera/PlayerCamera.ts: -------------------------------------------------------------------------------- 1 | import { Scene } from "@babylonjs/core/scene"; 2 | import {ArcRotateCamera } from "@babylonjs/core/Cameras/arcRotateCamera"; 3 | import {UniversalCamera} from "@babylonjs/core/Cameras/universalCamera"; 4 | import { Viewport } from "@babylonjs/core/Maths/math.viewport"; 5 | 6 | import Game from "../../Game"; 7 | import AbstractPlayer from "../AbstractPlayer"; 8 | import Player from "../Player" 9 | 10 | // Camera y-position offsets (depends on player scale and stand/crouch) 11 | const CAMERA_STAND_OFFSET = 0.35 * AbstractPlayer.HEIGHT; // half of height would place cam at top of mesh 12 | const CAMERA_CROUCH_OFFSET = CAMERA_STAND_OFFSET * AbstractPlayer.CROUCH_Y_SCALING; 13 | 14 | // Configuration for ArcRotateCamera 15 | const THIRD_PERSON_ALPHA_OFFSET = -0.5 * Math.PI; 16 | const THIRD_PERSON_BETA_OFFSET = 0.5 * Math.PI; 17 | 18 | enum Perspective { 19 | FIRST_PERSON, 20 | THIRD_PERSON 21 | } 22 | 23 | export default class PlayerCamera { 24 | private firstPersonCamera: UniversalCamera; 25 | private thirdPersonCamera: ArcRotateCamera; 26 | private scene: Scene; 27 | private player: Player; 28 | private cameraOffset: number; // y-axis camera offset, changes when crouching 29 | private currentPerspective: Perspective; 30 | 31 | constructor(player: Player){ 32 | this.player = player; 33 | this.scene = this.player.level.scene; 34 | this.cameraOffset = CAMERA_STAND_OFFSET; 35 | 36 | this.setupFirstPersonCamera(); 37 | this.setupThirdPersonCamera(); 38 | this.selectFirstPerson(); 39 | 40 | this.reset(); 41 | } 42 | 43 | // firstperson camera is the default camera that we use to change the rotation of the player mesh 44 | public get(): UniversalCamera { 45 | return this.firstPersonCamera; 46 | } 47 | 48 | private setupFirstPersonCamera() { 49 | this.firstPersonCamera = new UniversalCamera("playerfirstperson", this.player.spawn.spawnPoint.clone(), this.player.level.scene); 50 | this.firstPersonCamera.attachControl(Game.canvas, true); 51 | this.firstPersonCamera.inertia = 0.1; 52 | this.firstPersonCamera.angularSensibility = 800; 53 | this.firstPersonCamera.checkCollisions = false; 54 | this.scene.activeCameras.push(this.firstPersonCamera); 55 | 56 | // remove key events (this is handled in player) 57 | this.firstPersonCamera.keysUp = []; // W or UP Arrow 58 | this.firstPersonCamera.keysDown = []; // S or DOWN ARROW 59 | this.firstPersonCamera.keysLeft = []; // A or LEFT ARROW 60 | this.firstPersonCamera.keysRight = []; // D or RIGHT ARROW 61 | this.firstPersonCamera.speed = 0; 62 | } 63 | 64 | private setupThirdPersonCamera() { 65 | const alpha = -0.5 * Math.PI; 66 | const beta = 0.5 * Math.PI; 67 | const distance = 30; 68 | this.thirdPersonCamera = new ArcRotateCamera("playerthirdperson", alpha, beta, distance, this.player.spawn.spawnPoint.clone(), this.scene); 69 | const cam = this.thirdPersonCamera; 70 | this.scene.activeCameras.push(cam); 71 | 72 | cam.inertia = 0.1; 73 | cam.checkCollisions = false; 74 | cam.setTarget(this.player.mesh.get()); 75 | } 76 | 77 | public selectFirstPerson() { 78 | if (this.currentPerspective != Perspective.FIRST_PERSON) { 79 | this.currentPerspective = Perspective.FIRST_PERSON; 80 | this.firstPersonCamera.viewport = new Viewport(0, 0, 1, 1); 81 | this.thirdPersonCamera.viewport = new Viewport(0, 0, 0, 0); 82 | this.scene.cameraToUseForPointers = this.firstPersonCamera; 83 | 84 | this.player.setVisible(false); 85 | } 86 | } 87 | 88 | public selectThirdPerson() { 89 | if (this.currentPerspective != Perspective.THIRD_PERSON) { 90 | this.currentPerspective = Perspective.THIRD_PERSON; 91 | this.firstPersonCamera.viewport = new Viewport(0, 0, 0, 0); 92 | this.thirdPersonCamera.viewport = new Viewport(0, 0, 1, 1); 93 | this.scene.cameraToUseForPointers = this.thirdPersonCamera; 94 | 95 | this.player.setVisible(true); 96 | } 97 | } 98 | 99 | public reset() { 100 | this.resetFirstPersonCamera(); 101 | this.resetThirdPersonCamera(); 102 | } 103 | 104 | private resetFirstPersonCamera() { 105 | // set target to view direction 106 | this.firstPersonCamera.position = this.player.spawn.spawnPoint.clone(); 107 | this.firstPersonCamera.setTarget(this.player.spawn.lookAt.clone()); 108 | } 109 | 110 | private resetThirdPersonCamera() { 111 | this.thirdPersonCamera.setTarget(this.player.mesh.get()); 112 | this.thirdPersonCamera.radius = 30; 113 | } 114 | 115 | public update() { 116 | // set camera position equal to mesh position 117 | // also increase height of camera to match top of cylinder 118 | const pos = this.player.mesh.get().position; 119 | this.firstPersonCamera.position.set(pos.x, pos.y + this.cameraOffset, pos.z); 120 | 121 | this.thirdPersonCamera.alpha = THIRD_PERSON_ALPHA_OFFSET - this.firstPersonCamera.rotation.y; 122 | this.thirdPersonCamera.beta = THIRD_PERSON_BETA_OFFSET - this.firstPersonCamera.rotation.x; 123 | } 124 | 125 | public setCrouch(doCrouch: boolean) { 126 | // adjust camera offset accordingly 127 | this.cameraOffset = (doCrouch) ? CAMERA_CROUCH_OFFSET : CAMERA_STAND_OFFSET; 128 | this.resetThirdPersonCamera(); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /client/src/ui/FullScreenUI.ts: -------------------------------------------------------------------------------- 1 | import {Image, Control, AdvancedDynamicTexture, TextBlock, StackPanel, Rectangle} from "@babylonjs/gui/2D"; 2 | 3 | export default class FullScreenUI { 4 | public advancedTexture: AdvancedDynamicTexture; 5 | 6 | constructor() { 7 | this.advancedTexture = AdvancedDynamicTexture.CreateFullscreenUI("UI"); 8 | this.addCrossHair(); 9 | this.addPlayerControlInfo(); 10 | } 11 | 12 | private addCrossHair() { 13 | let image = new Image('gunsight', 'assets/images/gunsight.jpg'); 14 | 15 | image.stretch = Image.STRETCH_UNIFORM; 16 | image.width = 0.05; 17 | image.height = 0.05; 18 | image.left = '0px'; 19 | image.top = '0px'; 20 | image.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER; 21 | image.verticalAlignment = Control.VERTICAL_ALIGNMENT_CENTER; 22 | image.isVisible = true; 23 | this.advancedTexture.addControl(image); 24 | } 25 | 26 | private addPlayerControlInfo() { 27 | const infoFrame = new Rectangle("infoFrame"); 28 | infoFrame.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_LEFT; 29 | infoFrame.verticalAlignment = Control.VERTICAL_ALIGNMENT_TOP; 30 | infoFrame.paddingTop = 15; 31 | infoFrame.paddingLeft = 15; 32 | infoFrame.cornerRadius = 15; 33 | infoFrame.thickness = 2; 34 | infoFrame.widthInPixels = 370; 35 | infoFrame.heightInPixels = 325; 36 | this.advancedTexture.addControl(infoFrame); 37 | 38 | const infoStack = new StackPanel("infoStack"); 39 | infoStack.paddingLeft = 10; 40 | infoStack.addControl(this.getInfoText("Move with:", "WASD or ARROW KEYS")); 41 | infoStack.addControl(this.getInfoText("Crouch with:", "C")); 42 | infoStack.addControl(this.getInfoText("Create checkpoint with:", "T")); 43 | infoStack.addControl(this.getInfoText("Go to latest checkpoint with:", "V")); 44 | infoStack.addControl(this.getInfoText("Restart level with:", "P")); 45 | infoStack.addControl(this.getInfoText("Set to first-person POV with:", "1")); 46 | infoStack.addControl(this.getInfoText("Set to third-person POV with:", "2")); 47 | infoStack.addControl(this.getInfoText("To start chatting press:", "ESC")); 48 | infoFrame.addControl(infoStack); 49 | } 50 | 51 | private getInfoText(infoText: string, commandText: string): StackPanel { 52 | const stack = new StackPanel("infoTextStack"); 53 | stack.isVertical = false; 54 | stack.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_LEFT; 55 | stack.widthInPixels = 360; 56 | stack.heightInPixels = 40; 57 | 58 | const infoBlock = new TextBlock("infoText"); 59 | infoBlock.color = "white"; 60 | infoBlock.paddingRight = 5; 61 | infoBlock.textHorizontalAlignment = TextBlock.HORIZONTAL_ALIGNMENT_LEFT; 62 | infoBlock.fontSize = 20; 63 | infoBlock.widthInPixels = 180; 64 | infoBlock.resizeToFit = true; 65 | infoBlock.heightInPixels = 20; 66 | infoBlock.text = infoText; 67 | infoBlock.fontFamily = "Helvetica"; 68 | stack.addControl(infoBlock); 69 | 70 | const commandBlock = new TextBlock("commandText"); 71 | commandBlock.color = "blue"; 72 | commandBlock.heightInPixels = 20; 73 | commandBlock.widthInPixels = 180; 74 | commandBlock.resizeToFit = true; 75 | commandBlock.textHorizontalAlignment = TextBlock.HORIZONTAL_ALIGNMENT_RIGHT; 76 | commandBlock.fontSize = 20; 77 | commandBlock.text = commandText; 78 | commandBlock.fontFamily = "Helvetica"; 79 | stack.addControl(commandBlock); 80 | return stack; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "es6", // should not be commonjs when using webpack 4 | "target": "es5", 5 | "moduleResolution": "node", 6 | "strict": true, 7 | "noImplicitAny": true, 8 | "noImplicitReturns": true, 9 | "noImplicitThis": true, 10 | "strictNullChecks": false, 11 | "strictFunctionTypes": true, 12 | "skipLibCheck": true, 13 | "preserveConstEnums":true, 14 | "sourceMap": true, 15 | "experimentalDecorators": true, 16 | "rootDir": "src" 17 | }, 18 | } -------------------------------------------------------------------------------- /client/webpack.common.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const fs = require('fs'); 3 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | 5 | // App directory 6 | const appDirectory = fs.realpathSync(process.cwd()); 7 | 8 | module.exports = { 9 | entry: path.resolve(appDirectory, "src/Entry.ts"), 10 | output: { 11 | filename: 'js/bundle.js', 12 | path: path.resolve(__dirname, 'public') 13 | }, 14 | resolve: { 15 | extensions: ['.ts', '.tsx', '.js'] 16 | }, 17 | module: { 18 | rules: [ 19 | { 20 | test: /\.tsx?$/, 21 | loader: 'ts-loader', 22 | exclude: /node_modules/, 23 | }, 24 | ] 25 | }, 26 | plugins: [ 27 | new HtmlWebpackPlugin({ 28 | inject: true, 29 | template: path.resolve(appDirectory, "public/index.html"), 30 | }) 31 | ], 32 | } 33 | -------------------------------------------------------------------------------- /client/webpack.dev.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge'); 2 | const common = require('./webpack.common.js'); 3 | const path = require("path"); 4 | const fs = require('fs'); 5 | 6 | // App directory 7 | const appDirectory = fs.realpathSync(process.cwd()); 8 | 9 | module.exports = merge(common, { 10 | mode: 'development', 11 | devtool: 'inline-source-map', 12 | devServer: { 13 | static : { 14 | directory : path.resolve(appDirectory, "public") 15 | }, 16 | compress: true, 17 | devMiddleware:{ 18 | publicPath: "/" 19 | }, 20 | hot: "only", 21 | open: true, 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /client/webpack.prod.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge'); 2 | const common = require('./webpack.common.js'); 3 | const TerserPlugin = require("terser-webpack-plugin"); 4 | 5 | module.exports = merge(common, { 6 | mode: 'production', 7 | devtool: 'source-map', 8 | optimization: { 9 | minimizer: true, 10 | minimizer: [ 11 | new TerserPlugin({ 12 | parallel: 4, 13 | terserOptions: { 14 | format: { 15 | comments: false, 16 | }, 17 | }, 18 | extractComments: false, 19 | }), 20 | ], 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "BabylonJS-Platformer-Game-Prototype", 3 | "lockfileVersion": 2, 4 | "requires": true, 5 | "packages": {} 6 | } 7 | -------------------------------------------------------------------------------- /play.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BabylonJSGames/BabylonJS-Platformer-Game-Prototype/c6ee1ac74310d7da43d9878a8df6e857a7cbc5dc/play.gif -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /server/Procfile: -------------------------------------------------------------------------------- 1 | web: npm run start -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kz-climbing", 3 | "version": "1.0.0", 4 | "description": "A 3d browser game based on the classic Counter Strike KZ mode", 5 | "main": "dist/server.js", 6 | "engines": { 7 | "node": "14.x" 8 | }, 9 | "scripts": { 10 | "build": "npm install && tsc", 11 | "heroku-postbuild": "tsc", 12 | "watch:build": "tsc --w", 13 | "start": "node ./dist/server.js", 14 | "start-dev": "nodemon dist/server.js", 15 | "dev": "npm-run-all -p watch:build start-dev" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/JacobPjetursson/KZClimbing.git" 20 | }, 21 | "author": "Jacob Pjetursson and Fadi Bunni", 22 | "license": "ISC", 23 | "dependencies": { 24 | "@colyseus/loadtest": "0.14.7", 25 | "@colyseus/monitor": "0.14.22", 26 | "@colyseus/ws-transport": "0.14.21", 27 | "colyseus": "0.14.23", 28 | "@types/cors": "2.8.12", 29 | "@types/express": "4.17.13", 30 | "cors": "^2.8.5", 31 | "express": "^4.18.1" 32 | }, 33 | "devDependencies": { 34 | "nodemon": "^2.0.16", 35 | "npm-run-all": "^4.1.5", 36 | "typescript": "^4.6.4" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /server/src/loadtester/LoadTester.ts: -------------------------------------------------------------------------------- 1 | // Incomplete file for testing bandwidth load 2 | 3 | import { Room, Client } from "colyseus.js"; 4 | 5 | export function requestJoinOptions (this: Client, i: number) { 6 | return { requestNumber: i }; 7 | } 8 | 9 | export function onJoin(this: Room) { 10 | console.log(this.sessionId, "joined."); 11 | 12 | this.onMessage("*", (type, message) => { 13 | console.log("onMessage:", type, message); 14 | }); 15 | } 16 | 17 | export function onLeave(this: Room) { 18 | console.log(this.sessionId, "left."); 19 | } 20 | 21 | export function onError(this: Room, err: { message: any; }) { 22 | console.error(this.sessionId, "!! ERROR !!", err.message); 23 | } 24 | 25 | export function onStateChange(this: Room, state: any) { 26 | } 27 | -------------------------------------------------------------------------------- /server/src/rooms/ChatRoom.ts: -------------------------------------------------------------------------------- 1 | import {Room, Client} from '@colyseus/core'; 2 | 3 | export class ChatRoom extends Room { 4 | maxClients = 64; 5 | private utc = new Date().toLocaleString(); 6 | 7 | // When room is initialized 8 | onCreate(options: any){ 9 | console.log("ChatRoom created!", options); 10 | 11 | //For chat 12 | this.onMessage("message", (client, message) => { 13 | console.log("ChatRoom received message from: ", client.sessionId, ":", message); 14 | this.broadcast("messages", this.utc +"| " + "message from " + client.sessionId +": "+ message ); 15 | }); 16 | } 17 | 18 | // When client successfully join the room 19 | onJoin (client: Client, options: any, auth: any){ 20 | this.broadcast("messages", this.utc +"| "+ client.sessionId + " joined chat room"); 21 | } 22 | 23 | // When a client leaves the room 24 | onLeave(client: Client, consented: boolean) { 25 | this.broadcast("messages", this.utc +"| "+ client.sessionId + " left chat room"); 26 | } 27 | 28 | // Cleanup callback, called after there are no more clients in the room. (see `autoDispose`) 29 | onDispose() {} 30 | } -------------------------------------------------------------------------------- /server/src/rooms/GameRoom.ts: -------------------------------------------------------------------------------- 1 | import {Room, Client} from '@colyseus/core'; 2 | import { PlayerCrouchSchema, PlayerDirectionSchema, PlayerKeySchema, PlayerPositionSchema } from '../schema/PlayerSchema'; 3 | 4 | import {StateHandlerSchema} from '../schema/StateHandlerSchema'; 5 | 6 | export class GameRoom extends Room { 7 | public maxClients = 64; 8 | 9 | // When room is initialized 10 | onCreate(options: any){ 11 | console.log("GameRoom created!", options); 12 | 13 | //Frequency to send the room state to connected clients. 16ms=60fps. 14 | this.setPatchRate(16); 15 | 16 | this.setState(new StateHandlerSchema()); 17 | } 18 | 19 | // When client successfully join the room 20 | onJoin (client: Client) { 21 | this.onMessage("key", (message) => { 22 | this.broadcast("key", message); 23 | console.log(message); 24 | }); 25 | 26 | console.log(`player ${client.sessionId} joined room ${this.roomId}.`); 27 | this.state.addPlayer(client.sessionId); 28 | 29 | //Update player 30 | this.onMessage("playerPosition", (client, data: PlayerPositionSchema) => { 31 | this.state.setPosition(client.sessionId, data); 32 | }); 33 | 34 | this.onMessage("playerDirection", (client, data: PlayerDirectionSchema) => { 35 | this.state.setDirection(client.sessionId, data); 36 | }); 37 | 38 | this.onMessage("playerCrouching", (client, data: PlayerCrouchSchema) => { 39 | this.state.setCrouching(client.sessionId, data); 40 | }); 41 | 42 | this.onMessage("playerKey", (client, data: PlayerKeySchema) => { 43 | this.state.setKeys(client.sessionId, data); 44 | }); 45 | } 46 | 47 | // When a client leaves the room 48 | onLeave(client: Client) { 49 | if(this.state.players.has(client.sessionId)){ 50 | console.log("This player: " + client.sessionId + " has left."); 51 | this.state.removePlayer(client.sessionId); 52 | } 53 | } 54 | 55 | // Cleanup callback, called after there are no more clients in the room. (see `autoDispose`) 56 | onDispose() { 57 | console.log("Dispose GameRoom"); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /server/src/schema/PlayerSchema.ts: -------------------------------------------------------------------------------- 1 | import {Schema, type} from '@colyseus/schema'; 2 | 3 | export class PlayerDirectionSchema extends Schema { 4 | @type('number') public rotationY: number; 5 | } 6 | 7 | export class PlayerCrouchSchema extends Schema { 8 | @type('boolean') public crouching: boolean; 9 | } 10 | 11 | export class PlayerKeySchema extends Schema { 12 | @type('boolean') public up: boolean; 13 | @type('boolean') public right: boolean; 14 | @type('boolean') public down: boolean; 15 | @type('boolean') public left: boolean; 16 | @type('boolean') public jump: boolean; 17 | } 18 | 19 | export class PlayerPositionSchema extends Schema { 20 | @type('number') public x: number; 21 | @type('number') public y: number; 22 | @type('number') public z: number; 23 | } 24 | 25 | export class PlayerSchema extends Schema { 26 | 27 | @type('string') public sessionId: string; 28 | @type(PlayerPositionSchema) public playerPosition = new PlayerPositionSchema(); 29 | @type(PlayerDirectionSchema) public playerDirection = new PlayerDirectionSchema(); 30 | @type(PlayerKeySchema) public playerKey = new PlayerKeySchema(); 31 | @type(PlayerCrouchSchema) public playerCrouch = new PlayerCrouchSchema(); 32 | } 33 | -------------------------------------------------------------------------------- /server/src/schema/StateHandlerSchema.ts: -------------------------------------------------------------------------------- 1 | import {Schema, type, MapSchema} from '@colyseus/schema'; 2 | 3 | import {PlayerCrouchSchema, PlayerDirectionSchema, PlayerKeySchema, PlayerPositionSchema, PlayerSchema} from './PlayerSchema'; 4 | 5 | export class StateHandlerSchema extends Schema { 6 | 7 | @type({map: PlayerSchema}) 8 | players = new MapSchema(); 9 | 10 | addPlayer(sessionId: string) { 11 | this.players.set(sessionId, new PlayerSchema().assign({sessionId: sessionId})); 12 | } 13 | 14 | getPlayer(sessionId: string): PlayerSchema { 15 | return this.players.get(sessionId); 16 | } 17 | 18 | removePlayer(sessionId: string) { 19 | this.players.delete(sessionId); 20 | } 21 | 22 | setDirection(sessionId: string, direction: PlayerDirectionSchema) { 23 | this.getPlayer(sessionId).playerDirection.rotationY = direction.rotationY; 24 | } 25 | 26 | setKeys(sessionId: string, keys: PlayerKeySchema) { 27 | const player = this.getPlayer(sessionId); 28 | player.playerKey.up = keys.up; 29 | player.playerKey.right = keys.right; 30 | player.playerKey.down = keys.down; 31 | player.playerKey.left = keys.left; 32 | player.playerKey.jump = keys.jump; 33 | } 34 | 35 | setPosition(sessionId: string, position: PlayerPositionSchema) { 36 | const player = this.getPlayer(sessionId); 37 | player.playerPosition.x = position.x; 38 | player.playerPosition.y = position.y; 39 | player.playerPosition.z = position.z; 40 | } 41 | 42 | setCrouching(sessionId: string, crouchSchema: PlayerCrouchSchema) { 43 | this.getPlayer(sessionId).playerCrouch.crouching = crouchSchema.crouching; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /server/src/server.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import cors from 'cors'; 3 | import { createServer } from "http"; 4 | import { Server } from "@colyseus/core"; 5 | import { monitor } from "@colyseus/monitor"; 6 | import { WebSocketTransport } from "@colyseus/ws-transport"; 7 | 8 | import { GameRoom } from "./rooms/GameRoom"; 9 | import { ChatRoom } from "./rooms/ChatRoom"; 10 | 11 | const port = Number(process.env.PORT || 8081); 12 | const app = express(); 13 | 14 | app.use(cors({ origin: true })); 15 | app.use(express.json()) 16 | 17 | 18 | const gameServer = new Server({ 19 | transport: new WebSocketTransport({ 20 | server: createServer(app) 21 | }), 22 | }) 23 | 24 | gameServer.define("ChatRoom", ChatRoom); 25 | gameServer.define("GameRoom", GameRoom); 26 | 27 | //useful for simulating latency. 28 | //gameServer.simulateLatency(200); 29 | 30 | gameServer.onShutdown(() => { 31 | console.log("Server is shutting down."); 32 | }); 33 | 34 | app.use("/colyseus", monitor()); 35 | 36 | gameServer.listen(port); 37 | console.log("listening on http://localhost:" + port); 38 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "moduleResolution": "node", 6 | "strict": false, 7 | "noImplicitAny": true, 8 | "noImplicitReturns": true, 9 | "noImplicitThis": true, 10 | "strictNullChecks": false, 11 | "strictFunctionTypes": true, 12 | "skipLibCheck": true, 13 | "preserveConstEnums":true, 14 | "removeComments": true, 15 | "isolatedModules": false, 16 | "inlineSourceMap": true, 17 | "esModuleInterop": true, 18 | "experimentalDecorators": true, 19 | "rootDir": "src", 20 | "outDir": "./dist/" 21 | }, 22 | } --------------------------------------------------------------------------------