5.快节奏的多人游戏 (第五部分):样例代码和生动的Demo

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/qq_25327609/article/details/88898088

英文原文地址 Fast-Paced Multiplayer: Sample Code and Live Demo

能力有限,如有错误,请谅解,并欢迎指正!


      这是客户端 - 服务器体系结构的示例实现,演示了我的快节奏的多人游戏系列文章中解释的主要概念。除非你先阅读这些文章,否则没有多大意义。

      代码是纯JavaScript,它完全包含在此页面中。它不到500行代码,包括很多注释,表明一旦你真正理解了这些概念,实现它们就相对简单了。

      虽然它不是生产级别质量代码,但您可以在自己的应用程序中使用此代码。
在这里插入图片描述

Demo说明

      上面的视图显示了根据服务器的游戏世界的状态,以及两个客户端正在呈现的内容。您可以使用左箭头键和右箭头键移动由玩家1控制的蓝色球; 并且由玩家2控制的红球与A和D键。

      移动蓝色球。按箭头键和蓝色球实际移动之间存在相当大的延迟。在没有客户端预测的情况下,客户端仅在到服务器的往返之后才呈现球的新位置。由于250ms滞后,这需要一段时间。

      将玩家1延迟设置为0毫秒,然后重试。现在客户端和服务器同步移动,因为它们之间没有延迟,但移动不顺畅,因为服务器每秒只更新其内部状态3次。如果将服务器的更新速率提高到60,我们可以顺利移动。

      但这不是一个非常现实的情况。将玩家1延迟设置为250毫秒服务器更新速率恢复为3。这更接近真实游戏仍然需要工作的可怕条件。

      客户端预测和服务器协调救援!为玩家1启用它们并移动蓝色球。现在运动非常平滑,按箭头键和移动球之间没有明显的延迟。

      如果您使条件更差,这仍然有效 - 尝试将玩家1延迟设置为500毫秒,将服务器更新速率设置为1

      现在,对于玩家1自己的实体,蓝球来说,事情看起来很棒。然而,玩家2对同一实体的看法看起来很糟糕。由于服务器的更新率较低,因此玩家2每秒只获得一次玩家1实体的新位置,因此移动非常激动。

      为玩家2启用客户端预测和服务器协调无助于平滑蓝球的移动,因为这些技术仅影响玩家渲染其自己的实体的方式。如果你移动红球确实有所不同,但现在我们在玩家1的视野中有相同的跳跃感。

      为了解决这个问题,我们使用实体插值。为玩家2启用实体插值并移动蓝球。现在它移动得很顺利,但与玩家1和服务器相比,总是呈现“过去”。

      您可能会注意到插值实体的速度可能会有所不同。这是插值的一种假象,是由于服务器更新速率与速度的关系设置得太低而引起的。如果将服务器更新速率设置为10(这仍然很低),此效果几乎完全消失。

总结


      客户端预测和服务器协调是非常强大的技术,使多人游戏即使在极端恶劣的网络条件下也能感受到响应。因此,它们几乎是任何客户端/服务器多玩家网络架构的基本组成部分。


最后贴出整理后的代码


