├── .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 | 
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 |
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 | }
--------------------------------------------------------------------------------