From 70d2850932e1cf4e1795dd7eef86779ee95ade5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20L=C3=B8ve=20Selvik?= Date: Sun, 13 Jan 2019 18:02:09 -0800 Subject: [PATCH 1/5] Add local prediction of player character to reduce perception of lag --- client/index.html | 2 + client/src/GameState.js | 79 ++++++--- game/Character.js | 147 ++++++++++------ game/utility.js | 377 ++++++++++++++++++++-------------------- server/server.js | 20 ++- 5 files changed, 364 insertions(+), 261 deletions(-) diff --git a/client/index.html b/client/index.html index 3699918..eb5d8fd 100644 --- a/client/index.html +++ b/client/index.html @@ -42,6 +42,8 @@ + + diff --git a/client/src/GameState.js b/client/src/GameState.js index 2dfd801..a4daa6e 100644 --- a/client/src/GameState.js +++ b/client/src/GameState.js @@ -5,7 +5,10 @@ var types = { BULLET: 2 }; +var local_prediction = true; var ready_for_render = false; +var walls = []; +Utility.populate_walls(walls, Wall); GameState.prototype.connectWebsocket = function() { var ws = new WebSocket('ws://localhost:1337', 'echo-protocol'); @@ -30,6 +33,7 @@ GameState.prototype.connectWebsocket = function() { that.states = [that.states[1] ,that.states[2] ,message.state]; + that.input_ack = message.input_tick; that.playSounds(message.state.sounds); } else if (message.type == 'chat') { that.chat.displayMessage(message.name + ': ' + message.message); @@ -79,6 +83,7 @@ GameState.prototype.resume = function() { ]; var playerName = document.getElementById('player-name-input').value; this.player = new Player(playerName); + this.you = new Character(); localStorage.playerName = playerName; this.connectWebsocket(); this.scoreL = 8; @@ -87,8 +92,16 @@ GameState.prototype.resume = function() { this.cameraX = 0; this.cameraY = 0; this.states = []; + this.pending_input = []; }; +GameState.prototype.predict_self = function() { + for (var i = 0; i < this.pending_input.length; i++){ + input = [false].concat(this.pending_input[i]['inputs']); + this.you.applyInputs(input, walls, Utility); + } + +} GameState.prototype.render = function(ctx) { var states = this.states; @@ -108,18 +121,32 @@ GameState.prototype.render = function(ctx) { var players = state.players; var players_next = state_next.players; - var you = players[this.youId]; - var you_next = players_next[this.youId]; - if(you_next && you){ - var you_x = you.x * (1 - coeff) + you_next.x * coeff; - var you_y = you.y * (1 - coeff) + you_next.y * coeff; - this.cameraX = you_x; - this.cameraY = you_y; - - } else if(you){ - this.cameraX = you.x; - this.cameraY = you.y; + // To reduce lag the player need to be as updated as possible + // even if this is inconsistent with the rest of the state + // see https://developer.valvesoftware.com/wiki/Latency_Compensating_Methods_in_Client/Server_In-game_Protocol_Design_and_Optimization + // for an example of the philosophy used + that = this; + this.pending_input = this.pending_input.filter(input => input.tick > that.input_ack); + + if(local_prediction){ + var you_state = states[2].players[this.youId]; + this.you.setState(you_state) + this.predict_self(); + }else{ + var you = players[this.youId]; + var you_next = players_next[this.youId]; + } + if(this.you){ + if(local_prediction){ + this.cameraX = this.you.x; + this.cameraY = this.you.y; + } else { + var you_x = you.x * (1 - coeff) + you_next.x * coeff; + var you_y = you.y * (1 - coeff) + you_next.y * coeff; + this.cameraX = you_x; + this.cameraY = you_y; + } } else { this.cameraX = 8; this.cameraY = 5; @@ -154,9 +181,16 @@ GameState.prototype.render = function(ctx) { } for(var i in players) { + var player = players[i]; var player_next = players_next[i]; var name = this.players[i].name; + + if(i == this.youId && local_prediction){ + this.you.render(ctx, null, 0, this.playerImgLight, this.playerImgDark, name); + continue; // Special case for self rendering + } + Character.prototype.render.call( player, ctx, @@ -166,7 +200,7 @@ GameState.prototype.render = function(ctx) { this.playerImgDark, name); } - + var bullets = state.bullets; var bullets_next = state_next.bullets; @@ -282,8 +316,8 @@ GameState.prototype.render = function(ctx) { 8 * GU, 7.5 * GU); } ctx.save(); - if (you) { - Character.prototype.renderUi.call(you, ctx); + if (this.you) { + this.you.renderUi(ctx); } } @@ -319,19 +353,22 @@ GameState.prototype.update = function() { } if(this.wsReady && !this.chat.open) { - var inputs = []; - this.ws.send(JSON.stringify({ - type: 'inputs', - inputs: [ + var inputs = [ KEYS[87] || KEYS[38], // W, up arrow KEYS[83] || KEYS[40], // S, down arrow KEYS[65] || KEYS[37], // A, left arrow KEYS[68] || KEYS[39], // D, right arrow MOUSE.left, MOUSE.right, - mouseDir - ] - })); + mouseDir]; + + this.ws.send(JSON.stringify({ + type: 'inputs', + tick: tick, + inputs: inputs})); + + this.pending_input.push({tick: tick, inputs: inputs}); + } }; diff --git a/game/Character.js b/game/Character.js index 739dce1..973b443 100644 --- a/game/Character.js +++ b/game/Character.js @@ -46,6 +46,24 @@ Character.prototype.init = function(spawnPoint) { this.respawnTime = 4000; }; +Character.prototype.setState = function(state) { + this.x = state.x; + this.y = state.y; + this.hp = state.hp; + this.kills = state.kills; + this.deaths = state.deaths; + this.captures = state.captures; + //this.mouseDirection = state.mouseDirection; + this.isShieldActive = state.isShieldActive; + this.shieldEnergy = state.shieldEnergy; + this.team = state.team; + this.weaponHeat = state.weaponHeat; + this.overheated = state.overheated; + this.respawnTime = state.respawnTime; + this.timeDied = state.timeDied; + this.timeToRespawn = state.timeToRespawn; +}; + Character.prototype.getState = function() { return { t: 'C', @@ -141,6 +159,27 @@ Character.getClosestSpawnPoint = function(team, character, capturePoints) { } return spawnPoint; }; + +Character.prototype.applyInputs = function(input, walls, utility) { + this.applyMovementForce(input, walls, utility); + this.mouseDirection = input[BUTTONS.MOUSE_DIR]; + + this.isShieldActive = input[BUTTONS.ALTERNATE_FIRE]; + this.shieldEnergy += 0.003; + if (this.shieldEnergy > 1) { + this.shieldEnergy = 1; + } + + this.weaponHeat -= this.overheated ? 0.005 : 0.01; + if (this.weaponHeat < 0) { + this.weaponHeat = 0; + } + if (this.weaponHeat < 1) { + this.overheated = false; + } + +} + Character.prototype.update = function(input, walls, utility, capturePoints, points) { this.points = points; if (this.timeDied) { @@ -154,8 +193,7 @@ Character.prototype.update = function(input, walls, utility, capturePoints, poin return; } - this.applyMovementForce(input); - this.applyFrictionForce(); + this.applyMovementForce(input, walls, utility); this.mouseDirection = input[BUTTONS.MOUSE_DIR]; this.isShieldActive = input[BUTTONS.ALTERNATE_FIRE]; @@ -173,47 +211,11 @@ Character.prototype.update = function(input, walls, utility, capturePoints, poin } - for(var i = 0; i < walls.length; i++) { - if(utility.intersectLineCircle(walls[i].start_x, walls[i].start_y, walls[i].end_x, walls[i].end_y, this.x, this.y, Character.BODY_RADIUS)) { - var p = walls[i].getPushVector(this.x, this.y, Character.BODY_RADIUS); - - //Decompose velocity! - var newDx = 0; - var newDy = 0; - - - var p_l = Math.sqrt(p.x * p.x + p.y * p.y); - if(p_l > 0.0001){ - p = { x: p.x/p_l, y: p.y / p_l } - - var newSpeed = p.x * this.dx + p.y * this.dy; - if(newSpeed > 0){ //Moving away from wall. this is ok. - newDx += p.x * newSpeed; - newDy += p.y * newSpeed; - } - - } - - var n = { x: -p.y, y: p.x }; - var n_l = Math.sqrt(n.x * n.x + n.y * n.y); - if(n_l > 0.0001){ - n = { x: n.x/n_l, y: n.y / n_l } - - var newSpeed = n.x * this.dx + n.y * this.dy; - newDx += n.x * newSpeed; - newDy += n.y * newSpeed; - } - - this.dx = newDx; - this.dy = newDy; - } - } - - this.x += this.dx; - this.y += this.dy; + //this.x += this.dx; + //this.y += this.dy; }; -Character.prototype.applyMovementForce = function(input) { +Character.prototype.applyMovementForce = function(input, walls, utility) { var fx = 0; var fy = 0; if (input[BUTTONS.MOVE_UP]) { // W @@ -232,11 +234,50 @@ Character.prototype.applyMovementForce = function(input) { if (fx || fy) { var targetDirection = Math.atan2(fy, fx); - fx = this.accelerationCoefficient * Math.cos(targetDirection); - fy = this.accelerationCoefficient * Math.sin(targetDirection); + speed = 0.1; + fx = speed * Math.cos(targetDirection); + fy = speed * Math.sin(targetDirection); + + if(walls){ + for(var i = 0; i < walls.length; i++) { + if(utility.intersectLineCircle(walls[i].start_x, walls[i].start_y, walls[i].end_x, walls[i].end_y, this.x, this.y, Character.BODY_RADIUS)) { + var p = walls[i].getPushVector(this.x, this.y, Character.BODY_RADIUS); + + //Decompose velocity! + var newDx = 0; + var newDy = 0; + + + var p_l = Math.sqrt(p.x * p.x + p.y * p.y); + if(p_l > 0.0001){ + p = { x: p.x/p_l, y: p.y / p_l } + + var newSpeed = p.x * fx + p.y * fy; + if(newSpeed > 0){ //Moving away from wall. this is ok. + newDx += p.x * newSpeed; + newDy += p.y * newSpeed; + } + + } + + var n = { x: -p.y, y: p.x }; + var n_l = Math.sqrt(n.x * n.x + n.y * n.y); + if(n_l > 0.0001){ + n = { x: n.x/n_l, y: n.y / n_l } + + var newSpeed = n.x * fx + n.y * fy; + newDx += n.x * newSpeed; + newDy += n.y * newSpeed; + } + + fx = newDx; + fy = newDy; + } + } + } + this.x += fx; + this.y += fy; - this.dx += fx; - this.dy += fy; } }; @@ -256,11 +297,13 @@ Character.prototype.applyFrictionForce = function() { }; Character.prototype.render = function(ctx, player_next, coeff, lightImg, darkImg, name) { - if (!player_next) { - return; + if (player_next) { + var x = this.x * (1 - coeff) + player_next.x * coeff; + var y = this.y * (1 - coeff) + player_next.y * coeff; + }else{ + var x = this.x; + var y = this.y; } - var x = this.x * (1 - coeff) + player_next.x * coeff; - var y = this.y * (1 - coeff) + player_next.y * coeff; if (this.timeToRespawn) { ctx.save(); @@ -274,7 +317,11 @@ Character.prototype.render = function(ctx, player_next, coeff, lightImg, darkImg return; } - var hp = this.hp * (1 - coeff) + player_next.hp * coeff; + if (player_next) { + var hp = this.hp * (1 - coeff) + player_next.hp * coeff; + }else{ + var hp = this.hp; + } ctx.save(); ctx.translate(x * GU, y * GU); diff --git a/game/utility.js b/game/utility.js index ef0c7d0..0f6dec5 100644 --- a/game/utility.js +++ b/game/utility.js @@ -1,201 +1,206 @@ //various utility functions -module.exports = { - intersectLineCircle : function(startX, startY, endX, endY, centerX, centerY, radius){ - // http://stackoverflow.com/questions/1073336/circle-line-segment-collision-detection-algorithm - var d_x = endX - startX; - var d_y = endY - startY; - - var f_x = startX - centerX; - var f_y = startY - centerY; +function Utility(){ + +} + +Utility.intersectLineCircle = function(startX, startY, endX, endY, centerX, centerY, radius){ + // http://stackoverflow.com/questions/1073336/circle-line-segment-collision-detection-algorithm + var d_x = endX - startX; + var d_y = endY - startY; + + var f_x = startX - centerX; + var f_y = startY - centerY; + + + var a = d_x*d_x + d_y * d_y; + var b = 2 * (f_x*d_x + f_y*d_y); + var c = (f_x*f_x + f_y*f_y) - radius * radius; + + var discriminant = b*b - 4*a*c; + + if(discriminant < 0){ + //no intersection + return false; + }else{ + discriminant = Math.sqrt(discriminant); + // either solution may be on or off the ray so need to test both + // t1 is always the smaller value, because BOTH discriminant and + // a are nonnegative. + var t1 = (-b - discriminant)/(2*a); + var t2 = (-b + discriminant)/(2*a); + + // 3x HIT cases: + // -o-> --|--> | | --|-> + // Impale(t1 hit,t2 hit), Poke(t1 hit,t2>1), ExitWound(t1<0, t2 hit), + + // 3x MISS cases: + // -> o o -> | -> | + // FallShort (t1>1,t2>1), Past (t1<0,t2<0), CompletelyInside(t1<0, t2>1) + + if( t1 >= 0 && t1 <= 1 ) + { + // t1 is the intersection, and it's closer than t2 + // (since t1 uses -b - discriminant) + // Impale, Poke + return true ; + } + // here t1 didn't intersect so we are either started + // inside the sphere or completely past it + if( t2 >= 0 && t2 <= 1 ) + { + // ExitWound + return true ; + } - var a = d_x*d_x + d_y * d_y; - var b = 2 * (f_x*d_x + f_y*d_y); - var c = (f_x*f_x + f_y*f_y) - radius * radius; + // no intn: FallShort, Past, CompletelyInside + return false ; + } - var discriminant = b*b - 4*a*c; +} - if(discriminant < 0){ - //no intersection +Utility.lineIntersect = function(x1,y1,x2,y2, x3,y3,x4,y4) { + //http://stackoverflow.com/questions/9043805/test-if-two-lines-intersect-javascript-function + var x=((x1*y2-y1*x2)*(x3-x4)-(x1-x2)*(x3*y4-y3*x4))/((x1-x2)*(y3-y4)-(y1-y2)*(x3-x4)); + var y=((x1*y2-y1*x2)*(y3-y4)-(y1-y2)*(x3*y4-y3*x4))/((x1-x2)*(y3-y4)-(y1-y2)*(x3-x4)); + if (isNaN(x)||isNaN(y)) { return false; - }else{ - discriminant = Math.sqrt(discriminant); - // either solution may be on or off the ray so need to test both - // t1 is always the smaller value, because BOTH discriminant and - // a are nonnegative. - var t1 = (-b - discriminant)/(2*a); - var t2 = (-b + discriminant)/(2*a); - - // 3x HIT cases: - // -o-> --|--> | | --|-> - // Impale(t1 hit,t2 hit), Poke(t1 hit,t2>1), ExitWound(t1<0, t2 hit), - - // 3x MISS cases: - // -> o o -> | -> | - // FallShort (t1>1,t2>1), Past (t1<0,t2<0), CompletelyInside(t1<0, t2>1) - - if( t1 >= 0 && t1 <= 1 ) - { - // t1 is the intersection, and it's closer than t2 - // (since t1 uses -b - discriminant) - // Impale, Poke - return true ; + } else { + if (x1>=x2) { + if (!(x2<=x&&x<=x1)) {return false;} + } else { + if (!(x1<=x&&x<=x2)) {return false;} } - - // here t1 didn't intersect so we are either started - // inside the sphere or completely past it - if( t2 >= 0 && t2 <= 1 ) - { - // ExitWound - return true ; + if (y1>=y2) { + if (!(y2<=y&&y<=y1)) {return false;} + } else { + if (!(y1<=y&&y<=y2)) {return false;} } - - // no intn: FallShort, Past, CompletelyInside - return false ; - } - - }, - lineIntersect : function(x1,y1,x2,y2, x3,y3,x4,y4) { - //http://stackoverflow.com/questions/9043805/test-if-two-lines-intersect-javascript-function - var x=((x1*y2-y1*x2)*(x3-x4)-(x1-x2)*(x3*y4-y3*x4))/((x1-x2)*(y3-y4)-(y1-y2)*(x3-x4)); - var y=((x1*y2-y1*x2)*(y3-y4)-(y1-y2)*(x3*y4-y3*x4))/((x1-x2)*(y3-y4)-(y1-y2)*(x3-x4)); - if (isNaN(x)||isNaN(y)) { - return false; - } else { - if (x1>=x2) { - if (!(x2<=x&&x<=x1)) {return false;} - } else { - if (!(x1<=x&&x<=x2)) {return false;} - } - if (y1>=y2) { - if (!(y2<=y&&y<=y1)) {return false;} - } else { - if (!(y1<=y&&y<=y2)) {return false;} - } - if (x3>=x4) { - if (!(x4<=x&&x<=x3)) {return false;} - } else { - if (!(x3<=x&&x<=x4)) {return false;} - } - if (y3>=y4) { - if (!(y4<=y&&y<=y3)) {return false;} - } else { - if (!(y3<=y&&y<=y4)) {return false;} - } - } - return true; - }, - populate_walls : function(walls, Wall) { - points = [[414, 433, - 1521, 446, - 1530, 678, - 1706, 680, - 1713, 782, - 1846, 783, - 1845, 1013, - 1657, 1208, - 1476, 1210, - 1471, 1500, - 377, 1482, - 59, 1104, - 68, 800, - 217, 692, - 410, 691, - 414, 433 - ], - [520, 517, - 785, 521, - 788, 579, - 658, 728, - 520, 726, - 520, 517 - ], - [733, 763, - 861, 619, - 950, 615, - 1025, 616 - ], - [950, 615, - 949, 541, - ], - [1109, 556, - 1364, 563, - 1360, 670, - 1228, 786, - 1104, 782, - 1109, 556 - ], - [416, 822, - 598, 826, - 596, 908, - 483, 908, - 470, 1111, - 318, 1112, - 318, 922, - 416, 822 - ], - [631, 1018, - 631, 948, - 700, 949 - ], - [881, 795, - 881, 884 - ], - [733, 1011, - 860, 1013, - 860, 971 - ], - [1475, 1055, - 1580, 1057 - ], - [535, 1196, - 782, 1198, - 781, 1362, - 535, 1359, - 535, 1196 - ], - [945, 1308, - 943, 1414 - ], - [1081, 1211, - 1082, 1168, - 1375, 1165, - 1374, 1360, - 1083, 1356, - 1081, 1211, - 889, 1209, - 888, 1116, - 736, 1114 - ], - [1031, 881, - 1179, 880 - ], - [1041, 991, - 1042, 1081 - ], - [1265, 1077, - 1359, 1079, - 1372, 791, - 1580, 790 - ], - [1444, 871, - 1573, 876, - 1571, 995, - 1438, 995, - 1444, 871 - ]]; - for(var i = 0; i < points.length; i++) { - - for(var j = 0; j < points[i].length; j++) { - points[i][j] = Math.round( points[i][j] * 64/1920); + if (x3>=x4) { + if (!(x4<=x&&x<=x3)) {return false;} + } else { + if (!(x3<=x&&x<=x4)) {return false;} } - for(var j = 0; j < points[i].length; j += 2) { - walls.push(new Wall(points[i][j],points[i][j + 1], points[i][j + 2], points[i][j + 3])); + if (y3>=y4) { + if (!(y4<=y&&y<=y3)) {return false;} + } else { + if (!(y3<=y&&y<=y4)) {return false;} } + } + return true; +} + +Utility.populate_walls = function(walls, Wall) { + points = [[414, 433, + 1521, 446, + 1530, 678, + 1706, 680, + 1713, 782, + 1846, 783, + 1845, 1013, + 1657, 1208, + 1476, 1210, + 1471, 1500, + 377, 1482, + 59, 1104, + 68, 800, + 217, 692, + 410, 691, + 414, 433 + ], + [520, 517, + 785, 521, + 788, 579, + 658, 728, + 520, 726, + 520, 517 + ], + [733, 763, + 861, 619, + 950, 615, + 1025, 616 + ], + [950, 615, + 949, 541, + ], + [1109, 556, + 1364, 563, + 1360, 670, + 1228, 786, + 1104, 782, + 1109, 556 + ], + [416, 822, + 598, 826, + 596, 908, + 483, 908, + 470, 1111, + 318, 1112, + 318, 922, + 416, 822 + ], + [631, 1018, + 631, 948, + 700, 949 + ], + [881, 795, + 881, 884 + ], + [733, 1011, + 860, 1013, + 860, 971 + ], + [1475, 1055, + 1580, 1057 + ], + [535, 1196, + 782, 1198, + 781, 1362, + 535, 1359, + 535, 1196 + ], + [945, 1308, + 943, 1414 + ], + [1081, 1211, + 1082, 1168, + 1375, 1165, + 1374, 1360, + 1083, 1356, + 1081, 1211, + 889, 1209, + 888, 1116, + 736, 1114 + ], + [1031, 881, + 1179, 880 + ], + [1041, 991, + 1042, 1081 + ], + [1265, 1077, + 1359, 1079, + 1372, 791, + 1580, 790 + ], + [1444, 871, + 1573, 876, + 1571, 995, + 1438, 995, + 1444, 871 + ]]; + for(var i = 0; i < points.length; i++) { + + for(var j = 0; j < points[i].length; j++) { + points[i][j] = Math.round( points[i][j] * 64/1920); + } + for(var j = 0; j < points[i].length; j += 2) { + walls.push(new Wall(points[i][j],points[i][j + 1], points[i][j + 2], points[i][j + 3])); } - }, -}; + } +} +module.exports = Utility; diff --git a/server/server.js b/server/server.js index 89bf852..dc217f5 100644 --- a/server/server.js +++ b/server/server.js @@ -27,6 +27,7 @@ var count = 0; var clients = {}; var bullets = []; var walls = []; +console.log(utility.populate_walls); utility.populate_walls(walls, Wall); var capture_points = []; var dark_points = 0; @@ -78,6 +79,8 @@ wsServer.on('request', function(r) { if(event.type == 'inputs') { if(!connection.player) return; connection.player.input = [false].concat(event.inputs); + connection.input_tick = event.tick; + } else if (event.type == 'chat') { console.log((new Date()) + ' Chat: ' + connection.player.name + ': ' + event.message); parseChatMessage(event.message); @@ -88,7 +91,6 @@ wsServer.on('request', function(r) { clients[i].send(JSON.stringify({ type: 'chat', message: event.message, - name: connection.player.name, })); } } else if (event.type == 'join') { @@ -159,7 +161,7 @@ function parseChatMessage(msg){ function loop() { time = getTime(); - var deltaTime = time -oldTime; + var deltaTime = time - oldTime; updateTickAccumulator += deltaTime; networkTickAccumulator += deltaTime; oldTime = time; @@ -326,9 +328,8 @@ function update() { oldDarkCapturePointCount = darkCapturePointCount; } -function sendNetworkState(tick) { +function getState(tick) { var state = {}; - state.tick = tick; state.players = {}; state.bullets = {}; state.capture_points = {}; @@ -365,6 +366,12 @@ function sendNetworkState(tick) { state.sounds.push(soundId) } } + return state; +} + +function sendNetworkState(tick) { + var state = getState(tick); + state.tick = tick; var messageAsJSON = JSON.stringify({ type: 'state', @@ -375,6 +382,11 @@ function sendNetworkState(tick) { if(!clients.hasOwnProperty(i)) { continue; } + var messageAsJSON = JSON.stringify({ + type: 'state', + state: state, + input_tick: clients[i].input_tick ? clients[i].input_tick : 0 + }); clients[i].sendUTF(messageAsJSON); } soundsToPlay = {}; From 9f1666223048e655c7093e4949273fb7e2d45263 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20L=C3=B8ve=20Selvik?= Date: Sun, 13 Jan 2019 20:35:47 -0800 Subject: [PATCH 2/5] Distinguish between the input received vs applied by the server --- server/server.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/server/server.js b/server/server.js index dc217f5..69a45f5 100644 --- a/server/server.js +++ b/server/server.js @@ -79,7 +79,7 @@ wsServer.on('request', function(r) { if(event.type == 'inputs') { if(!connection.player) return; connection.player.input = [false].concat(event.inputs); - connection.input_tick = event.tick; + connection.received_tick = event.tick; } else if (event.type == 'chat') { console.log((new Date()) + ' Chat: ' + connection.player.name + ': ' + event.message); @@ -220,6 +220,7 @@ function update() { continue; } var player = clients[i].player; + clients[i].applied_tick = clients[i].received_tick; if (!player) { continue; } @@ -385,7 +386,7 @@ function sendNetworkState(tick) { var messageAsJSON = JSON.stringify({ type: 'state', state: state, - input_tick: clients[i].input_tick ? clients[i].input_tick : 0 + input_tick: clients[i].applied_tick ? clients[i].applied_tick : 0 }); clients[i].sendUTF(messageAsJSON); } From ce26ef3702c3b850bdeb07d780a83c1de42e30ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20L=C3=B8ve=20Selvik?= Date: Sat, 19 Jan 2019 12:43:46 -0800 Subject: [PATCH 3/5] Make server queue input events to make sure it doesn't drop any --- game/Character.js | 8 ++-- game/input.js | 14 +++--- server/server.js | 117 +++++++++++++++++++++++++--------------------- 3 files changed, 75 insertions(+), 64 deletions(-) diff --git a/game/Character.js b/game/Character.js index 973b443..99e8692 100644 --- a/game/Character.js +++ b/game/Character.js @@ -180,7 +180,7 @@ Character.prototype.applyInputs = function(input, walls, utility) { } -Character.prototype.update = function(input, walls, utility, capturePoints, points) { +Character.prototype.update = function(capturePoints, points) { this.points = points; if (this.timeDied) { this.timeToRespawn = this.getTimeUntilRespawn(this.timeDied); @@ -193,10 +193,10 @@ Character.prototype.update = function(input, walls, utility, capturePoints, poin return; } - this.applyMovementForce(input, walls, utility); - this.mouseDirection = input[BUTTONS.MOUSE_DIR]; + //this.applyMovementForce(input, walls, utility); + //this.mouseDirection = input[BUTTONS.MOUSE_DIR]; - this.isShieldActive = input[BUTTONS.ALTERNATE_FIRE]; + //this.isShieldActive = input[BUTTONS.ALTERNATE_FIRE]; this.shieldEnergy += 0.003; if (this.shieldEnergy > 1) { this.shieldEnergy = 1; diff --git a/game/input.js b/game/input.js index 518dded..11f064d 100644 --- a/game/input.js +++ b/game/input.js @@ -1,9 +1,9 @@ module.exports = BUTTONS = { - MOVE_UP: 1, - MOVE_DOWN: 2, - MOVE_LEFT: 3, - MOVE_RIGHT: 4, - FIRE: 5, - ALTERNATE_FIRE: 6, - MOUSE_DIR: 7, + MOVE_UP: 0, + MOVE_DOWN: 1, + MOVE_LEFT: 2, + MOVE_RIGHT: 3, + FIRE: 4, + ALTERNATE_FIRE: 5, + MOUSE_DIR: 6, }; diff --git a/server/server.js b/server/server.js index 69a45f5..6f97df2 100644 --- a/server/server.js +++ b/server/server.js @@ -78,8 +78,7 @@ wsServer.on('request', function(r) { var event = JSON.parse(message.utf8Data); if(event.type == 'inputs') { if(!connection.player) return; - connection.player.input = [false].concat(event.inputs); - connection.received_tick = event.tick; + connection.player.input_queue.push(event); } else if (event.type == 'chat') { console.log((new Date()) + ' Chat: ' + connection.player.name + ': ' + event.message); @@ -106,10 +105,11 @@ wsServer.on('request', function(r) { var spawnPoint = Character.getDefaultPoint(team); + connection.applied_tick = 0; connection.player = { character: new Character(team, spawnPoint), name: event.name, - input: [] + input_queue: [] }; for(var i in clients) { if(!clients.hasOwnProperty(i)) { @@ -220,68 +220,79 @@ function update() { continue; } var player = clients[i].player; - clients[i].applied_tick = clients[i].received_tick; if (!player) { continue; } var character = player.character; - character.update(player.input, walls, utility, capture_points, character.team == 0 ? light_points: dark_points); + while(player.input_queue.length > 0){ + var input_event = player.input_queue.shift(); //Get the oldest event in the queue and remove it. + var input_tick = input_event.tick; + var inputs = input_event.inputs; + player.applied_tick = input_tick; + + if (inputs[BUTTONS.FIRE] + && character.fireCooldown <= 0 + && !character.timeDied) { + + character.fireCooldown = fireCooldownTime; + + if (inputs[BUTTONS.FIRE] && (character.onCP || character.isShieldActive || character.overheated)) { + soundsToPlay['click.mp3'] = true; + } else { + var m_dir = inputs[BUTTONS.MOUSE_DIR]; + + var fire_dir_x = Math.cos(m_dir); + var fire_dir_y = Math.sin(m_dir); + + var blocked_by_wall = false; + for (var i = 0; i < walls.length; i++) { + if (utility.lineIntersect(character.x, + character.y, + character.x + (Character.BODY_RADIUS + 0.2) * fire_dir_x, + character.y + (Character.BODY_RADIUS + 0.2) * fire_dir_y, + walls[i].start_x, + walls[i].start_y, + walls[i].end_x, + walls[i].end_y)) { + blocked_by_wall = true; + } + } + if (!blocked_by_wall) { + // fire + bullets.push((new Bullet()).fire(character, fire_dir_x, fire_dir_y)); + } + character.weaponHeat += Character.heat_per_shot; + if (character.weaponHeat > Character.OVERHEAT_THRESHOLD) { + character.overheated = true; + character.weaponHeat = Character.OVERHEAT_THRESHOLD; + } + soundsToPlay[ + ['gun-1.mp3', 'gun-2.mp3', 'gun-3.mp3'][Math.random()*3|0]] = true; + } + } - if(character.fireCooldown > 0){ - character.fireCooldown--; + character.applyInputs(inputs, walls, utility) } + for(var i = 0; i < bullets.length; i++){ + var bullet = bullets[i]; + bullet.update(clients, walls, soundsToPlay); + if(!bullet.active){ + bullets[i] = bullets[bullets.length - 1]; + bullets.length = bullets.length - 1; + } + } - if (player.input[BUTTONS.FIRE] - && character.fireCooldown <= 0 - && !character.timeDied) { - character.fireCooldown = fireCooldownTime; + character.update(capture_points, character.team == 0 ? light_points: dark_points); - if (player.input[BUTTONS.FIRE] && (character.onCP || character.isShieldActive || character.overheated)) { - soundsToPlay['click.mp3'] = true; - } else { - var m_dir = player.input[BUTTONS.MOUSE_DIR]; - - var fire_dir_x = Math.cos(m_dir); - var fire_dir_y = Math.sin(m_dir); - - var blocked_by_wall = false; - for (var i = 0; i < walls.length; i++) { - if (utility.lineIntersect(character.x, - character.y, - character.x + (Character.BODY_RADIUS + 0.2) * fire_dir_x, - character.y + (Character.BODY_RADIUS + 0.2) * fire_dir_y, - walls[i].start_x, - walls[i].start_y, - walls[i].end_x, - walls[i].end_y)) { - blocked_by_wall = true; - } - } - if (!blocked_by_wall) { - // fire - bullets.push((new Bullet()).fire(character, fire_dir_x, fire_dir_y)); - } - character.weaponHeat += Character.heat_per_shot; - if (character.weaponHeat > Character.OVERHEAT_THRESHOLD) { - character.overheated = true; - character.weaponHeat = Character.OVERHEAT_THRESHOLD; - } - soundsToPlay[ - ['gun-1.mp3', 'gun-2.mp3', 'gun-3.mp3'][Math.random()*3|0]] = true; - } - } - } - for(var i = 0; i < bullets.length; i++){ - var bullet = bullets[i]; - bullet.update(clients, walls, soundsToPlay); - if(!bullet.active){ - bullets[i] = bullets[bullets.length - 1]; - bullets.length = bullets.length - 1; + if(character.fireCooldown > 0){ + character.fireCooldown--; } } + + //Set onCP = false for all players for(var i in clients) { if(!clients.hasOwnProperty(i)) { @@ -386,7 +397,7 @@ function sendNetworkState(tick) { var messageAsJSON = JSON.stringify({ type: 'state', state: state, - input_tick: clients[i].applied_tick ? clients[i].applied_tick : 0 + input_tick: clients[i].player.applied_tick ? clients[i].player.applied_tick : 0 }); clients[i].sendUTF(messageAsJSON); } From 4c71021b4f5e1c2cf5e4f45065019e6f3fb6fcfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20L=C3=B8ve=20Selvik?= Date: Sat, 19 Jan 2019 14:54:51 -0800 Subject: [PATCH 4/5] Add client side predicted bullets and tweak prediction --- client/src/GameState.js | 68 ++++++++++++++++++++++++++++++++++++++--- client/src/bootstrap.js | 3 +- game/Bullet.js | 7 +++-- game/Character.js | 5 ++- server/server.js | 28 +++++++++++------ 5 files changed, 92 insertions(+), 19 deletions(-) diff --git a/client/src/GameState.js b/client/src/GameState.js index a4daa6e..8f6e812 100644 --- a/client/src/GameState.js +++ b/client/src/GameState.js @@ -30,10 +30,10 @@ GameState.prototype.connectWebsocket = function() { message = JSON.parse(e.data); if(message.type == 'state') { oldstates = that.states; + message.state.input_ack = message.input_tick; that.states = [that.states[1] ,that.states[2] ,message.state]; - that.input_ack = message.input_tick; that.playSounds(message.state.sounds); } else if (message.type == 'chat') { that.chat.displayMessage(message.name + ': ' + message.message); @@ -93,15 +93,68 @@ GameState.prototype.resume = function() { this.cameraY = 0; this.states = []; this.pending_input = []; + this.speculative_bullets = []; }; GameState.prototype.predict_self = function() { + this.speculative_bullets = []; for (var i = 0; i < this.pending_input.length; i++){ - input = [false].concat(this.pending_input[i]['inputs']); + input = this.pending_input[i]['inputs']; + input_tick = this.pending_input[i]['tick']; + this.you.applyInputs(input, walls, Utility); + + if (input[BUTTONS.FIRE] + && input_tick - this.you.lastFireTick > 11 + && !this.you.onCP + && !this.you.isShieldActive + && !this.you.overheated + && !this.you.timeDied) { + + + + this.you.lastFireTick = input_tick; + + var m_dir = input[BUTTONS.MOUSE_DIR]; + + var fire_dir_x = Math.cos(m_dir); + var fire_dir_y = Math.sin(m_dir); + + var blocked_by_wall = false; + for (var wall_i = 0; wall_i < walls.length; wall_i++) { + if (Utility.lineIntersect(this.you.x, + this.you.y, + this.you.x + (Character.BODY_RADIUS + 0.2) * fire_dir_x, + this.you.y + (Character.BODY_RADIUS + 0.2) * fire_dir_y, + walls[wall_i].start_x, + walls[wall_i].start_y, + walls[wall_i].end_x, + walls[wall_i].end_y)) { + blocked_by_wall = true; + } + } + + if (!blocked_by_wall) { + // fire + bullet = (new Bullet()).fire(this.you, fire_dir_x, fire_dir_y) + bullet.fire_tick = input_tick + for(var t = 0; t < tick - bullet.fire_tick; t++){ + bullet.update(null, walls, null); + } + + this.speculative_bullets.push(bullet); + this.you.weaponHeat += Character.heat_per_shot; + this.you.fire_tick = input_tick; + if (this.you.weaponHeat > Character.OVERHEAT_THRESHOLD) { + this.you.overheated = true; + this.you.weaponHeat = Character.OVERHEAT_THRESHOLD; + } + } + } + } - } + GameState.prototype.render = function(ctx) { var states = this.states; @@ -126,10 +179,10 @@ GameState.prototype.render = function(ctx) { // see https://developer.valvesoftware.com/wiki/Latency_Compensating_Methods_in_Client/Server_In-game_Protocol_Design_and_Optimization // for an example of the philosophy used that = this; - this.pending_input = this.pending_input.filter(input => input.tick > that.input_ack); + this.pending_input = this.pending_input.filter(input => input.tick > state.input_ack); if(local_prediction){ - var you_state = states[2].players[this.youId]; + var you_state = players[this.youId]; this.you.setState(you_state) this.predict_self(); }else{ @@ -214,6 +267,11 @@ GameState.prototype.render = function(ctx) { } } + for(var i in this.speculative_bullets) { + var bullet = this.speculative_bullets[i]; + Bullet.prototype.render.call(bullet, ctx, bullet, 0); + } + this.ps.render(ctx); ctx.restore(); diff --git a/client/src/bootstrap.js b/client/src/bootstrap.js index 7e8974b..cc566b7 100644 --- a/client/src/bootstrap.js +++ b/client/src/bootstrap.js @@ -55,7 +55,6 @@ var RENDER_FRAMES_SO_FAR_THIS_COUNT_PERIOD = 0; var TIME_AT_RENDER_FRAME_COUNT_PERIOD_START = performance.now(); var UPDATE_FRAME = 0; function loop() { - requestAnimFrame(loop); if (loaded > 0) { canvas.width = canvas.width; ctx.fillStyle = "white"; @@ -63,6 +62,7 @@ function loop() { ctx.fillStyle = "black"; ctx.fillText("Loading " + loaded, 8 * GU, 4.5 * GU); t = old_time = performance.now(); + requestAnimFrame(loop); return; } t = performance.now(); @@ -90,6 +90,7 @@ function loop() { RENDER_FRAMES_SO_FAR_THIS_COUNT_PERIOD = 0; TIME_AT_RENDER_FRAME_COUNT_PERIOD_START = performance.now(); } + requestAnimFrame(loop); } function bootstrap() { diff --git a/game/Bullet.js b/game/Bullet.js index 86a64f7..858810d 100644 --- a/game/Bullet.js +++ b/game/Bullet.js @@ -1,7 +1,7 @@ try { window; } catch(e) { - var utility = require('./../game/utility'); + var Utility = require('./../game/utility'); var Character = require('./../game/Character'); } @@ -23,6 +23,7 @@ Bullet.prototype.init = function(x, y, dx, dy, team){ this.active = true; this.direction = Math.atan2(dy, dx); this.team = team; + this.fire_tick = 0; }; Bullet.prototype.getDamage = function(){ @@ -94,7 +95,7 @@ function checkCollisionWithPlayers(clients, bullet, oldX, oldY, newX, newY, soun continue; } var character = clients[i].player.character; - if (!character.timeDied && utility.intersectLineCircle(oldX, oldY, newX, newY, character.x, character.y, Character.BODY_RADIUS)) { + if (!character.timeDied && Utility.intersectLineCircle(oldX, oldY, newX, newY, character.x, character.y, Character.BODY_RADIUS)) { character.hit(bullet, soundsToPlay); hit = true; } @@ -106,7 +107,7 @@ function checkCollisionWithWalls(walls, bullet, oldX, oldY, newX, newY){ var hit = false; for(var i = 0; i < walls.length; i++) { - if(utility.lineIntersect(oldX, oldY, newX, newY, walls[i].start_x, walls[i].start_y, walls[i].end_x, walls[i].end_y)) { + if(Utility.lineIntersect(oldX, oldY, newX, newY, walls[i].start_x, walls[i].start_y, walls[i].end_x, walls[i].end_y)) { hit = true; } } diff --git a/game/Character.js b/game/Character.js index 99e8692..c47f704 100644 --- a/game/Character.js +++ b/game/Character.js @@ -12,6 +12,7 @@ function Character(team, spawnPoint) { this.kills = 0; this.deaths = 0; this.captures = 0; + this.lastFireTick = 0; this.init(spawnPoint); } @@ -62,6 +63,7 @@ Character.prototype.setState = function(state) { this.respawnTime = state.respawnTime; this.timeDied = state.timeDied; this.timeToRespawn = state.timeToRespawn; + this.lastFireTick = state.lastFireTick }; Character.prototype.getState = function() { @@ -81,7 +83,8 @@ Character.prototype.getState = function() { overheated: this.overheated, respawnTime: this.respawnTime, timeDied: this.timeDied, - timeToRespawn: this.timeToRespawn + timeToRespawn: this.timeToRespawn, + lastFireTick: this.lastFireTick }; }; diff --git a/server/server.js b/server/server.js index 6f97df2..22875cc 100644 --- a/server/server.js +++ b/server/server.js @@ -215,11 +215,11 @@ function update() { return ; } - for(var i in clients) { - if(!clients.hasOwnProperty(i)) { + for(var client_i in clients) { + if(!clients.hasOwnProperty(client_i)) { continue; } - var player = clients[i].player; + var player = clients[client_i].player; if (!player) { continue; } @@ -228,13 +228,18 @@ function update() { var input_event = player.input_queue.shift(); //Get the oldest event in the queue and remove it. var input_tick = input_event.tick; var inputs = input_event.inputs; - player.applied_tick = input_tick; + + character.applyInputs(inputs, walls, utility) + clients[client_i].applied_tick = input_tick; + if (inputs[BUTTONS.FIRE] - && character.fireCooldown <= 0 + && input_tick - character.lastFireTick >= fireCooldownTime && !character.timeDied) { character.fireCooldown = fireCooldownTime; + character.lastFireTick = input_tick; + if (inputs[BUTTONS.FIRE] && (character.onCP || character.isShieldActive || character.overheated)) { soundsToPlay['click.mp3'] = true; @@ -259,7 +264,14 @@ function update() { } if (!blocked_by_wall) { // fire - bullets.push((new Bullet()).fire(character, fire_dir_x, fire_dir_y)); + bullet = (new Bullet()).fire(character, fire_dir_x, fire_dir_y); + + //Compensate for latency + for(var t = input_tick; t < tick; t++){ + bullet.update(clients, walls, soundsToPlay); + } + + bullets.push(bullet); } character.weaponHeat += Character.heat_per_shot; if (character.weaponHeat > Character.OVERHEAT_THRESHOLD) { @@ -270,8 +282,6 @@ function update() { ['gun-1.mp3', 'gun-2.mp3', 'gun-3.mp3'][Math.random()*3|0]] = true; } } - - character.applyInputs(inputs, walls, utility) } for(var i = 0; i < bullets.length; i++){ @@ -397,7 +407,7 @@ function sendNetworkState(tick) { var messageAsJSON = JSON.stringify({ type: 'state', state: state, - input_tick: clients[i].player.applied_tick ? clients[i].player.applied_tick : 0 + input_tick: clients[i].applied_tick ? clients[i].applied_tick : 0 }); clients[i].sendUTF(messageAsJSON); } From 741aa1cedc6890e4585d9c14acfe997fbfffa77d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20L=C3=B8ve=20Selvik?= Date: Sat, 19 Jan 2019 15:00:20 -0800 Subject: [PATCH 5/5] Fix startup null error --- client/src/GameState.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/client/src/GameState.js b/client/src/GameState.js index 8f6e812..e7e3b04 100644 --- a/client/src/GameState.js +++ b/client/src/GameState.js @@ -183,8 +183,10 @@ GameState.prototype.render = function(ctx) { if(local_prediction){ var you_state = players[this.youId]; - this.you.setState(you_state) - this.predict_self(); + if(you_state){ + this.you.setState(you_state) + this.predict_self(); + } }else{ var you = players[this.youId]; var you_next = players_next[this.youId];