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
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.
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!
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
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); } } } } } }
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.
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.
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.
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.
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.
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); } } }
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.
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; } } }
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 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.
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); } } }
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.
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!
18.224.44.108