<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="generator" content="pandoc">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
<title>Fast-Paced Multiplayer: Sample Code and Live Demo - Gabriel Gambetta</title>
  <style type="text/css">code{white-space: pre;}</style>
  <link rel="stylesheet" href="/css/style.css">
  <!--[if lt IE 9]>
    <script src="//cdnjs.cloudflare.com/ajax/libs/html5shiv/3.7.3/html5shiv-printshiv.min.js"></script>
  <![endif]-->
  <script src="js/jquery-3.2.1.min.js"></script>
  <!-- Google Analytics -->
  <script>
    (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
    (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
    m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
    })(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
    ga('create', 'UA-17633478-1', 'auto');
    ga('send', 'pageview');
  
  
    var __send_ga_event = function(spec, opt_url) {
      var parts = spec.split("-");
      var opt_callback = undefined;
      if (!!opt_url) {
        //console.log("URL " + opt_url);
        opt_callback = {
          'transport': 'beacon',
          'hitCallback': function(){ document.location = opt_url; }
        };
      }
      //console.log("Event " + spec);
      ga('send', 'event', parts[0], parts[1], parts[2], opt_callback);
      return false;
    }
  
    var __add_gg_handlers = function(data_attr, opt_href_prefix) {
      $("[" + data_attr + "]").each(function(_, link) {
        var spec = $(link).attr(data_attr);
        var handler = (function() { 
          var href = undefined;
          if (!!opt_href_prefix) {        
            href = opt_href_prefix + spec;
            if (!$(link).attr("href")) {
              $(link).attr("href", href);
            }
          } else if (!$(link).attr("href")) {
            $(link).attr("href", "#");
          }
          var save_spec = spec;
          return function() { return __send_ga_event(save_spec, href); };
        })()
        $(link).click(handler);
      });
    }
  
    var __add_event_tracking = function() {
      __add_gg_handlers("gg-event");
      __add_gg_handlers("gg-link", "outbound.php?link=");
    }
  
    $(__add_event_tracking);
  </script>
  <!-- End Google Analytics -->
</head>
<body>
<style>
canvas {
    border: dotted 1px;
    padding:0;
    background: lightgray;
}</style>
<p><b>Player 1 view</b> - move with LEFT and RIGHT arrow keys<br> Lag = <input type="text" id="player1_lag" size=5 value="250" onchange="updateParameters();">ms</input> · <input type="checkbox" id="player1_prediction" onchange="updateParameters();">Prediction</input> · <input type="checkbox" id="player1_reconciliation" onchange="updateParameters();">Reconciliation</input> · <input type="checkbox" id="player1_interpolation" onchange="updateParameters();">Interpolation</input></p>
<canvas id="player1_canvas" width="920" height="75">
</canvas>
<div id="player1_status" style="font-family:courier;">
Waiting for connection…
</div>
</div>
<div style="height: 1em;">

</div>
<div style="border: 2px solid grey; padding: 15px;">
<p><b>Server view</b> · Update <input type="text" id="server_fps" size=5 value="3" onchange="updateParameters();"> times per second</input></p>
<canvas id="server_canvas" width="920" height="75">
</canvas>
<div id="server_status" style="font-family:courier;">

</div>
</div>
<div style="height: 1em;">

</div>
<div style="border: 5px solid red; padding: 15px;">
<p><b>Player 2 view</b> - move with A and D keys<br> Lag = <input type="text" id="player2_lag" size=5 value="150" onchange="updateParameters();">ms</input> · <input type="checkbox" id="player2_prediction" onchange="updateParameters();">Prediction</input> · <input type="checkbox" id="player2_reconciliation" onchange="updateParameters();">Reconciliation</input> · <input type="checkbox" id="player2_interpolation" onchange="updateParameters();">Interpolation</input></p>
<canvas id="player2_canvas" width="920" height="75">
</canvas>
<div id="player2_status" style="font-family:courier;">
Waiting for connection…
</div>
</div>
<script>

// =============================================================================
//  An Entity in the world.
// =============================================================================
var Entity = function() {
  this.x = 0;
  this.speed = 2; // units/s
  this.position_buffer = [];
}

// Apply user's input to this entity.
Entity.prototype.applyInput = function(input) {
  this.x += input.press_time*this.speed;
}


// =============================================================================
//  A message queue with simulated network lag.
// =============================================================================
var LagNetwork = function() {
  this.messages = [];
}

// "Send" a message. Store each message with the timestamp when it should be
// received, to simulate lag.
LagNetwork.prototype.send = function(lag_ms, message) {
  this.messages.push({recv_ts: +new Date() + lag_ms,
                      payload: message});
}

// Returns a "received" message, or undefined if there are no messages available
// yet.
LagNetwork.prototype.receive = function() {
  var now = +new Date();
  for (var i = 0; i < this.messages.length; i++) {
    var message = this.messages[i];
    if (message.recv_ts <= now) {
      this.messages.splice(i, 1);
      return message.payload;
    }
  }
}


// =============================================================================
//  The Client.
// =============================================================================
var Client = function(canvas, status) {
  // Local representation of the entities.
  this.entities = {};

  // Input state.
  this.key_left = false;
  this.key_right = false;

  // Simulated network connection.
  this.network = new LagNetwork();
  this.server = null;
  this.lag = 0;

  // Unique ID of our entity. Assigned by Server on connection.
  this.entity_id = null;

  // Data needed for reconciliation.
  this.client_side_prediction = false;
  this.server_reconciliation = false;
  this.input_sequence_number = 0;
  this.pending_inputs = [];

  // Entity interpolation toggle.
  this.entity_interpolation = true;

  // UI.
  this.canvas = canvas;
  this.status = status;

  // Update rate.
  this.setUpdateRate(50);
}


Client.prototype.setUpdateRate = function(hz) {
  this.update_rate = hz;

  clearInterval(this.update_interval);
  this.update_interval = setInterval(
    (function(self) { return function() { self.update(); }; })(this),
    1000 / this.update_rate);
}


// Update Client state.
Client.prototype.update = function() {
  // Listen to the server.
  this.processServerMessages();

  if (this.entity_id == null) {
    return;  // Not connected yet.
  }

  // Process inputs.
  this.processInputs();

  // Interpolate other entities.
  if (this.entity_interpolation) {
    this.interpolateEntities();
  }

  // Render the World.
  renderWorld(this.canvas, this.entities);

  // Show some info.
  var info = "Non-acknowledged inputs: " + this.pending_inputs.length;
  this.status.textContent = info;
}


// Get inputs and send them to the server.
// If enabled, do client-side prediction.
Client.prototype.processInputs = function() {
  // Compute delta time since last update.
  var now_ts = +new Date();
  var last_ts = this.last_ts || now_ts;
  var dt_sec = (now_ts - last_ts) / 1000.0;
  this.last_ts = now_ts;

  // Package player's input.
  var input;
  if (this.key_right) {
    input = { press_time: dt_sec };
  } else if (this.key_left) {
    input = { press_time: -dt_sec };
  } else {
    // Nothing interesting happened.
    return;
  }

  // Send the input to the server.
  input.input_sequence_number = this.input_sequence_number++;
  input.entity_id = this.entity_id;
  this.server.network.send(this.lag, input);

  // Do client-side prediction.
  if (this.client_side_prediction) {
    this.entities[this.entity_id].applyInput(input);
  }

  // Save this input for later reconciliation.
  this.pending_inputs.push(input);
}


// Process all messages from the server, i.e. world updates.
// If enabled, do server reconciliation.
Client.prototype.processServerMessages = function() {
  while (true) {
    var message = this.network.receive();
    if (!message) {
      break;
    }

    // World state is a list of entity states.
    for (var i = 0; i < message.length; i++) {
      var state = message[i];

      // If this is the first time we see this entity, create a local representation.
      if (!this.entities[state.entity_id]) {
        var entity = new Entity();
        entity.entity_id = state.entity_id;
        this.entities[state.entity_id] = entity;
      }

      var entity = this.entities[state.entity_id];

      if (state.entity_id == this.entity_id) {
        // Received the authoritative position of this client's entity.
        entity.x = state.position;

        if (this.server_reconciliation) {
          // Server Reconciliation. Re-apply all the inputs not yet processed by
          // the server.
          var j = 0;
          while (j < this.pending_inputs.length) {
            var input = this.pending_inputs[j];
            if (input.input_sequence_number <= state.last_processed_input) {
              // Already processed. Its effect is already taken into account into the world update
              // we just got, so we can drop it.
              this.pending_inputs.splice(j, 1);
            } else {
              // Not processed by the server yet. Re-apply it.
              entity.applyInput(input);
              j++;
            }
          }
        } else {
          // Reconciliation is disabled, so drop all the saved inputs.
          this.pending_inputs = [];
        }
      } else {
        // Received the position of an entity other than this client's.
   
        if (!this.entity_interpolation) {
          // Entity interpolation is disabled - just accept the server's position.
          entity.x = state.position;
        } else {
          // Add it to the position buffer.
          var timestamp = +new Date();
          entity.position_buffer.push([timestamp, state.position]);
        }
      }
    }
  }
}


Client.prototype.interpolateEntities = function() {
  // Compute render timestamp.
  var now = +new Date(); 
  var render_timestamp = now - (1000.0 / server.update_rate);

  for (var i in this.entities) { 
    var entity = this.entities[i];

    // No point in interpolating this client's entity.
    if (entity.entity_id == this.entity_id) {
      continue;
    }

    // Find the two authoritative positions surrounding the rendering timestamp.
    var buffer = entity.position_buffer;
  
    // Drop older positions.
    while (buffer.length >= 2 && buffer[1][0] <= render_timestamp) {
      buffer.shift();
    }

    // Interpolate between the two surrounding authoritative positions.
    if (buffer.length >= 2 && buffer[0][0] <= render_timestamp && render_timestamp <= buffer[1][0]) {
      var x0 = buffer[0][1];
      var x1 = buffer[1][1];
      var t0 = buffer[0][0];
      var t1 = buffer[1][0];

      entity.x = x0 + (x1 - x0) * (render_timestamp - t0) / (t1 - t0);
    }
  }
}


// =============================================================================
//  The Server.
// =============================================================================
var Server = function(canvas, status) {
  // Connected clients and their entities.
  this.clients = [];
  this.entities = [];

  // Last processed input for each client.
  this.last_processed_input = [];

  // Simulated network connection.
  this.network = new LagNetwork();

  // UI.
  this.canvas = canvas;
  this.status = status;

  // Default update rate.
  this.setUpdateRate(10);
}

Server.prototype.connect = function(client) {
  // Give the Client enough data to identify itself.
  client.server = this;
  client.entity_id = this.clients.length;
  this.clients.push(client);

  // Create a new Entity for this Client.
  var entity = new Entity();
  this.entities.push(entity);
  entity.entity_id = client.entity_id;

  // Set the initial state of the Entity (e.g. spawn point)
  var spawn_points = [4, 6];
  entity.x = spawn_points[client.entity_id];
}

Server.prototype.setUpdateRate = function(hz) {
  this.update_rate = hz;

  clearInterval(this.update_interval);
  this.update_interval = setInterval(
    (function(self) { return function() { self.update(); }; })(this),
    1000 / this.update_rate);
}

Server.prototype.update = function() {
  this.processInputs();
  this.sendWorldState();
  renderWorld(this.canvas, this.entities);
}


// Check whether this input seems to be valid (e.g. "make sense" according
// to the physical rules of the World)
Server.prototype.validateInput = function(input) {
  if (Math.abs(input.press_time) > 1/40) {
    return false;
  }
  return true;
}


Server.prototype.processInputs = function() {
  // Process all pending messages from clients.
  while (true) {
    var message = this.network.receive();
    if (!message) {
      break;
    }

    // Update the state of the entity, based on its input.
    // We just ignore inputs that don't look valid; this is what prevents clients from cheating.
    if (this.validateInput(message)) {
      var id = message.entity_id;
      this.entities[id].applyInput(message);
      this.last_processed_input[id] = message.input_sequence_number;
    }

  }

  // Show some info.
  var info = "Last acknowledged input: ";
  for (var i = 0; i < this.clients.length; ++i) {
    info += "Player " + i + ": #" + (this.last_processed_input[i] || 0) + "   ";
  }
  this.status.textContent = info;
}


// Send the world state to all the connected clients.
Server.prototype.sendWorldState = function() {
  // Gather the state of the world. In a real app, state could be filtered to avoid leaking data
  // (e.g. position of invisible enemies).
  var world_state = [];
  var num_clients = this.clients.length;
  for (var i = 0; i < num_clients; i++) {
    var entity = this.entities[i];
    world_state.push({entity_id: entity.entity_id,
                      position: entity.x,
                      last_processed_input: this.last_processed_input[i]});
  }

  // Broadcast the state to all the clients.
  for (var i = 0; i < num_clients; i++) {
    var client = this.clients[i];
    client.network.send(client.lag, world_state);
  }
}


// =============================================================================
//  Helpers.
// =============================================================================

// Render all the entities in the given canvas.
var renderWorld = function(canvas, entities) {
  // Clear the canvas.
  canvas.width = canvas.width;

  var colours = ["blue", "red"];

  for (var i in entities) { 
    var entity = entities[i];

    // Compute size and position.
    var radius = canvas.height*0.9/2;
    var x = (entity.x / 10.0)*canvas.width;

    // Draw the entity.
    var ctx = canvas.getContext("2d");
    ctx.beginPath();
    ctx.arc(x, canvas.height / 2, radius, 0, 2*Math.PI, false);
    ctx.fillStyle = colours[entity.entity_id];
    ctx.fill();
    ctx.lineWidth = 5;
    ctx.strokeStyle = "dark" + colours[entity.entity_id];
    ctx.stroke();
  }
}


var element = function(id) {
  return document.getElementById(id);
}

// =============================================================================
//  Get everything up and running.
// =============================================================================

// World update rate of the Server.
var server_fps = 4;


// Update simulation parameters from UI.
var updateParameters = function() {
  updatePlayerParameters(player1, "player1");
  updatePlayerParameters(player2, "player2");
  server.setUpdateRate(updateNumberFromUI(server.update_rate, "server_fps"));
  return true;
}


var updatePlayerParameters = function(client, prefix) {
  client.lag = updateNumberFromUI(player1.lag, prefix + "_lag");

  var cb_prediction = element(prefix + "_prediction");
  var cb_reconciliation = element(prefix + "_reconciliation");

  // Client Side Prediction disabled => disable Server Reconciliation.
  if (client.client_side_prediction && !cb_prediction.checked) {
    cb_reconciliation.checked = false;
  }

  // Server Reconciliation enabled => enable Client Side Prediction.
  if (!client.server_reconciliation && cb_reconciliation.checked) {
    cb_prediction.checked = true;
  }

  client.client_side_prediction = cb_prediction.checked;
  client.server_reconciliation = cb_reconciliation.checked;

  client.entity_interpolation = element(prefix + "_interpolation").checked;
}


var updateNumberFromUI = function(old_value, element_id) {
  var input = element(element_id);
  var new_value = parseInt(input.value);
  if (isNaN(new_value)) {
    new_value = old_value;
  }
  input.value = new_value;
  return new_value;
}


// When the player presses the arrow keys, set the corresponding flag in the client.
var keyHandler = function(e) {
  e = e || window.event;
  if (e.keyCode == 39) {
    player1.key_right = (e.type == "keydown");
  } else if (e.keyCode == 37) {
    player1.key_left = (e.type == "keydown");
  } else if (e.key == 'd') { 
    player2.key_right = (e.type == "keydown");
  } else if (e.key == 'a') {
    player2.key_left = (e.type == "keydown");
  } else {
    console.log(e)
  }
}
document.body.onkeydown = keyHandler;
document.body.onkeyup = keyHandler;


// Setup a server, the player's client, and another player.
var server = new Server(element("server_canvas"), element("server_status"));
var player1 = new Client(element("player1_canvas"), element("player1_status"));
var player2 = new Client(element("player2_canvas"), element("player2_status"));


// Connect the clients to the server.
server.connect(player1);
server.connect(player2);


// Read initial parameters from the UI.
updateParameters();

</script>

</div>
<script>$("#signup").load("signup.php?m=gamedev&r=fpm_live");</script>
<hr>
<div class="social">

<!-- Facebook -->
<div id="fb-root"></div>
<script>(function(d, s, id) {
  var js, fjs = d.getElementsByTagName(s)[0];
  if (d.getElementById(id)) return;
  js = d.createElement(s); js.id = id;
  js.src = "//connect.facebook.net/en_US/all.js#xfbml=1";
  fjs.parentNode.insertBefore(js, fjs);
}(document, 'script', 'facebook-jssdk'));</script>

<div class="fb-like" data-send="false" data-layout="button_count" data-width="450" data-show-faces="false" style="width: 150px;"></div>


<!-- Google+ -->
<div class="g-plusone"></div>
<script type="text/javascript">
  (function() {
    var po = document.createElement('script'); po.type = 'text/javascript'; po.async = true;
    po.src = 'https://apis.google.com/js/plusone.js';
    var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(po, s);
  })();
</script>


<!-- Twitter -->
<a href="https://twitter.com/share" class="twitter-share-button" data-via="gabrielgambetta">Tweet</a>
<script>!function(d,s,id){var js,fjs=d.getElementsByTagName(s)[0],p=/^http:/.test(d.location)?'http':'https';if(!d.getElementById(id)){js=d.createElement(s);js.id=id;js.src=p+'://platform.twitter.com/widgets.js';fjs.parentNode.insertBefore(js,fjs);}}(document, 'script', 'twitter-wjs');</script>

</div>
</div>
<div class="copyright">&copy; Gabriel Gambetta 2017</div>
</body>
</html>

猜你喜欢

转载自blog.csdn.net/qq_25327609/article/details/88898088