This tutorial demonstrates how QSM can be used in combination with the XNA framework to create a simple multiplayer game.
Update: a subset of the most recent QSM API is available online
Contents:
This tutorial assumes you've installed the following:
You may also later find it useful to have at your disposal Debugging Tools for Windows, Process Explorer, PsTools, IronPython, and Microsoft Visual Studio 2005 SDK.
We will also be using the latest version QSM, available from the QSM project webpage (the latest version as of writing this tutorial was 0.12). QSM doesn't require installation, it comes in the form of a set of DLLs that are referenced from the applications using it, and a minimal set of documentation, including the user's guide that the reader should consult for a more detailed introduction to QSM.
In this section, we create a skeleton of a game client that will run on a user's
machine.
This section includes just the basics. You may find it useful to go over the
three examples at MSDN or watch the
video tutorials.
First, launch Visual Studio and create a new project.
In this tutorial, we're not using the XNA Game Studio, so the XNA project types are not available. Instead, we create a Windows Application, and we'll customize it to our needs.
Now, the very first thing to do is to add the references to the XNA Framework assemblies, so that they're available within our project.
We will need Microsoft.Xna.Framework, Microsoft.Xna.Framework.Content.Pipeline, and Microsoft.Xna.Framework.Game.
Now, select the automatically generated form that represents the main application window and switch to the code view.
We will create a separate thread in which the game will run; add the lines highlighted in blue to the existing source.
using System.Threading; namespace MyGameClient { public partial class Form1 : Form { public Form1() { InitializeComponent(); gamethread = new Thread(new ThreadStart(ThreadCallback)); gamethread.Name = "Game Thread"; gamethread.Start(); } private void ClosingCallback(object sender, FormClosingEventArgs e) { try { if (!gamethread.Join(100)) gamethread.Abort(); } catch (Exception) { } } private Thread gamethread; private void ThreadCallback() { } } }
Now, switch back to the form design view, and open the form's Properties window.
Switch to the Events tab (use the lightning button), choose the FormClosing events, and select "ClosingCallback" in the editing area. This will associate a callback we wrote earlier with the closing event, thus allowing us to smoothly clean our resources on exit. Clean resource cleanup is essential for effective debugging if you don't want to deal with lingering processes that have to be manually terminated from the Task Manager each time something goes wrong.
Now, we're ready to create a skeleton of a game. Add a new class using the wizard.
Now, import the XNA assemblies, inherit the new class from Microsoft.Xna.Framework.Game, and add the basic code shown below. You may refer to the XNA documentation and the tutorials on MSDN for the details. Basically, Initialize does the initial setup, Update is called periodically to make the game progress, and Draw renders the content on demand. We will fill these methods with more details as we go.
using System; using System.Collections.Generic; using System.Text; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Audio; using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Input; using Microsoft.Xna.Framework.Storage; namespace MyGameClient { public class MyGame : Microsoft.Xna.Framework.Game { public MyGame() { graphics = new GraphicsDeviceManager(this); content = new ContentManager(Services); } private GraphicsDeviceManager graphics; private ContentManager content; protected override void Initialize() { base.Initialize(); } protected override void LoadGraphicsContent(bool loadAllContent) { } protected override void UnloadGraphicsContent(bool unloadAllContent) { if (unloadAllContent) content.Unload(); } protected override void Update(GameTime gameTime) { base.Update(gameTime); } protected override void Draw(GameTime gameTime) { graphics.GraphicsDevice.Clear(Color.CornflowerBlue); base.Draw(gameTime); } } }
Now, we can go back to the form code view, and add the code to launch the game and smoothly handle its termination.
private Thread gamethread; private MyGame game; private void ThreadCallback() { game = new MyGame(); game.Exiting += new EventHandler(ExitingCallback); game.Run(); } private void ExitingCallback(object sender, EventArgs e) { ThreadPool.QueueUserWorkItem( new WaitCallback(delegate(object _o) { try { Close(); } catch (Exception) { BeginInvoke(new EventHandler(delegate(object o, EventArgs a) { Close(); })); } })); }
At this point, you can compile and run your client application. You should see an empty Form and a game window filled in blue. Closing either of the two should smoothly terminate the application.
At this point, you could extend the game client to create animations, as in the tutorials, although without the benefits of the XNA Game Studio, loading content such as textures etc. is slightly more complicated (we'll talk about it later).
Now, we'll switch to implementing a game server that will manage user registrations.
The game server will store information about the active gaming session and about the users.
Create another project of type Windows Application in the same solution.
Our server will be a web service. We'll use Windows Communication Foundation to streamline the development. The necessary web interfaces will be shared by the server and the client. To this end, we create a yet another project, this time of type C# Class Library, still in the same solution, and reference it from both the client and the server projects.
To use Windows Communication Foundation, each of the three projects in the solution will also need to reference System.ServiceModel, where the WCF namespaces reside.
Now, create a new interface in the class library, and will it with code as follows.
using System; using System.Collections.Generic; using System.Text; using System.ServiceModel; namespace MyGameLib { [ServiceContract] public interface IMyGameServer { [OperationContract] void JoinTheGame(string myname, byte[] mypicture, out uint myid, out uint groupid); [OperationContract] void GetPlayerInfo(uint playerid, out string playername, out byte[] playerpicture); } }
Method JoinTheGame will be used for new clients to provide informaiton to the server, while GetPlayerInfo will be used to access information about other players. We'll use these methods shortly.
Now, go back to the server project, switch to the form's code view, and add the following code to add the necessary plumbing, register the server as a web service and provide a smooth cleanup. You will need to associate the ClosingCallback we defined here with the form's closing event in the same way you did this in the client application.
using System.Net; using System.ServiceModel; namespace MyGameServer { [ServiceBehavior(InstanceContextMode = InstanceContextMode.Single)] public partial class Form1 : Form, MyGameLib.IMyGameServer { public Form1() { InitializeComponent(); servicehost = new ServiceHost(this); servicehost.AddServiceEndpoint(typeof(MyGameLib.IMyGameServer), new WSHttpBinding(), "http://" + Dns.GetHostName() + ":60000"); servicehost.Open(); } private void ClosingCallback(object sender, FormClosingEventArgs e) { try { servicehost.Close(); } catch (Exception) { } } private ServiceHost servicehost; void MyGameLib.IMyGameServer.JoinTheGame(string myname, byte[] mypicture, out uint myid, out uint groupid) { throw new NotImplementedException(); } void MyGameLib.IMyGameServer.GetPlayerInfo(uint playerid, out string playername, out byte[] playerpicture) { throw new NotImplementedException(); } } }
At this point, you should be able to compile, run, and smoothly shutdown the server. Now we're going to add the code for managing user accounts.
First, le'ts define a class that will hold this information. Use the wizard to create a new class for user accounts, as shown below. We will need to store user's name, picture given as a sequence of bytes, and a numeric identifier that will be used in the network communication between clients as a unique user identifier.
namespace MyGameServer
{
public class PlayerInfo
{
public PlayerInfo(uint id, string name, byte[] picture)
{
this.id = id;
this.name = name;
this.picture = picture;
}
public uint id;
public string name;
public byte[] picture;
}
}
Now, let's fix the server code to implement the required functionality.
private ServiceHost servicehost; private uint lastplayerid; private IDictionary<uint, PlayerInfo> players = new Dictionary<uint, PlayerInfo>(); void MyGameLib.IMyGameServer.JoinTheGame(string myname, byte[] mypicture, out uint myid, out uint groupid) { lock (this) { myid = ++lastplayerid; PlayerInfo playerinfo = new PlayerInfo(myid, myname, mypicture); players.Add(myid, playerinfo); groupid = 1000; } } void MyGameLib.IMyGameServer.GetPlayerInfo(uint playerid, out string playername, out byte[] playerpicture) { lock (this) { PlayerInfo playerinfo = players[playerid]; playername = playerinfo.name; playerpicture = playerinfo.picture; } }
To visualize the process of adding new users, we'll add a crude user's interface. Switch to the server's form view, add a listbox, then go to the Properties window, and configure it to fill the entire form area and rely on the user to render its contents. We will use the listbox to display user images, which we'll draw ourselves. Also, set the ItemHeight property of the list box to 120; we'll need space for the images.
Now, go back to the server form's code view, add a single line of code that will fill the list with user names and images and a method that will render user images in the list box.
void MyGameLib.IMyGameServer.JoinTheGame(string myname, byte[] mypicture, out uint myid, out uint groupid) { lock (this) { myid = ++lastplayerid; PlayerInfo playerinfo = new PlayerInfo(myid, myname, mypicture); players.Add(myid, playerinfo); groupid = 1000; listBox1.Items.Add(playerinfo); } } private void DrawingCallback(object sender, DrawItemEventArgs e) { e.DrawBackground(); if (e.Index >= 0) { PlayerInfo playerinfo = (PlayerInfo) listBox1.Items[e.Index]; e.Graphics.DrawString(playerinfo.name, new Font(FontFamily.GenericSansSerif, 10), Brushes.Black, new PointF(e.Bounds.X, e.Bounds.Y)); float size = Math.Min(e.Bounds.Width, e.Bounds.Height - 20); e.Graphics.DrawImage(Image.FromStream(new MemoryStream(playerinfo.picture)), new RectangleF(new PointF(e.Bounds.X, e.Bounds.Y + 20), new SizeF(size, size))); } }
You will need to import System.IO. You will also need to associate list box's DrawItem event with the DrawingCallback we defined here in the same way we previously associated callbacks with the form closing events.
Now, to test the registration process, we switch back to the client, and add the code that will carry on registration with the server.
We'll have the user provide the name and image, and the address of the server, from the command line (you can be more fancy; here we just demonstrate the basic mechanics to get you started). Switch to the Program.cs source file in the client project and extend it to carry over some command-line arguments to the client form's constructor.
namespace MyGameClient { static class Program { [STAThread] static void Main(string[] args) { Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); Application.Run(new Form1(args[0], args[1], args[2])); } } }
Now, add the highlighted code to connect to the server, register and retrieve the user and channel identifiers that we'll use for network communication.
using System.Threading; using System.IO; using System.ServiceModel; namespace MyGameClient { public partial class Form1 : Form { public Form1(string playername, string playerpicture, string serveraddress) { this.playername = playername; this.playerpicture = playerpicture; this.serveraddress = serveraddress; (...) } private void ClosingCallback(object sender, FormClosingEventArgs e) { try { game.Exit(); if (!gamethread.Join(100)) gamethread.Abort(); servicefactory.Close(); } catch (Exception) { } } private Thread gamethread; private MyGame game; private string playername, playerpicture, serveraddress; private byte[] picturebytes; private ChannelFactory<MyGameLib.IMyGameServer> servicefactory; private MyGameLib.IMyGameServer gameserver; private uint myid, groupid; private void ThreadCallback() { using (FileStream picturefile = new FileStream(playerpicture, FileMode.Open)) { picturebytes = new byte[picturefile.Length]; picturefile.Read(picturebytes, 0, picturebytes.Length); } servicefactory = new ChannelFactory<MyGameLib.IMyGameServer>(new WSHttpBinding(), new EndpointAddress("http://" + serveraddress + ":60000")); gameserver = servicefactory.CreateChannel(); gameserver.JoinTheGame(playername, picturebytes, out myid, out groupid); game = new MyGame(); game.Exiting += new EventHandler(ExitingCallback); game.Run(); } (...) } }
At this point, you can compile and run the server and the client, as shown below. The last parameter to the client invocation should specify the IP address of the server. Make sure that the firewall allows the traffic through. You may need to enable TPC port 60000. If you're running Windows Vista, you may need to run the server from an elevated command prompt (started using the "Run As Administrator" option), otherwise Vista won't allow the game server to register itself as an HTTP server.
The next step is to add some animation...
First, let's pass the information obtained from the server to the game object: open the client form's code view and modify ThreadCallback as shown below.
private void ThreadCallback()
{
(...)
game = new MyGame(myid, playername, picturebytes, gameserver);
game.Exiting += new EventHandler(ExitingCallback);
game.Run();
}
Now, the information needs to be stored: we modify the game class accordingly.
public MyGame(uint myid, string myname, byte[] mypicture, MyGameLib.IMyGameServer gameserver) { this.myid = myid; this.myname = myname; this.mypicture = mypicture; this.gameserver = gameserver; graphics = new GraphicsDeviceManager(this); content = new ContentManager(Services); } private GraphicsDeviceManager graphics; private ContentManager content; private uint myid; private string myname; private byte[] mypicture; private MyGameLib.IMyGameServer gameserver;
Before we can use the data in the game, we create a structure to represent a player. We'll use the same structure both for the local player and for the other players that we'll learn about from the server. Create a new class to represent a player and fill it with code as shown below. The position field holds the last known player position on the screen, the sprite field holds a reference to the moving object, and texture holds the player's picture in the form of a texture usable by the XNA framework.
using System; using System.Collections.Generic; using System.Text; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Audio; using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Input; using Microsoft.Xna.Framework.Storage; namespace MyGameClient { class Player { public Player(uint id, string name, Vector2 position, SpriteBatch sprite, Texture2D texture) { this.id = id; this.name = name; this.position = position; this.sprite = sprite; this.texture = texture; } public uint id; public string name; public Vector2 position; public SpriteBatch sprite; public Texture2D texture; } }
Finally, we're ready to add code that will create the player object and render it on the screen.
private Player myplayer; private IDictionary<uint, Player> players = new Dictionary<uint, Player>(); private Player CreatePlayer(uint id, string name, byte[] picture, Vector2 position) { Player player = new Player(id, name, position, new SpriteBatch(graphics.GraphicsDevice), Texture2D.FromFile(graphics.GraphicsDevice, new System.IO.MemoryStream(picture))); players.Add(id, player); return player; } (...) protected override void LoadGraphicsContent(bool loadAllContent) { if (loadAllContent) myplayer = CreatePlayer(myid, myname, mypicture, Vector2.Zero); } (...) protected override void Draw(GameTime gameTime) { graphics.GraphicsDevice.Clear(Color.CornflowerBlue); foreach (Player player in players.Values) { player.sprite.Begin(SpriteBlendMode.AlphaBlend); player.sprite.Draw(player.texture, player.position, Color.White); player.sprite.End(); } base.Draw(gameTime); }
At this point, you can compile and run the client. The user's picture should be visible in the upper left corner.
Finally, it's time to give it some motion. Modify the Update method as showm below to implement the sprite movement.
protected override void Update(GameTime gameTime)
{
lock (this)
{
float elapsed = (float)gameTime.ElapsedGameTime.TotalSeconds;
Vector2 oldposition = myplayer.position;
foreach (Keys k in Keyboard.GetState().GetPressedKeys())
{
if (k == Keys.Left)
myplayer.position += elapsed * new Vector2(-50.0f, 0.0f);
else if (k == Keys.Right)
myplayer.position += elapsed * new Vector2(+50.0f, 0.0f);
else if (k == Keys.Up)
myplayer.position += elapsed * new Vector2(0.0f, -50.0f);
else if (k == Keys.Down)
myplayer.position += elapsed * new Vector2(0.0f, +50.0f);
}
myplayer.position.X = Math.Min(Math.Max(myplayer.position.X, 0), graphics.GraphicsDevice.Viewport.Width - myplayer.texture.Width);
myplayer.position.Y = Math.Min(Math.Max(myplayer.position.Y, 0), graphics.GraphicsDevice.Viewport.Height - myplayer.texture.Height);
if (!myplayer.position.Equals(oldposition))
{
// here we'll write code that notifies other players of our movements...
}
}
base.Update(gameTime);
}
If you run the server and the client now, you will be able to move your picture using the arrow keys on your keyboard.
The final step of our tutorial will be to implement communication between the players.
First, unpack the QuickSilver distribution and locate the four DLLs named QuickSilver_0, QuickSilver_1, QuickSilver_2, and QuickSilver_3. You will need to add references to the last three of these in both the client and the server in order for the client and server to be able to use QSM.
Adding these files as references will also cause them to be copied over to the destination folder with your binaries, such as bin\Debug. QuickSilver_0 cannot be added as a reference, and will need to be copied there manually, or by adding a custom build step that performs this task, as shown below. The four DLLs have to always be deployed together.
Now, we add code to initialize and cleanup a local instance of QSM that will live on the game server. This instance of QSM will act as a membership service that controls the multicast protocols run among the game clients. Note that in the code below, the form takes an argument subnet; the file Program.cs needs to be modified accordingly for the argument to be passed from the command line, in the same way we did this for the client.
public Form1(string subnet) { InitializeComponent(); QS.CMS.Framework2.ClientConfiguration configuration = new QS.CMS.Framework2.ClientConfiguration(subnet, null); client = QS.CMS.Framework2.Client.Create(configuration); servicehost = new ServiceHost(this); servicehost.AddServiceEndpoint(typeof(MyGameLib.IMyGameServer), new WSHttpBinding(), "http://" + Dns.GetHostName() + ":60000"); servicehost.Open(); } private void ClosingCallback(object sender, FormClosingEventArgs e) { try { client.Dispose(); servicehost.Close(); } catch (Exception) { } } private QS.CMS.Framework2.IClient client;
QSM is normally instantiated by calling QS.CMS.Framework2.Client.Create, passing an object of type QS.CMS.Framework2.ClientConfiguration that holds the custom configuration parameters. Configuration can be adjusted by setting public properties of the latter object. In this example, we use a constructor with the following signature.
public ClientConfiguration ( string local_subnet, string gms_address );
The first argument to the constructor represents the range of addresses that will be used by QSM to select the specific network adapter to be used for communication, given in the form such as 192.168.x.x, with "x" representing the wildcard, or in the form 192.168.0.0/16, using the mask length, or as 192.168.0.0/255.255.0.0, with an explicit mask. This parameter can also be set using the Subnet property.
The second argument represents the address of the GMS server. If the address is NULL, the GMS server will be hosted locally. Since this is an instance of QSM that will run on the game server, and the purpose of which is precisely to serve as the GMS, we leave the GMS address NULL. This parameter can also be set using the GMSAddress property.
Among the useful configuration parameters are those used for logging:
QS.CMS.Framework2.ClientConfiguration configuration = new QS.CMS.Framework2.ClientConfiguration(subnet, null);
configuration.Verbose = true;
configuration.LogTo = new QS.HMS.Components.FileLogger("gameserverlog.txt");
client = QS.CMS.Framework2.Client.Create(configuration);
The setup on the client looks similarly, see below.
public Form1(string playername, string playerpicture, string serveraddress, string subnet) { this.playername = playername; this.playerpicture = playerpicture; this.serveraddress = serveraddress; this.subnet = subnet; (...) } private void ClosingCallback(object sender, FormClosingEventArgs e) { try { game.Exit(); if (!gamethread.Join(100)) gamethread.Abort(); client.Dispose(); servicefactory.Close(); } catch (Exception) { } } (...) private string playername, playerpicture, serveraddress, subnet; private QS.CMS.Framework2.IClient client; (...) private void ThreadCallback() { (...) QS.CMS.Framework2.ClientConfiguration configuration = new QS.CMS.Framework2.ClientConfiguration(subnet, 10000, serveraddress); client = QS.CMS.Framework2.Client.Create(configuration); game = new MyGame(myid, playername, picturebytes, gameserver); game.Exiting += new EventHandler(ExitingCallback); game.Run(); }
This time, we use an overloaded ClientConfiguration constructor that lets us specify the local port (10000) that the clients will use for client-to-client communication. By making this port different from the default (65000), we allow the game server (which doesn't set this parameter, and thus uses the default port number) to be run on the same machine as one of the clients, thus making it easier to test and debug the game.
Unfortunately, it is not possible to run multiple clients on the same physical machine be cause of the limitations of IP multicast: achieving high performance requires QSM to disable IP multicast loopback to prevent receiving its own data, which makes instances of QSM running on the same physical machine unable to receive each other's messages.
Now, with all plumbing in place, we can finally get the clients to communicate. First, we define a class to represent client-to-client messages. Create a new class and fill it with code as shown below.
using System;
using System.Collections.Generic;
using System.Text;
namespace MyGameClient
{
[QS.Fx.Serialization.ClassID(Message.ClassID)] public
class Message : QS.Fx.Serialization.ISerializable
{
public const ushort ClassID = (ushort)(QS.ClassID.UserMin + 100);
public Message()
{
}
public Message(uint playerid, float x, float y)
{
this.playerid = playerid;
this.x = x;
this.y = y;
}
public uint playerid;
public float x, y;
unsafe QS.Fx.Serialization.SerializableInfo QS.Fx.Serialization.ISerializable.SerializableInfo
{ get { return new QS.Fx.Serialization.SerializableInfo(ClassID, sizeof(uint) +
2 * sizeof(float)); } } unsafe void QS.Fx.Serialization.ISerializable.SerializeTo(ref QS.Fx.Base.ConsumableBlock header, ref IList<QS.Fx.Base.Block> data)
{
fixed (byte* arrayptr = header.Array)
{
byte* headerptr = arrayptr + header.Offset;
*((uint*)headerptr) = playerid;
*((float*)(headerptr + sizeof(uint))) = x;
*((float*)(headerptr + sizeof(uint) + sizeof(float))) = y;
}
header.consume(sizeof(uint) + 2 * sizeof(float));
}
unsafe void QS.Fx.Serialization.ISerializable.DeserializeFrom(ref QS.Fx.Base.ConsumableBlock header, ref QS.Fx.Base.ConsumableBlock data)
{
fixed (byte* arrayptr = header.Array)
{
byte* headerptr = arrayptr + header.Offset;
playerid = *((uint*)headerptr);
x = *((float*)(headerptr + sizeof(uint)));
y = *((float*)(headerptr + sizeof(uint) + sizeof(float)));
}
header.consume(sizeof(uint) + 2 * sizeof(float));
}
}
}
In our simple prototype, messages will carry two pieces of information: the numeric identifier of the player (playerid), and the player's current position (x and y).
To be able to send this data over the network, we need to add serialization and deserialization code that will conver between the objects of this class and flat byte arrays. We won't use the default .NET serialization, however, because it is slow and space-inefficient. Instead, we'll use the QSM's own built-in serialization scheme.
For the purposes of this tutorial, we won't go into the details of the serialization process; some of the aspect of it are described in the QSM user's guide.
Roughly, the process involves the following (consult this with the code snippet above).
First, each serializable class must have a unique identifier. Free identifiers start from QS.ClassID.UserMin. You assign an identifier to your class by annotating it with attribute QS.Fx.Serialization.ClassID, with the unique class identifier given as the argument. Each serializable class must also have a default no-argument constructor and must implement interface QS.Fx.Serialization.ISerializable, including a property SerializableInfo and methods SerializeTo and DeserializeFrom. The read-only property SerializableInfo returns the unique class identifier and information about the size of the serialized structures. The methods SerializeTo and DeserializeFrom perform the actual serialization and deserialization.
Serialization involves copying small amounts of information to a preallocated header and/or appending addresses of large contiguous blocks of bytes to a list of memory buffers. The former involves copying memory, but is often more convenient. The latter can be used to serialize large amounts of data, such as strings, files, or arrays, efficiently and without additional copying. In this example, messages contain just a few numeric fields, hence we only use the former method.
In the serialization method, we used the fixed construct to pin the address of the array holding the header, obtain the actual address of the header by adding the header offset, and copy the contents of the message directly to the memory of the header. After the copy, we call the consume method to adjust the ConsumableBlock structure representing the header, to reflect the fact that a certain number of bytes has been written to it and hence both the offset and the amount of available space in the header have changed. The deserialization method is symmetric.
Copying memory directly requires the use of the unsafe statement, which by default is not permitted. To enable it, we must adjust the project settings.
The same serialization code can be written equivalently, although less efficiently, using helper functions in the QS.CMS.Base3.SerializationHelper.
unsafe QS.Fx.Serialization.SerializableInfo QS.Fx.Serialization.ISerializable.SerializableInfo
{ get { QS.Fx.Serialization.SerializableInfo info = new QS.Fx.Serialization.SerializableInfo(ClassID);
QS.CMS.Base3.SerializationHelper.ExtendSerializableInfo_UInt32(ref info);
QS.CMS.Base3.SerializationHelper.ExtendSerializableInfo_Float(ref info);
QS.CMS.Base3.SerializationHelper.ExtendSerializableInfo_Float(ref info);
return info;
}
}
unsafe void QS.Fx.Serialization.ISerializable.SerializeTo(ref QS.Fx.Base.ConsumableBlock header, ref IList data)
{
QS.CMS.Base3.SerializationHelper.Serialize_UInt32(ref header, ref data, playerid);
QS.CMS.Base3.SerializationHelper.Serialize_Float(ref header, ref data, x);
QS.CMS.Base3.SerializationHelper.Serialize_Float(ref header, ref data, y);
}
unsafe void QS.Fx.Serialization.ISerializable.DeserializeFrom(ref QS.Fx.Base.ConsumableBlock header, ref QS.Fx.Base.ConsumableBlock data)
{
playerid = QS.CMS.Base3.SerializationHelper.Deserialize_UInt32(ref header, ref data);
x = QS.CMS.Base3.SerializationHelper.Deserialize_Float(ref header, ref data);
y = QS.CMS.Base3.SerializationHelper.Deserialize_Float(ref header, ref data);
}
Before we can send anything, the clients must first join the multicast group. This can be achieved by calling the Open method on the QSM's "client" object. We pass the group reference obtained this way to the game object.
private void ClosingCallback(object sender, FormClosingEventArgs e) { try { game.Exit(); if (!gamethread.Join(100)) gamethread.Abort(); group.Dispose(); client.Dispose(); servicefactory.Close(); } catch (Exception) { } } (...) private QS.CMS.Framework2.IClient client; private QS.CMS.Framework2.IGroup group; private void ThreadCallback() { (...) QS.CMS.Framework2.ClientConfiguration configuration = new QS.CMS.Framework2.ClientConfiguration(subnet, 10000, serveraddress); client = QS.CMS.Framework2.Client.Create(configuration); group = client.Open(new QS.CMS.Base3.GroupID(groupid), QS.CMS.Framework2.GroupOptions.FastCallbacks); game = new MyGame(myid, playername, picturebytes, gameserver, group); game.Exiting += new EventHandler(ExitingCallback); game.Run(); }
In the game object, we store the received group reference...
public MyGame(uint myid, string myname, byte[] mypicture, MyGameLib.IMyGameServer gameserver, QS.CMS.Framework2.IGroup group) { this.group = group;(...) } private QS.CMS.Framework2.IGroup group; (...)
...and we use it to multicast our whereabouts to other players.
protected override void Update(GameTime gameTime) { lock (this) { (...) if (!myplayer.position.Equals(oldposition)) { group.ScheduleSend(new QS.CMS.Framework2.OutgoingCallback(SendCallback)); } } base.Update(gameTime); } private void SendCallback(QS.CMS.Framework2.IChannel channel, uint maxsend, out bool hasmore) { channel.Send(new Message(myid, myplayer.position.X, myplayer.position.Y)); hasmore = false; }
The way we have implemented sending here is by registering only the intent to send with ScheduleSend rather than actually constructing a message. QSM invokes the supplied callback at a time convenient to it, and at that time, an unbuffered, efficient channel is provided, through which we can send serializable objects by invoking the Send call. The parameter hasmore must always be reset if no more objects are available for sending at this time.
The sending method we used corresponds to the "pull" model, where data to send is constructed by the application on demand, upon callback from the communication subsystem. A simpler, if less efficient way to send is by using the traditional "push" interface, where data to transmit is created right away, and buffered until the time of transmission. The alternative implementation is shown below. In this scheme, the callback isn't needed.
if (!myplayer.position.Equals(oldposition))
{
group.BufferedChannel.Send(new Message(myid, myplayer.position.X, myplayer.position.Y));
}
Finally, we need to register for messages that might arrive from other players. To this purpose, we associate a receive callback with the group object. Inside of the receive callback, we update our local record of the player's position. If this is the first time we have heard from this player, we first consult the gaming server to obtain the player's information.
protected override void Initialize() { group.OnReceive += new QS.CMS.Framework2.IncomingCallback(ReceiveCallback); base.Initialize(); } private void ReceiveCallback(QS.CMS.Base3.InstanceID sender, QS.Fx.Serialization.ISerializable message) { Message m = (Message)message; if (m.playerid != myid) { lock (this) { Player player; if (players.TryGetValue(m.playerid, out player)) player.position = new Vector2(m.x, m.y); else { string playername; byte[] playerpicture; gameserver.GetPlayerInfo(m.playerid, out playername, out playerpicture); player = CreatePlayer(m.playerid, playername, playerpicture, new Vector2(m.x, m.y)); players.Add(player.id, player); } } } }
At this point, you should be able to compile the solution, and run the server and two clients on separate machines (the server can be colocated with one of the clients, but the clients can't be colocated). Each of the playes should be able to move its picture around the screen independently. As it moves, the other players should see its movements on their client consoles.
The client consoles can be easily extended to show the list of players, just as it was the case for the game server.
This concludes our simple example. Of course, the interface is crude, and the functionality is incomplete; for example, we don't see the other player's movements unless the other player actually moves. We leave completing this example up to the reader. All the missing functionality can be implemented in a fairly straightforward manner by leveraging the game server.
(coming soon)