Chapter 17. Multiplayer Game Engine

This final chapter draws on all of the knowledge of the past 16 chapters to build a multiplayer game engine suitable for small arcade-style games. There are many books on the market covering advanced graphics engine programming; the focus here is on gameplay, and especially gameplay via system link and online with the Xbox Live network. This “little engine that could” is barely a layer wrapped around the usual XNA code, but it is enough to simplify the code a bit and provide reusable classes and methods that streamline the whole process of coding a new game. Overall, this chapter covers these topics:

  • Integrating the engine

  • Font support

  • Engine source code

  • Building the Tank Battle game

  • Artwork

  • Gameplay classes

  • Game source code

Building a Simple XNA Engine

Why do we need a so-called “game engine” for a meager game demo such as the cliché tank game in this chapter? The answer can be summarized in one word: concepts. Don’t think of a game engine as always on par with those found in commercial game-development packages, such as the engines behind Unreal 3, Neverwinter Nights, Doom, Far Cry, Crysis, and the like; products such as Garage Games’ Torque Game Engine; or open-source options such as Illright (http://irrlicht.sourceforge.net/) and Ogre (http://www.ogre3d.org/).

We will base the source code for a game built with XNA Game Studio on the Microsoft.Xna.Framework.Game class. We will create a new class called Engine that inherits from Microsoft.Xna.Framework.Game to encapsulate all the functionality of that base Game class—and in the process tuck away all the methods in the Game class, replaced with our own, more gameplay-friendly code. This will greatly simplify and standardize our XNA code, making it easier to modify and use for any type of game you wish to create. As the previous 16 chapters have demonstrated, XNA is not a tool for beginners. Although it may be marketed toward beginners, XNA is not suitable for beginners, and therefore you have no usable gameplay code in an XNA project unless you write that code. Such is the purpose of a so-called XNA engine: to encapsulate or wrap the core XNA code inside your own class, and then transform that code into a more gameplay-friendly interface. That, then, is the essential goal of a so-called “game engine”: to provide gameplay services.

The sample game in this chapter is composed of the usual gameplay source code as well as an Engine class that encapsulates much of the usual XNA code. To learn how the engine works, we will create a multiplayer Tank Battle game, putting as much engine code as possible in the engine project so that the game code doesn’t even look like XNA any longer. Of course, we’re not trying to get away from XNA by any means; no, the goal is not to abstract away any semblance of XNA, but to provide services in the engine that our gameplay project can consume, thus making game development with XNA faster and easier.

Integrating the Engine

During early development, it is more convenient to include the engine project in the solution with the gameplay project, because changes will be ongoing in the engine as the game is developed. This is a small enough game that a separated engine is not even necessary. The idea is not to create an engine template and then force gameplay code to follow it in a rigid manner, but rather to support the gameplay code with as many useful and friendly features as possible to make the code easier to write and update. When the engine project reaches a level of development where it can be compiled and used as a component, then both the engine and its content projects can be moved into a new solution, compiled, and closed. As the theory goes, the compiled engine will result in a DLL file that can be added to the gameplay project. The DLL will contain the .NET Framework assembly information needed to list it in the References list so that it can be added to the gameplay project as a referenced library. At that point, the engine library is compiled once again with the game, and the DLL must be distributed with the game. I won’t go into building a library project in this short chapter, other than to just make the suggestion. To keep the project simpler, the source code for the engine files and gameplay files will be combined. But, should you wish to explore the concept of a separate library project, all the source files will be available!

Font Support

One of the first things we’re going to do with the engine is provide several reusable fonts, so we don’t have to keep creating them! Rather than require the gameplay programmer to create a new font object in every new project, we will create a bunch of fonts in the engine and make them available as an enumerated list and used as a parameter to the text-output functions. Each of these fonts is included in the Engine class and the content project with several point-size variations for each one. This greatly simplifies printing text, since the Print method now accepts a font name from an enumeration rather than a SpriteFont object! Wait until you see the code—it’s great! Here are the fonts that are supported:

  • Segoe UI Mono

  • Kootenay

  • Lindsey

  • Miramonte

  • Pericles

  • PericlesLight

  • Pescadero

Engine Source Code

Listing 17.1 contains source code for the Engine class. This is not an “engine” by any means; it is simply descriptive of what the code herein tries to accomplish. Do not confuse the term “engine” here with a commercial engine like Unreal. The little engine here was designed to make it relatively easy to throw together 2D multiplayer arcade-style games, as the Tank Battle example later in the chapter demonstrates.

Example 17.1. Engine Source Code

using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Audio;
using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.GamerServices;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using Microsoft.Xna.Framework.Media;
using Microsoft.Xna.Framework.Net;
using Microsoft.Xna.Framework.Storage;

namespace XNAEngine
{
    public enum Fonts
    {
        Kootenay8,
        Kootenay12,
        Kootenay14,
        Lindsey14,
        Lindsey18,
        Lindsey24,
        Segoe8,
        Segoe12,
        Miramonte8,
        Miramonte12,
        Pericles14,
        Pericles18,
        Pericles24,
        PericlesLight18,
        Pescadero14,
        Pescadero18
    }
    public abstract class Engine : Microsoft.Xna.Framework.Game
    {
        /**
         * ENGINE OBJECTS
         **/
        protected GraphicsDeviceManager graphics;
        protected SpriteBatch spriteBatch;
        private float lastTime=0;
        private SpriteFont[] fonts;
        private Fonts defaultFont=Fonts.Miramonte12;
        private Texture2D line;
        public Random random = new Random();
        public GamePadState gamePad;
        public GamePadState oldPad;
        public KeyboardState keys, oldKeys;
        public MouseState mouse;

        //network objects
        public SignedInGamer gamer;
        public NetworkSession session;
        private AvailableNetworkSessionCollection games;
        public string NetworkStatusMessage;

        private bool paused;
        double drawCount = 0, drawTime = 0, drawRate = 0;
        double updateCount = 0, updateTime = 0, updateRate = 0;
        public bool Use720HD = true;
        private Rectangle workBounds;

        /**
         * ENGINE VIRTUAL FUNCTIONS TO BE IMPLEMENTED IN SUB-CLASS
         **/
        public abstract bool Load();
        public abstract void Update(float deltaTime);
        public abstract void Draw2D();
        public abstract void Draw3D();
        public abstract void NetworkReceive(LocalNetworkGamer gamer,
            ref PacketReader packet);
        public bool IsPaused() { return paused; }
        public double GetUpdateRate() { return updateRate; }
        public double GetDrawRate() { return drawRate; }

        /**
          * ENGINE CONSTRUCTOR
          **/
        public Engine()
        {
            graphics = new GraphicsDeviceManager(this);
            Components.Add(new GamerServicesComponent(this));
            paused = false;

            //set the desired resolutions
            Vector2 screen = new Vector2(640, 480);
            if (Use720HD)
                screen = new Vector2(1280, 720);
            graphics.PreferredBackBufferWidth = (int)screen.X;
            graphics.PreferredBackBufferHeight = (int)screen.Y;
            graphics.ApplyChanges();

            //usable TV resolution is about 95%
            int bx = (int)(screen.X * 0.05);
            int by = (int)(screen.Y * 0.05);
            workBounds = new Rectangle(bx, by,
                (int)screen.X - bx, (int)screen.Y - by);

            //initialize game pads
            gamePad = new GamePadState();

            //initialize networking
            gamer = null;
            session = null;
        }

        /**
          * Microsoft.Xna.Framework.Game BASE METHODS
          **/
        protected override void Initialize()
        {
        base.Initialize();
        IsFixedTimeStep = true;
        long ticks = 10000000L / 100L;
        TargetElapsedTime = new TimeSpan(ticks);
        IsMouseVisible = true;
    }

    protected override void LoadContent()
    {
        spriteBatch = new SpriteBatch(GraphicsDevice);
        Content.RootDirectory = "Content";

        //line drawing point texture
        line = Content.Load<Texture2D>("dot");

        //load engine fonts
        fonts = new SpriteFont[16];
        fonts[(int)Fonts.Kootenay8] = Content.Load<SpriteFont>
            ("Fonts/Kootenay8");
        fonts[(int)Fonts.Kootenay12] = Content.Load<SpriteFont>
            ("Fonts/Kootenay12");
        fonts[(int)Fonts.Kootenay14] = Content.Load<SpriteFont>
            ("Fonts/Kootenay14");
        fonts[(int)Fonts.Lindsey14] = Content.Load<SpriteFont>
            ("Fonts/Lindsey14");
        fonts[(int)Fonts.Lindsey18] = Content.Load<SpriteFont>
            ("Fonts/Lindsey18");
        fonts[(int)Fonts.Lindsey24] = Content.Load<SpriteFont>
            ("Fonts/Lindsey24");
        fonts[(int)Fonts.Segoe8] = Content.Load<SpriteFont>
            ("Fonts/Segoe8");
        fonts[(int)Fonts.Segoe12] = Content.Load<SpriteFont>
            ("Fonts/Segoe12");
        fonts[(int)Fonts.Miramonte8] = Content.Load<SpriteFont>
            ("Fonts/Miramonte8");
        fonts[(int)Fonts.Miramonte12] = Content.Load<SpriteFont>
            ("Fonts/Miramonte12");
        fonts[(int)Fonts.Pericles14] = Content.Load<SpriteFont>
            ("Fonts/Pericles14");
        fonts[(int)Fonts.Pericles18] = Content.Load<SpriteFont>
            ("Fonts/Pericles18");
        fonts[(int)Fonts.Pericles24] = Content.Load<SpriteFont>
            ("Fonts/Pericles24");
        fonts[(int)Fonts.PericlesLight18] = Content.Load<SpriteFont>
            ("Fonts/PericlesLight18");
        fonts[(int)Fonts.Pescadero14] = Content.Load<SpriteFont>
            ("Fonts/Pescadero14");
        fonts[(int)Fonts.Pescadero18] = Content.Load<SpriteFont>
            ("Fonts/Pescadero18");

        //give game code a chance to load assets
        Load();
    }

    protected override void UnloadContent() { }

    protected override void Update(GameTime gameTime)
    {
        //calculate update fps
        updateCount++;
        if (Environment.TickCount > updateTime + 1000)
        {
            updateRate = updateCount;
            updateCount = 0;
            updateTime = Environment.TickCount;
        }

        if (paused == false)
        {
            //get status of input devices
            gamePad = GamePad.GetState(PlayerIndex.One);
            keys = Keyboard.GetState();
            mouse = Mouse.GetState();

            //call update function in sub-class
            float delta = Environment.TickCount - lastTime;
            lastTime = Environment.TickCount;
            Update(delta);
            //save input states
            oldPad = gamePad;
            oldKeys = keys;

            //update network
            GetIncomingData();
        }

        GamerServicesDispatcher.Update();
        if (session != null) session.Update();
        base.Update(gameTime);
    }

    protected override void Draw(GameTime gameTime)
    {
        //calculate draw fps
        drawCount++;
        if (Environment.TickCount > drawTime + 1000)
        {
            drawRate = drawCount;
            drawCount = 0;
            drawTime = Environment.TickCount;
        }

        //begin rendering
        GraphicsDevice.Clear(Color.DarkBlue);

        //render
        GraphicsDevice.BlendState = BlendState.Opaque;
        GraphicsDevice.DepthStencilState = DepthStencilState.Default;
        Draw3D();

        //begin 2D
        spriteBatch.Begin();
        Draw2D();
        spriteBatch.End();
        //end 2D

        base.Draw(gameTime);
        //end rendering
    }

    protected override void OnActivated(object sender, EventArgs args)
    {
        paused = false;
        base.OnActivated(sender, args);
    }

    protected override void OnDeactivated(object sender, EventArgs args)
    {
        paused = true;
        base.OnDeactivated(sender, args);
    }

    /**
      * ENGINE HELPER FUNCTIONS
      **/
    public Texture2D LoadTexture(string filename)
    {
        Texture2D texture = Content.Load<Texture2D>(filename);
        return texture;
    }

    public void DrawImage(Texture2D texture, Vector2 position)
    {
        spriteBatch.Draw(texture, position, Color.White);
    }

    public void Print(int x, int y, string text)
    {
        Print(fonts[(int)defaultFont], x, y, text, Color.White);
    }

    //Print using font identified via the Enum
    public void Print(Fonts font, int x, int y, string text,
        Color color)
    {
        Print(fonts[(int)font], x, y, text, color);
    }

    //Base Print function that does all the real work
    public void Print(SpriteFont font, int x, int y, string text,
        Color color)
    {
        try
        {
            spriteBatch.DrawString(font, text, new Vector2(
                (float)x, (float)y), color);
        }
        catch (Exception e) { }
    }

    public void DrawVLine(int x, int y1, int y2, Color color)
    {
        spriteBatch.Draw(line, new Rectangle(x, y1, 1, 1+y2-y1), color);
    }

    public void DrawHLine(int x1, int x2, int y, Color color)
    {
        spriteBatch.Draw(line, new Rectangle(x1, y, 1+x2 - x1, 1),
            color);
    }

    public void DrawBox(Rectangle rect, Color color)
    {
        DrawBox(rect.X, rect.Y, rect.X + rect.Width, rect.Y +
            rect.Height, color);
    }

    public void DrawBox(int x1, int y1, int x2, int y2, Color color)
    {
        DrawVLine(x1, y1, y2, color);
        DrawVLine(x2, y1, y2, color);
        DrawHLine(x1, x2, y1, color);
        DrawHLine(x1, x2, y2, color);
    }

    public bool Collision(Sprite A, Sprite B)
    {
        float radius = (A.size.X + B.size.X) / 2;
        return Collision(A.position, B.position, radius);
    }
    public bool Collision(Vector2 first, Vector2 second, float radius)
    {
        double diffX = first.X - second.X;
        double diffY = first.Y - second.Y;
        double dist = Math.Sqrt(Math.Pow(diffX, 2) +
            Math.Pow(diffY, 2));
        return (dist < radius);
    }

    /**
      * NETWORKING CODE
      **/
    void session_GamerJoined(object sender, GamerJoinedEventArgs e)
    {
        NetworkStatusMessage = "GamerJoined: " + e.Gamer.Gamertag;
    }

    void session_GamerLeft(object sender, GamerLeftEventArgs e)
    {
        NetworkStatusMessage = "GamerLeft: " + e.Gamer.Gamertag;
    }

    void session_GameStarted(object sender, GameStartedEventArgs e)
    {
        NetworkStatusMessage = "GameStarted: " + e.ToString();
    }

    void session_GameEnded(object sender, GameEndedEventArgs e)
    {
        NetworkStatusMessage = "GameEnded: " + e.ToString();
    }

    void session_SessionEnded(object sender,
        NetworkSessionEndedEventArgs e)
    {
        NetworkStatusMessage = "SessionEnded: " + e.EndReason.
            ToString();
    }

    public void GamerSignIn()
    {
        if (gamer == null)
        {
            if (Gamer.SignedInGamers[PlayerIndex.One] == null &&
                Guide.IsVisible == false)
            {
                Guide.ShowSignIn(1, false);
            }
            gamer = Gamer.SignedInGamers[PlayerIndex.One];
        }
        if (gamer != null)
        {
            NetworkStatusMessage = "Signed in: " + gamer.Gamertag;
        }
    }

    public void CreateSession()
    {
        if (gamer == null)
        {
            NetworkStatusMessage = "No local gamer—sign in first";
            return;
        }

        if (session != null) session.Dispose();
        session = null;

        int maxGamers = 4;
        int maxLocalGamers = 4;

        // Create the session
        session = NetworkSession.Create(NetworkSessionType.SystemLink,
            maxLocalGamers, maxGamers);
        NetworkStatusMessage = "New session created";

        session.AllowHostMigration = true;
        session.AllowJoinInProgress = true;

        session.GamerJoined += new EventHandler<
            GamerJoinedEventArgs>(session_GamerJoined);
        session.GamerLeft += new EventHandler<
            GamerLeftEventArgs>(session_GamerLeft);
        session.GameStarted += new EventHandler<
            GameStartedEventArgs>(session_GameStarted);
        session.GameEnded += new EventHandler<
            GameEndedEventArgs>(session_GameEnded);
        session.SessionEnded += new EventHandler<
            NetworkSessionEndedEventArgs>(session_SessionEnded);
    }

    public void FindGame()
    {
        if (session != null) session.Dispose();
        session = null;

        if (games != null) games.Dispose();
        games = null;

        if (games == null)
        {
            games = NetworkSession.Find(NetworkSessionType.SystemLink,
                1, null);
            if (games.Count == 0)
            {
                NetworkStatusMessage = "No System Link games found";
            }
            else
            {
                AvailableNetworkSession game = games[0];
                NetworkStatusMessage = "Found session '" +
                    game.HostGamertag + "'";

                session = NetworkSession.Join(game);
                session.GamerJoined += new EventHandler<
                    GamerJoinedEventArgs>(session_GamerJoined);
                session.GamerLeft += new EventHandler<
                    GamerLeftEventArgs>(session_GamerLeft);
                session.GameStarted += new EventHandler<
                    GameStartedEventArgs>(session_GameStarted);
                   session.GameEnded += new EventHandler<
                 GameEndedEventArgs>(session_GameEnded);
               session.SessionEnded += new EventHandler<
                    NetworkSessionEndedEventArgs>(session_SessionEnded);
                }
            }
        }

        public void NetworkSendPacket(PacketWriter writer)
        {
            if (session == null) return;
            foreach (LocalNetworkGamer gamer in session.LocalGamers)
            {
                gamer.SendData(writer, SendDataOptions.None);
            }
        }

        private void GetIncomingData()
        {
            if (session == null) return;
            PacketReader reader = new PacketReader();
            foreach (LocalNetworkGamer gamer in session.LocalGamers)
            {
                while (gamer.IsDataAvailable)
                {
                   NetworkGamer sender;

                   //get a packet
                   gamer.ReceiveData(reader, out sender);
                   if (!sender.IsLocal)
                   {
                       NetworkReceive(gamer, ref reader);
                   }
                }
            }
        }
    }
}

Building the Tank Battle Game

Figure 17.1 shows the finished game. The game functions similarly to the Network Demo program from Chapter 14, “Multiplayer Networking,” where a single code base is used and any player may host or join another hosted game at any time. This is one of the greatest pros of the XNA networking library: It can seamlessly transition from host to client and vice versa, as well as run with much the same code as a LAN (System Link) game or an Xbox Live game played online against other remote players. Speaking of which, anyone who has an App Hub account can play the tank game against you! To make the game work over Xbox Live, a minor change must be made to the code. You’ll note that the SystemLink option is used to create the network sessions. If you want to switch to playing on Xbox Live, that single option is all that must be changed.

The Xbox 360 account is hosting, while the Windows PC build is joining the game.

Figure 17.1. The Xbox 360 account is hosting, while the Windows PC build is joining the game.

This game is intended to be a launching point for any number of potential games. It is an infrastructure template for a networked tank game for two players. Of course we could adapt it for more than two, but for the sake of learning, this chapter focuses on just two players at this point. If you pore over the source code and examine how it works for just two players, I am confident you will have no problem adapting it for three, four, or eight! As a matter of fact, I had the game running with four players early on, and had to simplify it because the code was a bit unwieldy—in other words, it grew in complexity beyond the goal I had for this chapter! I want you to build a great game, so I can’t supply you with one up front. I need to take a step back and just show you how, give you the tools, and let you make a game out of it.

Artwork

The artwork for the game comes from several places. First, the tank sprite and artwork were created by Mark Simpson (Prinz Eugn at Gamedev.net). The ground texture was sourced from an asset collection called DarkMatter by The Game Creators (www.thegamecreators.com). As you can see in Figure 17.2, the tank chassis and turret are moved independently, so it’s possible to drive in one direction while shooting in another.

Shots can be fired at long range, beyond the boundary of the screen.

Figure 17.2. Shots can be fired at long range, beyond the boundary of the screen.

Gameplay Classes

There are some helper classes in the Tank Battle project to help organize the source code, which would otherwise become quite complicated due to the lists and custom properties needed for the tanks and shell/bullet sprites. The base Sprite class is extended for these custom game objects with the additional things needed to make the game work.

Hint

When starting up the Windows build of the networked Tank Battle game, it will often take several seconds before the game window appears due to the gamer services component.

The game keeps track of each player’s health, but doesn’t enforce game state. That is, there is no “win” or “lose” screen or notification. The damage dealt is 10 percent per shot, so you can adjust this to suit the needs of your own game. When the health drops to 0 percent, the player should lose the game—or get respawned, if you want to make it more fun! I would really like to see obstacles, spawn points, A.I. bot tanks, and destructible terrain added to this game. It has some real potential with just a few gameplay features that would be relatively easy to add now that the framework has been created.

Ground Class

The Ground class, shown in Listing 17.2, implements a scrolling background to allow the tanks a larger battlefield, since obviously the screen resolution alone is too small for any kind of effective gameplay! We don’t necessarily want a huge battlefield, but one that is just large enough to give the players some room to chase and evade. In a polished and more highly playable version of this game, we would expect to find obstacles such as walls, buildings, perhaps even destructible objects, A.I. players, and other things (based on the game mode). How about a capture-the-flag mode? The possibilities truly are endless. I think it would be really fun to give each player an A.I. ally tank to help them fight against their opponent. The A.I. tanks would add a bit of uncertainty to the game! Another gameplay idea that usually nets a good result with players is the addition of power-up items that the player can pick up. How about different weapons with which the player can equip his or her tank?

Example 17.2. The Ground Class

using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.Graphics;

namespace XNAEngine
{
    class Ground
    {
        private GraphicsDevice device;
        private ContentManager content;
        private SpriteBatch spriteBatch;
        private Texture2D tile;
        private Texture2D buffer;
        private Vector2 oldPos;
        private RenderTarget2D renderTarget;
        public Rectangle Boundary;
        public Vector2 Position;
        public Microsoft.Xna.Framework.Graphics.Viewport
            Viewport { get; set; }

        public Ground(ContentManager _content, SpriteBatch _spriteBatch)
        {
            content = _content;
            spriteBatch = _spriteBatch;
            device = spriteBatch.GraphicsDevice;
            buffer = null;
            tile = null;
            oldPos = Vector2.One;
            renderTarget = null;

            //if object is created inside LoadContent, then lock & load!
            if (content != null)
                Load();
        }

        public void Load()
        {
            Viewport = device.Viewport;
            tile = content.Load<Texture2D>("tile");
            CreateScrollBuffer();
        }

        public void Draw()
        {
            //keep view inside the buffer boundary
            if (Position.X < 0)
                Position.X = 0;
            else if (Position.X > buffer.Width - Viewport.Width)
                Position.X = buffer.Width - Viewport.Width;
            if (Position.Y < 0)
                Position.Y = 0;
            else if (Position.Y > buffer.Height - Viewport.Height)
                Position.Y = buffer.Height - Viewport.Height;

            //draw the ground scroll buffer
            Vector2 pos = Position;
            Rectangle sourceRect = new Rectangle((int)pos.X, (int)pos.Y,
                Viewport.Width, Viewport.Height);
            spriteBatch.Draw(buffer, Vector2.Zero, sourceRect, Color.White);
        }

        private void CreateScrollBuffer()
        {
            //create scroll buffer (4x total size of screen)
            buffer = new Texture2D(spriteBatch.GraphicsDevice,
                Viewport.Width * 2, Viewport.Height * 2);
            Boundary = new Rectangle(0, 0, buffer.Width, buffer.Height);

            //create new render target
            renderTarget = new RenderTarget2D(device, buffer.Width,
                buffer.Height);

            //set render target to scroll buffer
            device.SetRenderTarget(renderTarget);

            //fill buffer with tiles
            Vector2 tiles;
            tiles.X = buffer.Width / tile.Width;
            tiles.Y = buffer.Height / tile.Height;

            spriteBatch.Begin();
            for (int y = 0; y < tiles.Y; y++)
            {
                for (int x = 0; x < tiles.X; x++)
                {
                   Vector2 pos = new Vector2(x * tile.Width, y *
                       tile.Height);
                   spriteBatch.Draw(tile, pos, null, Color.White);
                }
            }
            spriteBatch.End();

            //copy texture to buffer
            buffer = (Texture2D)renderTarget;
            //restore default render target
            device.SetRenderTarget(null);
        }

        public Rectangle GetBoundary()
        {
            return new Rectangle(0, 0, buffer.Width, buffer.Height);
        }
    }
}

Tank Class

The Tank class, shown in Listing 17.3, might be considered an extension of the Sprite class, but while implementing the class for this game I found it more helpful to have two Sprite variables defined in the class instead—one for the tank’s main body (called the chassis) and one for the turret (the gun). The chassis is used for things like collision detection because it’s the largest piece, while the turret sprite just follows along in a relative position, drawn over the top of the chassis every time the Tank.Draw method is called. While developing any game, even a fairly simple game like this two-player Tank Battle, it is helpful to have debugging information on the screen. Figure 17.3 shows some debug messages printed to the lower-left corner of the game window.

The information panel in the lower-left corner shows vital data (essential when debugging a networked game).

Figure 17.3. The information panel in the lower-left corner shows vital data (essential when debugging a networked game).

Example 17.3. The Tank Class

using System;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Content;

namespace XNAEngine
{
    public class Tank
    {
        private SpriteBatch sb;
        public Sprite Chassis;
        public Sprite Turret;
        public Vector2 Position;
        public bool Firing { get; set; }
        public float MoveTimer { get; set; }
        public float FireTimer { get; set; }
        public int Health { get; set; }
        public bool Moving { get; set; }

        //this should be called from LoadContent
        public Tank(ContentManager content, SpriteBatch spriteBatch)
        {
            sb = spriteBatch;
            Chassis = new Sprite(spriteBatch);
            Chassis.Load(content, "chassis");
            Chassis.pivot = new Vector2(64, 64);
            Turret = new Sprite(spriteBatch);
            Turret.Load(content, "turret");
            Turret.pivot = new Vector2(32, 80);
            Position = new Vector2(0, 0);
            MoveTimer = 0;
            FireTimer = 0;
            Firing = false;
            Health = 100;
        }

        public void Update(float delta) { }

        public void Draw()
        {
            Chassis.position = new Vector2(Position.X + 64,
                Position.Y + 64);
            Chassis.Draw();
            Turret.position = new Vector2(Chassis.position.X,
                Chassis.position.Y);
            Turret.Draw();
        }

        public void MoveTurret(float degs)
        {
            if (degs < -5.0) degs = -5.0f;
            else if (degs > 5.0) degs = 5.0f;
            Turret.rotation += MathHelper.ToRadians(degs);
        }

        public void Turn(float degs)
        {
            if (degs < -5.0) degs = -5.0f;
            else if (degs > 5.0) degs = 5.0f;
            Chassis.rotation += MathHelper.ToRadians(degs);
            MoveTurret(degs);
            Moving = true;
        }
        public void Drive(float dir)
        {
            float angle = Chassis.rotation - MathHelper.ToRadians(90);
            Vector2 vel = new Vector2(
                dir * (float)Math.Cos(MathHelper.WrapAngle(angle)),
                dir * (float)Math.Sin(MathHelper.WrapAngle(angle)));
            Position += vel;
            Moving = true;
        }
    }
}

Shell Class

The Shell class, shown in Listing 17.4, is a very basic extension class that inherits from Sprite and just adds one new property: an Owner property, which identifies which tank fired the shot. Speaking of shooting, Figure 17.4 shows what happens if you aren’t careful with that fire button! Just make sure you can avoid incoming fire as fast as you dish it out.

The enemy scores a hit on our tank!

Figure 17.4. The enemy scores a hit on our tank!

Example 17.4. The Shell Class

using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
namespace XNAEngine
{
    class Shell : Sprite
    {
        public int Owner { get; set; }
        public Shell(SpriteBatch sb) : base(sb)
        {
        }
    }
}

Game Source Code

The complete source code for the Game class is shown in Listing 17.5, representing the majority of the gameplay code for Tank Battle. Remember, much of the helper code was encapsulated in the Engine class so we’re at a slightly higher level of coding here as a result. Note also that this MyGame class inherits from XNAEngine.Engine, which has its own abstract methods that must be implemented in the subclass. Gone are the default XNA events such as LoadContent, replaced with our own (in this case, it is simply called Load). Similarly, we have replaced Update() and Draw() with our own methods that are implemented by any class that inherits from Engine. The advantage is a cleaner source-code file that is at least somewhat protected from future changes to the XNA Framework. If the folks at Microsoft makes any changes—and odds are, they will—we can make most of the changes in Engine rather than in MyGame. Speaking of the game, let’s see another screenshot—Figure 17.5 shows the game running on an Xbox 360, as host, ready to receive a connection from another player.

The game hosted on an Xbox 360.

Figure 17.5. The game hosted on an Xbox 360.

Example 17.5. The Complete Source Code for the Game Class

using System;
using System.Collections.Generic;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Audio;
using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.GamerServices;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using Microsoft.Xna.Framework.Media;
using Microsoft.Xna.Framework.Net;
using Microsoft.Xna.Framework.Storage;

namespace XNAEngine
{
    public class MyGame : XNAEngine.Engine
    {
        const int FIRE_DELAY = 1500;
        const float SHELL_VEL = 12.0f;

        Tank[] tanks;
        List<Shell> shells;
        List<Shell> enemyShells;
        Texture2D shellImage;
        Sprite explosion;
        Ground ground;
        Sprite radar;
        Vector2 bulletPos;

        SoundEffect tankFire;
        SoundEffectInstance tankFireInst;
        SoundEffect explosionSound;
        SoundEffectInstance explInst;
 
        /**
         * Engine constructor
         **/
        public MyGame() { }

        /**
         * Implements Engine.Load
         **/
        public override bool Load()
        {
            //create ground scroller
            ground = new Ground(Content, spriteBatch);
 
            //create tank sprites
            tanks = new Tank[2];
            for (int n = 0; n &< 2; n++)
            {
                 tanks[n] = new Tank(Content, spriteBatch);
                 int x = random.Next(ground.Boundary.Width - 128);
                 int y = random.Next(ground.Boundary.Height - 128);
                 tanks[n].Position = new Vector2(x, y );
                 tanks[n].Chassis.rotation = MathHelper.ToRadians(
                     random.Next(360));
                 tanks[n].Turret.rotation = tanks[0].Chassis.rotation;
                 tanks[n].Health = 100;
                 tanks[n].Firing = false;
                 tanks[n].Moving = false;
            }
            tanks[0].Position = new Vector2(640-64,360-64);
            //create shell sprites
            shells = new List<Shell>();
            shellImage = Content.Load<Texture2D>("shell");

            //create enemy shell sprites
            enemyShells = new List<Shell>();

            //create explosion sprite
            explosion = new Sprite(spriteBatch);
            explosion.Load(Content, "explosion_30_128");
            explosion.columns = 6;
            explosion.totalframes = 30;
            explosion.size = new Vector2(128, 128);
            explosion.alive = false;

            //load sound clips
            tankFire = Content.Load<SoundEffect>("tankfire");
            tankFireInst = tankFire.CreateInstance();
            explosionSound = Content.Load<SoundEffect>("explosion");
            explInst = explosionSound.CreateInstance();

            //create radar image
            radar = new Sprite(spriteBatch);
            radar.Load(Content, "radar");
            radar.position = new Vector2(1280 - radar.size.X - 20, 720 -
                radar.size.Y - 20);

            return true;
        }

        /**
         * Implements Engine.Update
         **/
        public override void Update(float delta)
        {
            if (gamePad.Buttons.Back == ButtonState.Pressed
                || keys.IsKeyDown(Keys.Escape) ) this.Exit();
            if (gamePad.Buttons.Y == ButtonState.Pressed)
            {
                 GamerSignIn();
            }
            else if (gamePad.Buttons.X == ButtonState.Pressed)
            {
                CreateSession();
            }
            else if (gamePad.Buttons.B == ButtonState.Pressed)
            {
                FindGame();
            }
            UpdatePlayerTank(delta);
            UpdateFiring(delta);
            UpdateBullets();
            UpdateExplosions(delta);
            UpdateNetworking();
        }

        void UpdatePlayerTank(float delta)
        {
            //control the tank
            tanks[0].MoveTimer += (int)delta;
            if (tanks[0].MoveTimer > 1)
            {
                tanks[0].MoveTimer = 0;

                //turn chassis
                float stickx = gamePad.ThumbSticks.Right.X;
                if (stickx != 0)
                    tanks[0].MoveTurret(stickx);

                //rotate turret
                stickx = gamePad.ThumbSticks.Left.X;
                if (stickx != 0)
                    tanks[0].Turn(stickx);

                //apply throttle
                float sticky = gamePad.ThumbSticks.Left.Y;
                if (sticky == 0)
                    tanks[0].Moving = false;
                else
                    tanks[0].Moving = true;
                             if (tanks[0].Moving)
        {
            //calculate velocity from tank's direction
            float rot = tanks[0].Chassis.rotation;
            rot -= MathHelper.ToRadians(90);
            rot = MathHelper.WrapAngle(rot);
            float velx = 2.0f * sticky;
            float vely = 2.0f * sticky;
            tanks[0].Chassis.velocity = new Vector2(
                velx * (float)Math.Cos(rot),
                vely * (float)Math.Sin(rot) );

            //scroll ground with tank velocity
            ground.Position += tanks[0].Chassis.velocity;
            //move tank
            tanks[0].Position += tanks[0].Chassis.velocity;

            /**
             * THIS IS NOT WORKING RIGHT!
             **/
            if (ground.Position.X >= 0 &&
                ground.Position.X <= ground.Boundary.Width -
                   ground.Viewport.Width &&
                ground.Position.Y >= 0 &&
                ground.Position.Y <= ground.Boundary.Height -
                   ground.Viewport.Height)
            {
                tanks[0].Position.X = ground.Position.X + 640 - 64;
                tanks[0].Position.Y = ground.Position.Y + 360 - 64;
            }
        }
    }
}

void UpdateFiring(float delta)
{
    tanks[0].FireTimer += (int)delta;
    if (tanks[0].FireTimer > FIRE_DELAY)
    {
        if (gamePad.Triggers.Right > 0)
            tanks[0].Firing = true;
    }

    if (tanks[0].Firing)
    {
        tanks[0].FireTimer = 0;
        tanks[0].Firing = false;
        FireShell();
    }
}

void UpdateBullets()
{
    //test for bullet collisions
    foreach (Shell shell in shells)
    {
        if (shell.alive)
        {
            //is this shell out of bounds?
            if (shell.position.X < 0 ||
                shell.position.X > ground.Boundary.Width ||
                shell.position.Y < 0 ||
                shell.position.Y > ground.Boundary.Height)
            {
                shell.alive = false;
            }
            else
            {
                //did this shell hit a target?
                Vector2 tankCenter = tanks[1].Position;
                tankCenter.X += tanks[1].Chassis.size.X / 2;
                tankCenter.Y += tanks[1].Chassis.size.Y / 2;
                if (Collision(shell.position, tankCenter, 64))
                {
                   explInst.Play();
                   shell.alive = false;
                   tanks[1].Health -= 10;
                   explosion.alive = true;
                   explosion.position = tanks[1].Position;
                                   explosion.frame = 0;
                                   SendHitPacket();
                               }
                               bulletPos = shell.position;
                           }
                       }
                  }
              }
              void UpdateExplosions(float delta)
              {
                  //update explosion
                  if (explosion.alive || explosion.frame < 30)
                  {
                      explosion.Animate(0, 29, delta, 30);
                      if (explosion.frame >= 29)
                          explosion.alive = false;
                  }
              }

              /**
               * Implements Engine.Draw3D
               **/
              public override void Draw3D() { }
 
              /**
               * Implements Engine.Draw2D
               **/
              public override void Draw2D()
              {
                  ground.Draw();
                  DrawShells();
                  DrawEnemyShells();
                  DrawTanks();
                  DrawExplosions();
                  DrawRadar();
 
                  //clear out all enemy shell sprites
                  enemyShells.Clear();
    Print(Fonts.Pescadero18, 20, 20, "PLAYER 1 (" +
        tanks[0].Health.ToString() + "%)", Color.Yellow);
    Print(Fonts.Pescadero18, 240, 20, "PLAYER 2 (" +
        tanks[1].Health.ToString() + "%)", Color.LightBlue);

    int y = 500;
    Print(Fonts.Kootenay12,20,y,"Y - Gamer Sign-In",
        Color.Yellow); y += 15;
    Print(Fonts.Kootenay12,20,y,"X - Host Game Session",
        Color.Yellow); y += 15;
    Print(Fonts.Kootenay12,20,y,"B - Join Game Session",
        Color.Yellow); y += 30;

    Print(Fonts.Kootenay12,20,y,"FPS " + GetUpdateRate().ToString()
        + " / " + GetDrawRate().ToString(), Color.White); y += 15;
    Print(Fonts.Kootenay12,20,y,"Scroll " + ground.Position.
        ToString(), Color.White); y += 15;
    Print(Fonts.Kootenay12,20,y,"Player " + tanks[0].Position.X.
        ToString("N0") + "," + tanks[0].Position.Y.ToString("N0"),
        Color.White); y += 15;
    Print(Fonts.Kootenay12,20,y,"Enemy " + tanks[1].Position.X.
        ToString("N0") + "," + tanks[1].Position.Y.ToString("N0"),
        Color.White); y += 15;
    Print(Fonts.Kootenay12,20,y,"Bullet "+bulletPos.X.ToString("N0")
        + "," + bulletPos.Y.ToString("N0"), Color.White); y += 15;
    if (gamer != null)
    {
        Print(Fonts.Kootenay12, 20, y, "Gamer: " + gamer.Gamertag,
            Color.White); y += 15;
    }
    else
        Print(Fonts.Kootenay12, 20, y, "No Gamer profile loaded",
            Color.White); y += 15;
    Print(Fonts.Kootenay12,20,y,NetworkStatusMessage,Color.White);
}

void DrawShells()
{
    //draw shells
    foreach (Sprite shell in shells)
    {
        if (shell.alive)
        {
            //move shell
            shell.position += shell.velocity;
            //remember position
            Vector2 oldPos = shell.position;
            //make position relative to screen for drawing
            float x = shell.position.X - ground.Position.X;
            float y = shell.position.Y - ground.Position.Y;
            shell.position = new Vector2(x, y);
            if (shell.position.X > -16 &&
                shell.position.X < ground.Viewport.Width + 16 &&
                shell.position.Y > -16 &&
                shell.position.Y < ground.Viewport.Height + 16)
            {
                shell.Draw();
            }
            //restore position
            shell.position = oldPos;
        }
    }
}

void DrawEnemyShells()
{
    //draw enemy shells
    foreach (Sprite shell in enemyShells)
    {
        if (shell.alive)
        {
            //remember position
            Vector2 oldPos = shell.position;
            //make position relative to screen for drawing
            float x = shell.position.X - ground.Position.X;
            float y = shell.position.Y - ground.Position.Y;
            shell.position = new Vector2(x, y);
            if (shell.position.X > -16 &&
                shell.position.X < ground.Viewport.Width + 16 &&
                shell.position.Y > -16 &&
                shell.position.Y < ground.Viewport.Height + 16)
            {
                shell.Draw();
            }
            //restore position
            shell.position = oldPos;
        }
    }
}

void DrawTanks()
{
    //draw the tanks
    for (int n = 0; n < 2; n++)
    {
        //remember position
        Vector2 oldPos = tanks[n].Position;
        //set relative position on screen
        float x = tanks[n].Position.X - ground.Position.X;
        float y = tanks[n].Position.Y - ground.Position.Y;
        tanks[n].Position = new Vector2(x, y);
        if (tanks[n].Position.X > -128 &&
            tanks[n].Position.X < ground.Viewport.Width + 128 &&
            tanks[n].Position.Y > -128 &&
            tanks[n].Position.Y < ground.Viewport.Height + 128)
        {
            //draw the tank
            tanks[n].Draw();
        }
        //restore position
        tanks[n].Position = oldPos;
    }
}

void DrawExplosions()
{
    //draw explosions
    if (explosion.alive)
    {
        //save position
        Vector2 oldPos = explosion.position;

        //set relative position
        float x = explosion.position.X - ground.Position.X;
        float y = explosion.position.Y - ground.Position.Y;
        explosion.position = new Vector2(x, y);
        if (explosion.position.X > -16 &&
            explosion.position.X < ground.Viewport.Width + 16 &&
            explosion.position.Y > -16 &&
            explosion.position.Y < ground.Viewport.Height + 16)
        {
            explosion.Draw();
        }
        //restore position
        explosion.position = oldPos;
    }
}

void FireShell()
{
    Shell shell=null;
    tankFireInst.Play();
    //find unused shell
    foreach (Shell s in shells)
    {
        if (s.alive == false)
        {
            shell = s;
            break;
        }
    }

    //. . .or create a new one
    if (shell == null)
    {
        shell = new Shell(spriteBatch);
        shells.Add(shell);
    }
    shell.Owner = 0;
    shell.alive = true;
    shell.image = shellImage;
    shell.size = new Vector2(shellImage.Width, shellImage.Height);
    float x = tanks[0].Position.X + 64;
    float y = tanks[0].Position.Y + 64;
    shell.position = new Vector2(x, y);
    shell.rotation = tanks[0].Turret.rotation;
    float angle = shell.rotation - MathHelper.ToRadians(90);
    angle = MathHelper.WrapAngle(angle);
    shell.velocity.X = SHELL_VEL * (float)Math.Cos(angle);
    shell.velocity.Y = SHELL_VEL * (float)Math.Sin(angle);
    shell.pivot = new Vector2(4, 8);
}

void DrawRadar()
{
    radar.Draw();
    int rx = (int)radar.position.X;
    int ry = (int)radar.position.Y;

    //show player's position
    int x = rx + (int)tanks[0].Position.X/8;
    int y = ry + (int)tanks[0].Position.Y/8;
    DrawBox(x, y, x + 6, y + 6, Color.Green);

    //show enemy tank position
    x = rx + (int)tanks[1].Position.X / 8;
    y = ry + (int)tanks[1].Position.Y / 8;
    DrawBox(x, y, x + 6, y + 6, Color.Blue);

    //show shells
    foreach (Shell shell in shells)
    {
        if (shell.alive)
        {
            x = rx + (int)shell.position.X / 8 - 4;
            y = ry + (int)shell.position.Y / 8 - 4;
            DrawBox(x, y, x + 1, y + 1, Color.Red);
        }
    }

    //show enemy shells
    foreach (Shell shell in enemyShells)
    {
        if (shell.alive)
        {
            x = rx + (int)shell.position.X / 8 - 4;
            y = ry + (int)shell.position.Y / 8 - 4;
            DrawBox(x, y, x + 1, y + 1, Color.Red);
        }
    }
}

/**
  * Implements Engine.NetworkReceive
  **/
public override void NetworkReceive(LocalNetworkGamer gamer,
    ref PacketReader packet)
{
    Int16 id = packet.ReadInt16();
    switch(id)
    {
    case 1: //enemy tank
        tanks[1].Position = packet.ReadVector2();
        tanks[1].Chassis.rotation = (float)packet.ReadDouble();
        tanks[1].Turret.rotation = (float)packet.ReadDouble();
        break;
    case 2: //shell
        Shell shell = new Shell(spriteBatch);
        shell.image = shellImage;
        shell.size = new Vector2(shellImage.Width, shellImage.
            Height);
        shell.Owner = 1;
        shell.pivot = new Vector2(4, 8);
        shell.position = packet.ReadVector2();
        shell.rotation = (float)packet.ReadDouble();
        enemyShells.Add(shell);
        break;
    case 3: //hit
        explInst.Play();
        explosion.alive = true;
        explosion.frame = 0;
        explosion.position = tanks[0].Position;
        tanks[0].Health -= 10;
        break;
    }
}

/**
 * Send tank data to other player
 **/
void UpdateNetworking()
{
    SendTankPacket();
    SendShellPackets();
}

void SendTankPacket()
{
    PacketWriter writer = new PacketWriter();
    //add packet id
    Int16 id = 1;
    writer.Write(id);
    //add tank data
    writer.Write(tanks[0].Position);
    writer.Write((double)tanks[0].Chassis.rotation);
    writer.Write((double)tanks[0].Turret.rotation);
    //send
    NetworkSendPacket(writer);
}

void SendShellPackets()
{
    PacketWriter writer = new PacketWriter();
    if (shells.Count == 0) return;
    foreach (Shell shell in shells)
    {
        //add packet id
        Int16 id = 2;
        writer.Write(id);
        //add shell data
        writer.Write(shell.position);
        writer.Write((double)shell.rotation);
                //send
                NetworkSendPacket(writer);
            }
        }

        void SendHitPacket()
        {
            PacketWriter writer = new PacketWriter();
            //add packet id
            Int16 id = 3;
            writer.Write(id);
            //no data—just notification
            //send
            NetworkSendPacket(writer);
        }
    }
}

Improving the Game

Even a relatively simple two-player networked game involves a lot of work, as the source code in this chapter shows! There are so many things I would have liked to add to the game, had time permitted. There are a few known bugs because—let’s face it—this is a quick and dirty sample game. I think it’s got huge potential, though!

First of all, there’s a scrolling battlefield, which we might have easily made into a whole chapter on its own! The scroller could be used for any number of other game genres, from side-scrolling platformers to vertical-scrolling shooters. The tanks have rotating turrets, while the RADAR screen shows the tanks and bullets flying. There really is no reason this game can’t support many more players, but one-on-one was necessary, in my opinion, to make the code easier to follow—and as a result, it allows for a better learning experience. The important thing is, you can use this game as a learning tool to build your own networked Xbox 360 games.

In any event, here are some features you may wish to add to the game:

  • Add support for four, eight, or 16 players.

  • Add a title screen with menu.

  • Add a lobby feature so players can join before combat begins.

  • Allow players to send text messages so they can talk smack!

  • Add more tanks and allow players to choose one.

  • Start players off with a little walking guy and allow them to get into a tank or jump out.

  • Allow players to keep track of the score, and add conditions for winning the game.

  • Improve the user interface with controls, borders, etc.

Summary

It’s a wrap! The Tank Battle game fulfills the goals we set out to achieve back in Chapter 1, “Introduction to XNA Game Studio 4.0,” of making an Xbox 360 networked game! It’s been a very long haul. The book definitely could have spent more time on rendering, but shader-based rendering with DirectX 9 is now a well-established subject with many scores of books to support the technology. Yes, that’s a bit of a cop out, but the purpose here was not to try to outdo anyone with rendering effects, but to show primarily indie and hobby developers how to make a networked game for the Xbox 360. In the end, we can also run this code on Windows, and with some changes, on Zune or Windows Phone 7. . . but that is a subject for another day. Meanwhile, I’m working with some friends to port our game, Starflight - The Lost Colony, to XNA. To quote a friend of mine, “It must be done!” With that in mind, I leave with you the task of making something cool out of this Tank Battle game. I look forward to seeing what you come up with!

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.224.44.108