├── README.md ├── index.html ├── main.js ├── package.json └── src ├── client ├── Client.js └── InterpolatedPlayer.js ├── server ├── Client.js └── Server.js └── shared ├── Inputs.js └── Player.js /README.md: -------------------------------------------------------------------------------- 1 | # Client Side Prediction and Server reconciliation 2 | 3 | Client side prediction and server reconciliation implementation along with a little anti speed hack. 4 | 5 | [Live Demo](https://prediction-side-client.herokuapp.com/) -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Client Side Prediction 5 | 93 | 94 | 95 |
96 | 97 |
98 | 103 | 108 | 113 | 118 |
119 |
120 |
ASWD to move
121 |
QE to rotate
122 |
123 |
124 | 125 | 126 | 127 | 128 | 129 | 155 | 156 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | const WebSocket = require( 'ws' ); 2 | const url = require( 'url' ); 3 | 4 | const express = require( 'express' ); 5 | const Server = require( './src/server/Server.js' ); 6 | 7 | const app = express(); 8 | 9 | app.use( express.static( __dirname ) ); 10 | 11 | const port = process.env.PORT || 80; 12 | 13 | const httpServer = app.listen( port, function () { 14 | 15 | console.log( 'Server listening on port ' + port + '...' ); 16 | 17 | } ); 18 | 19 | const server = new Server(); 20 | 21 | server.start( httpServer ); 22 | 23 | setInterval( function () { 24 | 25 | server.update(); 26 | 27 | }, 1000 / 10 ); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client-side-prediction", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node main.js" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "ws": "^7.3.1", 13 | "express": "latest" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/client/Client.js: -------------------------------------------------------------------------------- 1 | function Client() { 2 | 3 | const scope = this; 4 | 5 | this.gameCanvasEl = document.querySelector( '.game-canvas' ); 6 | 7 | this.serverStateEl = document.querySelector( '.server-state' ); 8 | this.predictedStateEl = document.querySelector( '.predicted-state' ); 9 | this.interpolatedStateEl = document.querySelector( '.interpolated-state' ); 10 | this.speedHackEl = document.querySelector( '.speed-hack' ); 11 | 12 | this.gameCanvasEl.width = window.innerWidth; 13 | this.gameCanvasEl.height = window.innerHeight; 14 | 15 | this.context = this.gameCanvasEl.getContext( '2d' ); 16 | 17 | this.webSocket = null; 18 | 19 | this.messages = []; 20 | 21 | this.player = new Player(); 22 | this.inputs = new Inputs(); 23 | this.inputsArray = []; 24 | 25 | this.historySize = 1024; 26 | this.history = []; 27 | 28 | this.tickNumber = 0; 29 | 30 | this.currentTime = null; 31 | this.lastTime = null; 32 | this.deltaTime = null; 33 | 34 | this.interpolatedPlayer = new InterpolatedPlayer(); 35 | this.serverPlayer = new Player(); 36 | this.speedHackPlayer = new Player(); 37 | 38 | this.otherPlayers = []; 39 | 40 | this.trails = []; 41 | 42 | this.connect = function ( url ) { 43 | 44 | if ( scope.webSocket !== null ) { 45 | 46 | // disconnect 47 | 48 | } 49 | 50 | url = url.replace( 'http', 'ws' ); 51 | 52 | scope.webSocket = new WebSocket( url ); 53 | 54 | scope.webSocket.addEventListener( 'open', onWebSocketOpen ); 55 | scope.webSocket.addEventListener( 'message', onWebSocketMessage ); 56 | scope.webSocket.addEventListener( 'close', onWebSocketClose ); 57 | scope.webSocket.addEventListener( 'error', onWebSocketError ); 58 | 59 | } 60 | 61 | function onWebSocketOpen() { 62 | 63 | scope.onConnected(); 64 | 65 | } 66 | 67 | function onWebSocketMessage( event ) { 68 | 69 | scope.onMessage( event.data ); 70 | 71 | } 72 | 73 | function onWebSocketClose() { 74 | 75 | scope.onDisconnected(); 76 | 77 | } 78 | 79 | function onWebSocketError( error ) { 80 | 81 | console.error( 'WebSockt error! ' + error ); 82 | 83 | } 84 | 85 | window.addEventListener( 'keydown', onKeyDown, false ); 86 | window.addEventListener( 'keyup', onKeyUp, false ); 87 | window.addEventListener( 'resize', onWindowResize, false ); 88 | 89 | function onKeyDown( event ) { 90 | 91 | scope.setInputsFromKeyCode( event.keyCode, true ); 92 | 93 | } 94 | 95 | function onKeyUp( event ) { 96 | 97 | scope.setInputsFromKeyCode( event.keyCode, false ); 98 | 99 | } 100 | 101 | function onWindowResize() { 102 | 103 | scope.gameCanvasEl.width = window.innerWidth; 104 | scope.gameCanvasEl.height = window.innerHeight; 105 | 106 | scope.draw(); 107 | 108 | } 109 | 110 | } 111 | 112 | Object.assign( Client.prototype, { 113 | 114 | setInputsFromKeyCode: function ( keyCode, value ) { 115 | 116 | switch ( keyCode ) { 117 | 118 | case 65: 119 | 120 | this.inputs.moveLeft = value; 121 | 122 | break; 123 | 124 | case 87: 125 | 126 | this.inputs.moveForward = value; 127 | 128 | break; 129 | 130 | case 68: 131 | 132 | this.inputs.moveRight = value; 133 | 134 | break; 135 | 136 | case 83: 137 | 138 | this.inputs.moveBackward = value; 139 | 140 | break; 141 | 142 | case 81: 143 | 144 | this.inputs.rotateLeft = value; 145 | 146 | break; 147 | 148 | case 69: 149 | 150 | this.inputs.rotateRight = value; 151 | 152 | break; 153 | 154 | } 155 | 156 | }, 157 | 158 | sendMessage: function ( message ) { 159 | 160 | if ( this.webSocket && this.webSocket.readyState === WebSocket.OPEN ) { 161 | 162 | this.webSocket.send( message ); 163 | 164 | } 165 | 166 | }, 167 | 168 | onConnected: function () { 169 | 170 | console.log( 'connected!' ); 171 | 172 | }, 173 | 174 | onMessage: function ( data ) { 175 | 176 | this.messages.push( data ); 177 | 178 | }, 179 | 180 | onDisconnected: function () { 181 | 182 | console.log( 'disconnected.' ); 183 | 184 | }, 185 | 186 | update: function () { 187 | 188 | this.currentTime = Date.now(); 189 | 190 | if ( this.lastTime === null ) { 191 | 192 | this.lastTime = this.currentTime; 193 | 194 | return; 195 | 196 | } 197 | 198 | this.deltaTime = this.currentTime - this.lastTime; 199 | this.lastTime = this.currentTime; 200 | 201 | if ( ! this.webSocket || this.webSocket.readyState !== WebSocket.OPEN ) { 202 | 203 | console.log( 'not connected, skipping' ); 204 | 205 | return; 206 | 207 | } 208 | 209 | this.inputs.deltaTime = this.deltaTime; 210 | 211 | if ( this.speedHackEl.checked ) { 212 | 213 | this.inputs.deltaTime *= 4; 214 | 215 | } 216 | 217 | const inputsClone = this.inputs.clone(); 218 | this.inputsArray.push( inputsClone ); 219 | 220 | this.sendMessage( JSON.stringify( { 221 | id: 'inputs', 222 | tickNumber: this.tickNumber, 223 | inputsArray: this.inputsArray 224 | } ) ); 225 | 226 | this.inputsArray.length = 0; 227 | 228 | this.history[ this.tickNumber % this.historySize ] = { 229 | x: this.player.x, 230 | y: this.player.y, 231 | velX: this.player.velX, 232 | velY: this.player.velY, 233 | rotation: this.player.rotation, 234 | inputs: inputsClone 235 | }; 236 | 237 | this.player.move( this.inputs ); 238 | 239 | this.speedHackPlayer.move( this.inputs ); 240 | 241 | while ( this.messages.length > 0 ) { 242 | 243 | const message = JSON.parse( this.messages.shift() ); 244 | 245 | switch ( message.id ) { 246 | 247 | case 'update': 248 | 249 | const serverState = message.data; 250 | 251 | this.interpolatedPlayer.setNewState( serverState.x, serverState.y, serverState.rotation, this.currentTime ); 252 | 253 | this.serverPlayer.x = serverState.x; 254 | this.serverPlayer.y = serverState.y; 255 | this.serverPlayer.rotation = serverState.rotation; 256 | 257 | let history = this.history[ message.data.tickNumber % this.historySize ]; 258 | 259 | const error = Math.hypot( serverState.x - history.x, serverState.y - history.y ) + Math.abs( serverState.rotation - history.rotation ); 260 | 261 | if ( error > 0.00001 ) { 262 | 263 | console.log( 'correcting' ); 264 | 265 | this.player.x = serverState.x; 266 | this.player.y = serverState.y; 267 | this.player.velX = serverState.velX; 268 | this.player.velY = serverState.velY; 269 | this.player.rotation = serverState.rotation; 270 | 271 | let rewindTickNumber = serverState.tickNumber; 272 | 273 | while ( rewindTickNumber <= this.tickNumber ) { 274 | 275 | history = this.history[ rewindTickNumber % this.historySize ]; 276 | 277 | history.x = this.player.x; 278 | history.y = this.player.y; 279 | history.velX = this.player.velX; 280 | history.velY = this.player.velY; 281 | history.rotation = this.player.rotation; 282 | 283 | this.player.move( history.inputs ); 284 | 285 | rewindTickNumber ++; 286 | 287 | } 288 | 289 | } 290 | 291 | break; 292 | 293 | case 'worldUpdate': 294 | 295 | const players = []; 296 | 297 | while ( message.data.length > 0 ) { 298 | 299 | const data = message.data.shift(); 300 | 301 | let player = this.otherPlayers.find( function ( player ) { 302 | return player.id === data.id; 303 | } ); 304 | 305 | if ( player ) { 306 | 307 | player.setNewState( data.x, data.y, data.rotation, this.currentTime ); 308 | 309 | } else { 310 | 311 | player = new InterpolatedPlayer(); 312 | player.id = data.id; 313 | player.setNewState( data.x, data.y, data.rotation, 0 ); 314 | 315 | } 316 | 317 | players.push( player ); 318 | 319 | } 320 | 321 | this.otherPlayers = players; 322 | 323 | break; 324 | 325 | } 326 | 327 | } 328 | 329 | this.tickNumber ++; 330 | 331 | this.interpolatedPlayer.update( this.currentTime, 200 ); 332 | 333 | for ( let i = 0; i < this.otherPlayers.length; i ++ ) { 334 | 335 | this.otherPlayers[ i ].update( this.currentTime, 200 ); 336 | 337 | } 338 | 339 | const lastTrail = this.trails[ this.trails.length - 1 ]; 340 | 341 | this.trails.push( { 342 | x: this.player.x, 343 | y: this.player.y 344 | } ); 345 | 346 | if ( this.trails.length > 10 ) { 347 | 348 | this.trails.shift(); 349 | 350 | } 351 | 352 | }, 353 | 354 | drawPlayer: function ( player, hue, arrowColor ) { 355 | 356 | this.context.fillStyle = 'hsl(' + hue + ', 100%, 60%)'; 357 | this.context.strokeStyle = 'hsl(' + hue + ', 100%, 40%)'; 358 | 359 | this.context.lineWidth = 6; 360 | this.context.lineCap = 'round'; 361 | this.context.lineJoin = 'round'; 362 | 363 | this.context.beginPath(); 364 | this.context.arc( player.x, player.y, player.radius, 0, Math.PI * 2 ); 365 | this.context.closePath(); 366 | 367 | this.context.fill(); 368 | 369 | this.context.beginPath(); 370 | this.context.arc( player.x, player.y, player.radius + this.context.lineWidth / 2, 0, Math.PI * 2 ); 371 | this.context.closePath(); 372 | 373 | this.context.stroke(); 374 | 375 | this.context.strokeStyle = arrowColor; 376 | this.context.lineWidth = 8; 377 | 378 | const arrowSize = ( Math.sin( Date.now() / 100 ) * 0.5 + 0.5 ) * 10 + 50; 379 | 380 | this.context.save(); 381 | 382 | this.context.translate( player.x, player.y ); 383 | this.context.rotate( player.rotation ); 384 | 385 | this.context.translate( player.radius + 15, 0 ); 386 | 387 | this.context.beginPath(); 388 | 389 | this.context.moveTo( 0, 0 ); 390 | this.context.lineTo( arrowSize, 0 ); 391 | this.context.moveTo( arrowSize * 0.80, - 10 ); 392 | this.context.lineTo( arrowSize, 0 ); 393 | this.context.lineTo( arrowSize * 0.80, 10 ); 394 | 395 | this.context.stroke(); 396 | 397 | this.context.restore(); 398 | 399 | }, 400 | 401 | draw: function () { 402 | 403 | this.context.globalAlpha = 1; 404 | 405 | this.context.fillStyle = '#d4d4d4'; 406 | 407 | this.context.fillRect( 0, 0, this.gameCanvasEl.width, this.gameCanvasEl.height ); 408 | 409 | this.context.save(); 410 | 411 | this.context.translate( this.gameCanvasEl.width / 2, this.gameCanvasEl.height / 2 ); 412 | 413 | if ( ! this.webSocket || this.webSocket.readyState !== WebSocket.OPEN ) { 414 | 415 | this.context.font = 'bolder 50px arial'; 416 | this.context.textBaseline = 'middle'; 417 | this.context.textAlign = 'center'; 418 | 419 | this.context.fillStyle = '#fff'; 420 | this.context.strokeStyle = '#222'; 421 | 422 | this.context.lineJoin = 'round'; 423 | this.context.lineCap = 'round'; 424 | 425 | this.context.lineWidth = 6; 426 | 427 | if ( this.webSocket.readyState < WebSocket.OPEN ) { 428 | 429 | text = 'Connecting...'; 430 | 431 | } else { 432 | 433 | text = 'Disconnected!'; 434 | 435 | } 436 | 437 | this.context.strokeText( text, 0, 0 ); 438 | this.context.fillText( text, 0, 0 ); 439 | 440 | this.context.restore(); 441 | 442 | return; 443 | 444 | } 445 | 446 | this.context.lineWidth = 6; 447 | this.context.strokeStyle = '#aaa'; 448 | 449 | this.context.strokeRect( 450 | - this.player.areaSizeX / 2 - this.context.lineWidth / 2, 451 | - this.player.areaSizeY / 2 - this.context.lineWidth / 2, 452 | this.player.areaSizeX + this.context.lineWidth, 453 | this.player.areaSizeY + this.context.lineWidth 454 | ); 455 | 456 | for ( let i = 0; i < this.otherPlayers.length; i ++ ) { 457 | 458 | this.drawPlayer( this.otherPlayers[ i ], 150, '#999' ); 459 | 460 | } 461 | 462 | this.context.globalAlpha = 0.5; 463 | 464 | if ( this.interpolatedStateEl.checked ) { 465 | 466 | this.drawPlayer( this.interpolatedPlayer, 0, '#333' ); 467 | 468 | } 469 | 470 | if ( this.serverStateEl.checked ) { 471 | 472 | this.drawPlayer( this.serverPlayer, 200, '#333' ); 473 | 474 | } 475 | 476 | if ( this.speedHackEl.checked ) { 477 | 478 | this.drawPlayer( this.speedHackPlayer, 30, '#333' ); 479 | 480 | } 481 | 482 | if ( this.predictedStateEl.checked ) { 483 | 484 | this.context.globalAlpha = 0.5; 485 | 486 | this.context.strokeStyle = 'white'; 487 | this.context.lineWidth = this.player.radius * 2; 488 | 489 | this.context.beginPath(); 490 | 491 | for ( let i = 0; i < this.trails.length; i ++ ) { 492 | 493 | this.context.lineTo( this.trails[ i ].x, this.trails[ i ].y ); 494 | 495 | } 496 | 497 | this.context.stroke(); 498 | 499 | this.context.closePath(); 500 | 501 | this.context.globalAlpha = 1; 502 | 503 | this.drawPlayer( this.player, 80, '#333' ); 504 | 505 | } 506 | 507 | this.context.restore(); 508 | 509 | } 510 | 511 | } ); -------------------------------------------------------------------------------- /src/client/InterpolatedPlayer.js: -------------------------------------------------------------------------------- 1 | function InterpolatedPlayer() { 2 | 3 | Player.call( this ); 4 | 5 | this.oldX = this.newX = this.x; 6 | this.oldY = this.newY = this.y; 7 | this.oldRotation = this.newRotation = this.rotation; 8 | this.updateTime = 0; 9 | 10 | } 11 | 12 | Object.assign( InterpolatedPlayer.prototype, { 13 | 14 | setNewState: function ( x, y, rotation, time ) { 15 | 16 | this.newX = x; 17 | this.newY = y; 18 | this.newRotation = rotation; 19 | 20 | this.oldX = this.x; 21 | this.oldY = this.y; 22 | this.oldRotation = this.rotation; 23 | 24 | this.updateTime = time; 25 | 26 | }, 27 | 28 | update: function ( currentTime, period ) { 29 | 30 | const t = Math.min( ( currentTime - this.updateTime ) / period, 1 ); 31 | 32 | this.x = this.oldX + ( this.newX - this.oldX ) * t; 33 | this.y = this.oldY + ( this.newY - this.oldY ) * t; 34 | this.rotation = this.oldRotation + ( this.newRotation - this.oldRotation ) * t; 35 | 36 | } 37 | 38 | } ); -------------------------------------------------------------------------------- /src/server/Client.js: -------------------------------------------------------------------------------- 1 | function Client( socket ) { 2 | 3 | this.socket = socket; 4 | 5 | this.id = - 1; 6 | 7 | this.isAlive = true; 8 | 9 | this.messages = []; 10 | 11 | } 12 | 13 | Object.assign( Client.prototype, { 14 | 15 | sendMessage: function ( message ) { 16 | 17 | if ( this.socket && this.socket.readyState === this.socket.OPEN ) { 18 | 19 | this.socket.send( message ); 20 | 21 | } 22 | 23 | } 24 | 25 | } ); 26 | 27 | module.exports = Client; -------------------------------------------------------------------------------- /src/server/Server.js: -------------------------------------------------------------------------------- 1 | const Player = require( '../shared/Player.js' ); 2 | const Inputs = require( '../shared/Inputs.js' ); 3 | 4 | const Client = require( './Client.js' ); 5 | 6 | const WebSocket = require( 'ws' ); 7 | const url = require( 'url' ); 8 | 9 | function Server() { 10 | 11 | const scope = this; 12 | 13 | this.webSocketServer = null; 14 | 15 | this.allowedHostnames = [ 'localhost', 'prediction-side-client.herokuapp.com' ]; 16 | 17 | this.clients = []; 18 | 19 | this.nextClientId = 0; 20 | 21 | this.currentTime = null; 22 | this.lastTime = null; 23 | 24 | this.start = function ( server ) { 25 | 26 | if ( scope.webSocketServer !== null ) { 27 | 28 | // idk maybe dispose that shit? 29 | 30 | } 31 | 32 | scope.webSocketServer = new WebSocket.Server( { 33 | server: server, 34 | perMessageDeflate: false 35 | } ); 36 | 37 | const shouldHandle = scope.webSocketServer.shouldHandle; 38 | 39 | scope.webSocketServer.shouldHandle = function ( request ) { 40 | 41 | const hostname = url.parse( request.headers.origin ).hostname; 42 | 43 | if ( scope.allowedHostnames.indexOf( hostname ) === - 1 ) { 44 | 45 | return false; 46 | 47 | } 48 | 49 | return shouldHandle.call( scope.webSocketServer, request ); 50 | 51 | } 52 | 53 | scope.webSocketServer.on( 'connection', function ( socket ) { 54 | 55 | const client = new Client( socket ); 56 | 57 | scope.onClientConnected( client ); 58 | 59 | socket.on( 'message', function ( message ) { 60 | 61 | scope.onClientMessage( client, message ); 62 | 63 | } ); 64 | 65 | socket.on( 'close', function () { 66 | 67 | scope.onClientDisconnected( client ); 68 | 69 | } ); 70 | 71 | } ); 72 | 73 | } 74 | 75 | } 76 | 77 | Object.assign( Server.prototype, { 78 | 79 | onClientConnected: function ( client ) { 80 | 81 | this.clients.push( client ); 82 | 83 | }, 84 | 85 | onClientMessage: function ( client, message ) { 86 | 87 | client.messages.push( message ); 88 | 89 | }, 90 | 91 | onClientDisconnected: function ( client ) { 92 | 93 | client.isAlive = false; 94 | 95 | }, 96 | 97 | update: function () { 98 | 99 | this.currentTime = Date.now(); 100 | 101 | for ( let i = this.clients.length - 1; i >= 0; i -- ) { 102 | 103 | const client = this.clients[ i ]; 104 | 105 | if ( client.isAlive === false ) { 106 | 107 | this.clients.splice( i, 1 ); 108 | 109 | continue; 110 | 111 | } 112 | 113 | if ( client.id === - 1 ) { 114 | 115 | client.id = this.nextClientId ++; 116 | 117 | client.player = new Player(); 118 | client.inputs = new Inputs(); 119 | 120 | client.time = this.currentTime; 121 | 122 | } 123 | 124 | while ( client.messages.length > 0 ) { 125 | 126 | const message = JSON.parse( client.messages.shift() ); 127 | 128 | switch ( message.id ) { 129 | 130 | case 'inputs': 131 | 132 | while ( message.inputsArray.length > 0 ) { 133 | 134 | const inputs = message.inputsArray.shift(); 135 | 136 | if ( client.time + inputs.deltaTime > this.currentTime ) { 137 | 138 | inputs.deltaTime = this.currentTime - client.time; 139 | 140 | } 141 | 142 | client.time += inputs.deltaTime; 143 | 144 | client.player.move( inputs ); 145 | 146 | } 147 | 148 | client.sendMessage( JSON.stringify( { 149 | id: 'update', 150 | data: { 151 | x: client.player.x, 152 | y: client.player.y, 153 | velX: client.player.velX, 154 | velY: client.player.velY, 155 | rotation: client.player.rotation, 156 | tickNumber: message.tickNumber + 1 157 | } 158 | } ) ); 159 | 160 | break; 161 | 162 | } 163 | 164 | } 165 | 166 | } 167 | 168 | for ( let i = 0; i < this.clients.length; i ++ ) { 169 | 170 | const client = this.clients[ i ]; 171 | 172 | const worldUpdateMessage = { 173 | id: 'worldUpdate', 174 | data: [] 175 | }; 176 | 177 | for ( let j = 0; j < this.clients.length; j ++ ) { 178 | 179 | if ( i === j ) continue; 180 | 181 | const other = this.clients[ j ]; 182 | 183 | worldUpdateMessage.data.push( { 184 | id: other.id, 185 | x: other.player.x, 186 | y: other.player.y, 187 | rotation: other.player.rotation 188 | } ); 189 | 190 | } 191 | 192 | client.sendMessage( JSON.stringify( worldUpdateMessage ) ); 193 | 194 | } 195 | 196 | } 197 | 198 | } ); 199 | 200 | module.exports = Server; -------------------------------------------------------------------------------- /src/shared/Inputs.js: -------------------------------------------------------------------------------- 1 | function Inputs() { 2 | 3 | this.deltaTime = 0; 4 | this.moveLeft = false; 5 | this.moveRight = false; 6 | this.moveForward = false; 7 | this.moveBackward = false; 8 | this.rotateLeft = false; 9 | this.rotateRight = false; 10 | 11 | } 12 | 13 | Object.assign( Inputs.prototype, { 14 | 15 | clone: function () { 16 | 17 | let object = new this.constructor(); 18 | 19 | object.deltaTime = this.deltaTime; 20 | object.moveLeft = this.moveLeft; 21 | object.moveRight = this.moveRight; 22 | object.moveForward = this.moveForward; 23 | object.moveBackward = this.moveBackward; 24 | object.rotateLeft = this.rotateLeft; 25 | object.rotateRight = this.rotateRight; 26 | 27 | return object; 28 | 29 | } 30 | 31 | } ); 32 | 33 | if ( typeof module === 'object' ) { 34 | 35 | module.exports = Inputs; 36 | 37 | } -------------------------------------------------------------------------------- /src/shared/Player.js: -------------------------------------------------------------------------------- 1 | function Player() { 2 | 3 | this.x = 0; 4 | this.y = 0; 5 | this.velX = 0; 6 | this.velY = 0; 7 | this.rotation = 0; 8 | this.radius = 34; 9 | this.angularVelocity = 0; 10 | this.areaSizeX = 700; 11 | this.areaSizeY = 500; 12 | 13 | } 14 | 15 | Object.assign( Player.prototype, { 16 | 17 | move: function ( inputs ) { 18 | 19 | const deltaTime = inputs.deltaTime / 1000; 20 | 21 | const maxAngularVelocity = 6; 22 | const theta = Math.PI * 2 * deltaTime; 23 | 24 | 25 | if ( inputs.rotateLeft ) { 26 | 27 | this.angularVelocity -= theta; 28 | 29 | } else if ( inputs.rotateRight ) { 30 | 31 | this.angularVelocity += theta; 32 | 33 | } 34 | 35 | const angularFriction = Math.PI / 180 * 180; 36 | 37 | const currentAngularSpeed = Math.abs( this.angularVelocity ); 38 | 39 | this.angularVelocity = Math.sign( this.angularVelocity ) * Math.max( 0, currentAngularSpeed - angularFriction * deltaTime ); 40 | 41 | this.rotation += this.angularVelocity * deltaTime; 42 | 43 | let dirX = 0; 44 | let dirY = 0; 45 | 46 | const sin = Math.sin( this.rotation ); 47 | const cos = Math.cos( this.rotation ); 48 | 49 | if ( inputs.moveForward ) { 50 | 51 | dirX += cos; 52 | dirY += sin; 53 | 54 | } else if ( inputs.moveBackward ) { 55 | 56 | dirX -= cos; 57 | dirY -= sin; 58 | 59 | } 60 | 61 | if ( inputs.moveRight ) { 62 | 63 | dirX += - sin; 64 | dirY += cos; 65 | 66 | } else if ( inputs.moveLeft ) { 67 | 68 | dirX -= - sin; 69 | dirY -= cos; 70 | 71 | } 72 | 73 | const acceleration = 400; 74 | const maxSpeed = 500; 75 | const friction = 50; 76 | 77 | const currentSpeed = Math.hypot( this.velX, this.velY ); 78 | 79 | if ( currentSpeed > 0 ) { 80 | 81 | let amount = currentSpeed - friction * deltaTime; 82 | 83 | if ( amount < 0 ) { 84 | 85 | amount = 0; 86 | 87 | } 88 | 89 | const factor = amount / currentSpeed; 90 | 91 | this.velX *= factor; 92 | this.velY *= factor; 93 | 94 | } 95 | 96 | let amount = acceleration * deltaTime; 97 | 98 | if ( currentSpeed + amount > maxSpeed ) { 99 | 100 | amount = maxSpeed - currentSpeed; 101 | 102 | } 103 | 104 | this.velX += amount * dirX; 105 | this.velY += amount * dirY; 106 | 107 | this.x += this.velX * deltaTime; 108 | this.y += this.velY * deltaTime; 109 | 110 | const sx = this.areaSizeX / 2 - this.radius; 111 | const sy = this.areaSizeY / 2 - this.radius; 112 | 113 | if ( this.x < - sx ) { 114 | 115 | this.velX *= - 0.75; 116 | this.x = - sx; 117 | 118 | } else if ( this.x > sx ) { 119 | 120 | this.velX *= - 0.75; 121 | this.x = sx; 122 | 123 | } 124 | 125 | if ( this.y < - sy ) { 126 | 127 | this.velY *= - 0.75; 128 | this.y = - sy; 129 | 130 | } else if ( this.y > sy ) { 131 | 132 | this.velY *= - 0.75; 133 | this.y = sy; 134 | 135 | } 136 | 137 | } 138 | 139 | } ); 140 | 141 | if ( typeof module === 'object' ) { 142 | 143 | module.exports = Player; 144 | 145 | } --------------------------------------------------------------------------------