自己动手写游戏:飞机大战
一、关于飞机大战
要说微信中最火爆的小游戏是哪款,可能既不是精心打造的3D大作,也不是《植物大战僵尸2》,而是微信5.0刚开启时的《飞机大战》。
就是这样一款铅笔手绘风格的简单到不能再简单的“打飞机”游戏,让国内的微信用户一次又一次地尝试,并表示似乎又找回了童年时玩电子游戏的那份单纯的快乐。至于游戏的玩法都不用加以介绍,就是简单的“打飞机”。
二、关于游戏设计
2.1 总结游戏印象
(1)一个玩家飞机,多个电脑飞机
① ② ③ ④
(2)玩家飞机可以发射子弹,电脑飞机也可以发射子弹
① ②
(3)玩家和电脑飞机被击中后有爆炸效果,并且有一定几率出现大型飞机
① ②
2.2 总结设计思路
(1)万物皆对象
在整个游戏中,我们看到的所有内容,我们都可以理解为游戏对象(GameObject),每一个游戏对象,都由一个单独的类来创建;在游戏中主要有三类游戏对象:一是飞机,二是子弹,三是背景;其中,飞机又分为玩家飞机和电脑飞机,子弹又分为玩家子弹和电脑子弹。于是,我们可以对飞机进行抽象形成一个抽象父类:PlaneBase,然后分别创建两个子类:PlanePlayer和PlaneEnemy;然后对子弹进行抽象形成一个抽象类:BulletBase,然后分别创建两个子类:BulletPlayer和BulletEnemy。但是,我们发现这些游戏对象都有一些共同的属性和方法,例如X,Y轴坐标,长度和宽度,以及绘制(Draw())和移动(Move())的方法,这时我们可以设计一个抽象类,形成了GameObject类:将共有的东西封装起来,减少开发时的冗余代码,提高程序的可扩展性,符合面向对象设计的思路:
(2)计划生育好
在整个游戏中,我们的玩家飞机对象只有一个,也就是说在内存中只需要存一份即可。这时,我们想到了伟大的计划生育政策,于是我们想到了使用单例模式。借助单例模式,可以保证只生成一个玩家飞机的实例,即为程序提供一个全局访问点,避免重复创建浪费不必要的内存。当然,除了玩家飞机外,我们的电脑飞机集合、子弹集合等集合对象实例也保证只有一份存储,降低游戏开销;
(3)对象的运动
在整个游戏过程中,玩家可以通过键盘上下左右键控制玩家飞机的上下左右运动,而飞机的运动本质上还是改变游戏对象的X轴和Y轴的坐标,然后一直不间断地在窗体上重绘游戏对象。相比玩家飞机的移动,电脑飞机的移动则完全是通过程序中设置的随机函数控制左右方向移动的,而玩家飞机发出的子弹执行的运动则是从下到上,而电脑飞机发出的子弹执行的运动则是从上到下。
(4)设计流程图
三、关键代码实现
3.1 客户端开发
(1)设计GameObject类:封装所有游戏对象的公有属性
/// <summary> /// 抽象类:游戏对象基类 /// </summary> public abstract class GameObject { public int X { get; set; } public int Y { get; set; } public int Width { get; set; } public int Height { get; set; } public int Speed { get; set; } public int Life { get; set; } public Direction Dir { get; set; } public GameObject(int x, int y, int width, int height, int speed, int life, Direction dir) { this.X = x; this.Y = y; this.Width = width; this.Height = height; this.Speed = speed; this.Life = life; this.Dir = dir; } public GameObject(int x, int y) { this.X = x; this.Y = y; } // 实例方法:返回所在矩形区域用于碰撞检测 public Rectangle GetRectangle() { return new Rectangle(this.X, this.Y, this.Width, this.Height); } // 抽象方法:游戏对象的绘制各不相同 public abstract void Draw(Graphics g); // 虚方法:游戏对象的移动各不相同 public virtual void Move() { // 根据指定的移动方向进行移动 switch (Dir) { case Direction.Up: this.Y -= this.Speed; break; case Direction.Down: this.Y += this.Speed; break; case Direction.Left: this.X -= this.Speed; break; case Direction.Right: this.X += this.Speed; break; } // 移动之后判断是否超出了边界 if (this.X <= 0) { this.X = 0; } if (this.X >= 380) { this.X = 380; } if (this.Y <= 0) { this.Y = 0; } if (this.Y >= 670) { this.Y = 670; } } }
一切皆对象,这里封装了游戏对象:飞机、子弹以及其他游戏对象共有的属性,以及两个抽象方法,让对象们(飞机?子弹?爆炸效果?等)自己去实现。
(2)设计SingleObject类:保证游戏中的类都只有一个实例
/// <summary> /// 单例模式类 /// </summary> public class SingleObject { private SingleObject() { } private static SingleObject singleInstance = null; public static SingleObject GetInstance() { if (singleInstance == null) { singleInstance = new SingleObject(); } return singleInstance; } #region 单一实例对象列表 // 1.游戏背景单一实例 public GameBackground Background { get; set; } // 2.游戏标题单一实例 public GameTitle Title { get; set; } // 3.玩家飞机单一实例 public PlanePlayer Player { get; set; } // 4.玩家飞机子弹集合单一实例 public List<BulletPlayer> PlayerBulletList { get; set; } // 5.敌人飞机集合单一实例 public List<PlaneEnemy> EnemyList { get; set; } // 6.敌人飞机子弹集合单一实例 public List<BulletEnemy> EnemyBulletList { get; set; } // 7.玩家飞机爆炸效果单一实例 public List<BoomPlayer> PlayerBoomList { get; set; } // 8.敌人飞机爆炸效果单一实例 public List<BoomEnemy> EnemyBoomList { get; set; } #endregion // 为游戏屏幕增加一个游戏对象 public void AddGameObject(GameObject go) { if (go is GameBackground) { this.Background = go as GameBackground; } if (go is GameTitle) { this.Title = go as GameTitle; } if (go is PlanePlayer) { this.Player = go as PlanePlayer; } if (go is BulletPlayer) { if(this.PlayerBulletList == null) { this.PlayerBulletList = new List<BulletPlayer>(); } this.PlayerBulletList.Add(go as BulletPlayer); } if (go is PlaneEnemy) { if (this.EnemyList == null) { this.EnemyList = new List<PlaneEnemy>(); } this.EnemyList.Add(go as PlaneEnemy); } if(go is BulletEnemy) { if (this.EnemyBulletList == null) { this.EnemyBulletList = new List<BulletEnemy>(); } this.EnemyBulletList.Add(go as BulletEnemy); } if (go is BoomPlayer) { if (this.PlayerBoomList == null) { this.PlayerBoomList = new List<BoomPlayer>(); } this.PlayerBoomList.Add(go as BoomPlayer); } if (go is BoomEnemy) { if (this.EnemyBoomList == null) { this.EnemyBoomList = new List<BoomEnemy>(); } this.EnemyBoomList.Add(go as BoomEnemy); } } // 移除指定的游戏对象 public void RemoveGameObject(GameObject go) { if (go is GameTitle) { this.Title = null; } if (go is BulletPlayer) { this.PlayerBulletList.Remove(go as BulletPlayer); } if (go is PlaneEnemy) { this.EnemyList.Remove(go as PlaneEnemy); } if (go is BulletEnemy) { this.EnemyBulletList.Remove(go as BulletEnemy); } if (go is BoomPlayer) { this.PlayerBoomList.Remove(go as BoomPlayer); } if (go is BoomEnemy) { this.EnemyBoomList.Remove(go as BoomEnemy); } } // 为游戏屏幕绘制游戏背景对象 public void DrawFirstBackground(Graphics g) { if (Background != null) { Background.Draw(g); } if (Title != null) { Title.Draw(g); } if (Player != null) { Player.Draw(g); } } // 为游戏屏幕绘制所有游戏对象 public void DrawGameObjects(Graphics g) { if (Background != null) { Background.Draw(g); } if (Player != null) { Player.Draw(g); } if (PlayerBulletList != null) { for (int i = 0; i < PlayerBulletList.Count; i++) { PlayerBulletList[i].Draw(g); } } if (EnemyList != null) { for (int i = 0; i < EnemyList.Count; i++) { EnemyList[i].Draw(g); } } if(EnemyBulletList != null) { for (int i = 0; i < EnemyBulletList.Count; i++) { EnemyBulletList[i].Draw(g); } } if (PlayerBoomList != null) { for (int i = 0; i < PlayerBoomList.Count; i++) { PlayerBoomList[i].Draw(g); } } if (EnemyBoomList != null) { for (int i = 0; i < EnemyBoomList.Count; i++) { EnemyBoomList[i].Draw(g); } } } // 玩家得分 public int Score { get; set; } }
这里借助单例模式,保证玩家飞机对象只有一个存储,电脑飞机集合也只有一个,而具体的电脑飞机对象则分别在单例类中的集合中进行Add和Remove。
(3)设计CollisionDetect方法:不停地进行碰撞检测
①Rectangle的IntersectsWith方法
在游戏界面中,任何一个游戏对象我们都可以视为一个矩形区域(Rectangle类实例),它的坐标是X轴和Y轴,它还有长度和宽度,可以轻松地确定一个它所在的矩形区域。那么,我们可以通过Rectangle的IntersectsWith方法确定两个Rectangle是否存在重叠,如果有重叠,此方法将返回 true;否则将返回 false。那么,在飞机大战中主要是判断两种情况:一是玩家或电脑飞机发射的子弹是否击中了对方?二是玩家是否撞到了敌人飞机?
②在定时器事件中定期执行碰撞检测方法
// 碰撞检测方法 public void CollisionDetect() { #region 1.判断玩家的子弹是否打到了敌人飞机身上 for (int i = 0; i < PlayerBulletList.Count; i++) { for (int j = 0; j < EnemyList.Count; j++) { if(PlayerBulletList[i].GetRectangle().IntersectsWith(EnemyList[j].GetRectangle())) { // 1.敌人的生命值减少 EnemyList[j].Life -= PlayerBulletList[i].Power; // 2.生命值减少后判断敌人是否死亡 EnemyList[j].IsOver(); // 3.玩家子弹打到了敌人身上后将玩家子弹销毁 PlayerBulletList.Remove(PlayerBulletList[i]); break; } } } #endregion #region 2.判断敌人的子弹是否打到了玩家飞机身上 for (int i = 0; i < EnemyBulletList.Count; i++) { if(EnemyBulletList[i].GetRectangle().IntersectsWith(Player.GetRectangle())) { // 使玩家发生一次爆炸但不阵亡 Player.IsOver(); break; } } #endregion #region 3.判断敌人飞机是否和玩家飞机相撞 for (int i = 0; i < EnemyList.Count; i++) { if (EnemyList[i].GetRectangle().IntersectsWith(Player.GetRectangle())) { EnemyList[i].Life = 0; EnemyList[i].IsOver(); break; } } #endregion }
3.2 服务端开发
(1)创建监听玩家连接的Socket,不停地监听玩家的游戏连接请求
private void btnBeginListen_Click(object sender, EventArgs e) { if (isEndService) { SetTxtReadOnly(); if (socketWatch == null) { // 创建Socket->绑定IP与端口->设置监听队列的长度->开启监听连接 socketWatch = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); socketWatch.Bind(new IPEndPoint(IPAddress.Parse(txtIPAddress.Text), int.Parse(txtPort.Text))); socketWatch.Listen(10); threadWatch = new Thread(ListenClientConnect); threadWatch.IsBackground = true; threadWatch.Start(socketWatch); } isEndService = false; this.btnStartGame.Enabled = true; ShowMessage("^_^:飞机大战服务器端启动服务成功,正在等待玩家进入游戏..."); } else { MessageBox.Show("服务已启动,请不要重复启动服务!", "提示", MessageBoxButtons.OK, MessageBoxIcon.Warning); } } private void ListenClientConnect(object obj) { Socket serverSocket = obj as Socket; while (!isEndService) { Socket proxSocket = null; try { // 注意:Accept方法会阻断当前所在的线程 proxSocket = serverSocket.Accept(); dictClients.Add(proxSocket.RemoteEndPoint.ToString(), proxSocket); ShowMessage("*_*:玩家<" + proxSocket.RemoteEndPoint.ToString() + ">连接上了,请准备开始游戏。"); playerCount++; ThreadPool.QueueUserWorkItem(new WaitCallback(ReceiveData), proxSocket); } catch (SocketException ex) { ShowMessage("#_#:异常【" + ex.Message + "】"); // 让方法结束,终结当前监听客户端数据的异步线程 return; } catch (Exception ex) { ShowMessage("#_#:异常【" + ex.Message + "】"); // 让方法结束,终结当前监听客户端数据的异步线程 return; } } }
在.NET中进行网络编程,一般都会涉及到Socket,其过程大概会经历如下图所示的流程:
PS:Socket非常类似于电话插座,以一个电话网为例:电话的通话双方相当于相互通信的2个程序,电话号码就是IP地址。任何用户在通话之前,首先要占有一部电话机,相当于申请一个Socket;同时要知道对方的号码,相当于对方有一个固定的Socket。然后向对方拨号呼叫,相当于发出连接请求。对方假如在场并空闲,拿起电话话筒,双方就可以正式通话,相当于连接成功。双方通话的过程,是一方向电话机发出信号和对方从电话机接收信号的过程,相当于向Socket发送数据和从Socket接收数据。通话结束后,一方挂起电话机相当于关闭socket,撤消连接。
(2)使用线程池ThreadPool新开线程,不停地接收玩家发送的信息
ThreadPool.QueueUserWorkItem(new WaitCallback(ReceiveData), proxSocket);
在监听线程中使用了线程池,开启了一个新的线程来接收客户端发送过来的数据,那么这个ReceiveData方法如何实现的:
private void ReceiveData(object obj) { Socket proxSocket = obj as Socket; byte[] data = new byte[1024 * 1024]; int length = 0; while (!isEndService) { try { length = proxSocket.Receive(data); } catch (SocketException ex) { ShowMessage("#_#:异常【" + ex.Message + "】"); StopConnection(proxSocket); // 让方法结束,终结当前接收客户端数据的异步线程 return; } catch (Exception ex) { ShowMessage("#_#:异常【" + ex.Message + "】"); StopConnection(proxSocket); // 让方法结束,终结当前接收客户端数据的异步线程 return; } if (length <= 0) { ShowMessage("*_*:玩家<" + proxSocket.RemoteEndPoint.ToString() + ">退出了游戏"); StopConnection(proxSocket); if (playerCount > 0) { playerCount--; } // 让方法结束,终结当前接收客户端数据的异步线程 return; } else { // 接受客户端发送过来的消息 string playerScore = Encoding.UTF8.GetString(data, 0, length); dictScores.Add(proxSocket.RemoteEndPoint.ToString(), Convert.ToInt32(playerScore)); if (dictScores.Count > 0 && dictScores.Count == playerCount) { ComparePlayerScores(); } } } }
(3)当所有玩家都发送完游戏分数,服务器端对所有分数进行排序并发送最终名次
private void ComparePlayerScores() { List<KeyValuePair<string, int>> scoreList = dictScores.OrderByDescending(s => s.Value).ToList(); for (int i = 0; i < scoreList.Count; i++) { string result = string.Format("您本次的成绩是第{0}名,分数为{1}分", i + 1, scoreList[i].Value); byte[] bytes = Encoding.UTF8.GetBytes(result); byte[] data = new byte[bytes.Length + 1]; data[0] = 2; Buffer.BlockCopy(bytes, 0, data, 1, bytes.Length); dictClients[scoreList[i].Key].Send(data, 0, data.Length, SocketFlags.None); } }
在服务端有一个键值对集合专门存储玩家对应分数,然后对其按分数进行降序排序,排序后再遍历集合一一向玩家发送名次信息;
四、个人开发小结
4.1 服务端开启服务
服务器端主要开启监听玩家连接请求的服务,当几个处在同一局域网的玩家连接后,服务端管理员点击“开始游戏”则客户端会启动游戏。
4.2 客户端开始游戏
在客户端中,玩家飞机可以通过不停地发射子弹向不同类型的电脑飞机来获取得分,但是如果被敌人飞机的子弹击中分数也会被扣去一部分。
4.3 服务端计算成绩客户端显示
当两个玩家连接游戏服务端后,便开始了“打飞机”的战斗,当指定时间后游戏结束,显示各自的游戏名次和分数。
当然,还有很多核心的内容没有实现。希望有兴趣的童鞋可以去继续完善实现,这里提供一个我的飞机大战实现仅供参考,谢谢!
参考资料
赵剑宇,《C#开发太空大战》:http://open.itcast.cn/net/3-106.html
附件下载
MyPlaneGame:https://github.com/EdisonChou/The-Fighting-of-Planes