C#飞机大战实战:WinForm游戏开发与面向对象设计
2026/8/7 22:19:30 网站建设 项目流程

1. 项目概述与核心价值

最近在带几个刚入门C#的朋友做项目,发现很多人学完基础语法后,面对一个完整的项目就有点无从下手。他们常问:“老师,我学了变量、循环、类,但怎么把它们组合成一个能跑起来的程序呢?” 这让我想起了自己初学时的经历,当时也是通过一个“飞机大战”这样的小游戏,才真正把C#的面向对象、事件处理、绘图这些知识点给串了起来。所以,今天我就以“C#核心(25)练手小项目:飞机大战”为题,把这个经典练手项目的完整实现思路、核心代码和避坑经验,掰开揉碎了讲给你听。

这个项目绝不仅仅是一个简单的游戏。它本质上是一个综合性的C#桌面应用开发实战案例,完美融合了WinForm窗体编程、GDI+图形绘制、键盘事件处理、碰撞检测算法、面向对象设计以及资源文件管理等多个核心技能点。对于刚掌握C#语法、想向实际应用迈进的开发者来说,它是一个绝佳的“试金石”。通过亲手实现它,你能直观地理解如何将零散的知识点组织成一个有逻辑、可交互的软件系统。项目最终会呈现一个由玩家控制的飞机,通过键盘移动、发射子弹击落敌机,并实时计算得分和生命值的完整游戏循环。

2. 项目整体架构与设计思路

2.1 为什么选择WinForm和GDI+?

很多新手可能会纠结:现在不是WPF、Unity更流行吗?为什么还要用“老掉牙”的WinForm和GDI+?这里面的考量很实际。首先,学习成本低。WinForm的拖拽式界面设计和事件驱动模型非常直观,能让你快速建立起“窗体-控件-事件”的GUI编程思维,而无需过早陷入XAML或游戏引擎的复杂概念中。其次,GDI+绘图足够轻量。对于“飞机大战”这种2D、精灵数量有限的游戏,GDI+的Graphics对象和Bitmap类完全够用,性能开销小,代码也直接,便于你理解“如何在屏幕上画出一帧图像”这个最本质的图形渲染过程。最后,聚焦C#核心。这个项目的首要目标是巩固C#语言特性和面向对象设计,而不是学习某个复杂的框架。WinForm+GDI+的组合能让你把主要精力放在类的设计、逻辑的编写上。

2.2 核心类的职责划分(面向对象设计)

一个清晰、高内聚低耦合的类结构是项目成功的关键。我们不能把所有代码都堆在Form的后台文件里。参考常见的实现,我建议采用以下五个核心类:

  1. GameForm (主窗体类):继承自Form。它是游戏的舞台和总指挥,负责窗体的初始化、游戏主循环的驱动(通常用一个Timer控件)、键盘事件的监听与分发,以及调用各个对象的Draw方法进行画面渲染。
  2. PlayerPlane (玩家飞机类):代表玩家控制的实体。核心属性包括位置(X,Y)、生命值(HP)、移动速度等。核心方法有Move()(根据按键更新位置)、Shoot()(创建子弹对象)和Draw()(在指定位置绘制自身图像)。
  3. EnemyPlane (敌机类):代表敌方单位。除了基础属性,还需要有移动模式(如直线下降、左右摆动)、分数价值等。它的Update()方法负责按模式移动,Draw()方法绘制自身。
  4. Bullet (子弹基类/我方子弹类):代表飞行道具。属性包括位置、速度向量(SpeedX,SpeedY)、威力等。Update()方法让子弹按速度移动,Draw()方法绘制子弹轨迹(可以是图片,也可以是一个小矩形)。
  5. CollisionManager (碰撞检测管理器类):这是一个关键的服务类。它不负责绘制,只负责逻辑判断。它的CheckCollision()方法会遍历所有我方子弹和所有敌机,判断它们的矩形区域是否相交,如果相交,则触发敌机被击毁、子弹消失、分数增加等一系列事件。

注意:很多初学者会把碰撞检测逻辑直接写在GameForm的循环里,导致代码臃肿。将其抽离成一个独立的类,是迈向良好架构的第一步。这体现了“单一职责原则”。

2.3 游戏主循环与双缓冲技术

游戏的核心是一个不断运行的循环,在WinForm中,我们通常使用System.Windows.Forms.Timer控件来模拟这个循环。将TimerInterval属性设为比如16毫秒(约60FPS),在它的Tick事件中,我们按顺序执行以下步骤:

  1. 更新(Update):调用所有活动对象(玩家、敌机、子弹)的Update方法,更新它们的位置、状态。
  2. 碰撞检测(Collision):调用CollisionManager.CheckCollision(),处理所有碰撞事件。
  3. 渲染(Draw):清除画布,然后按从底到顶的顺序(背景->敌机->子弹->玩家飞机),调用所有对象的Draw方法,将最新画面绘制到窗体上。

这里会遇到一个经典问题:直接绘制到窗体上会导致严重的画面闪烁。这是因为Graphics直接在屏幕设备上绘图,中间过程会被用户看到。解决方案是双缓冲技术。原理很简单:我们不在屏幕上直接画,而是先在一块内存中的“画布”(Bitmap)上画好一整帧图像,画完之后,一次性将这块内存画布的内容“贴”到屏幕窗体上。这个切换过程极快,人眼无法察觉,从而消除了闪烁。

在C# WinForm中实现双缓冲非常简单,你只需要在GameForm的构造函数中设置这两个属性:

this.SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint, true); this.UpdateStyles();

这行代码启用了Windows窗体自带的双缓冲支持。在Paint事件或自定义的绘制方法中,我们创建一个与窗体客户区同样大小的Bitmap作为缓冲画布,所有绘图操作都针对这个BitmapGraphics对象进行,最后将这个Bitmap绘制到窗体本身的Graphics上。

3. 核心功能模块的详细实现

3.1 玩家控制与键盘事件处理

玩家飞机的控制需要实时响应键盘输入。WinForm提供了KeyDownKeyUpKeyPress事件。对于游戏这种需要持续响应的场景,我们通常采用“状态记录”法,而不是在事件中直接移动。

首先,在GameForm中定义几个标志位:

private bool isUpPressed = false; private bool isDownPressed = false; private bool isLeftPressed = false; private bool isRightPressed = false; private bool isShootPressed = false;

KeyDown事件中,根据按下的键设置对应的标志位为true

private void GameForm_KeyDown(object sender, KeyEventArgs e) { switch (e.KeyCode) { case Keys.W: isUpPressed = true; break; case Keys.S: isDownPressed = true; break; case Keys.A: isLeftPressed = true; break; case Keys.D: isRightPressed = true; break; case Keys.J: isShootPressed = true; break; } // 防止按键事件被系统处理(比如防止按方向键时窗体焦点移动) e.Handled = true; }

KeyUp事件中,将对应的标志位重置为false

然后,在游戏主循环(Timer_Tick)的Update阶段,我们根据这些标志位来更新玩家飞机的位置和触发射击:

private void GameLoop_Tick(object sender, EventArgs e) { // 1. 更新玩家位置 if (isUpPressed) playerPlane.Y -= playerPlane.Speed; if (isDownPressed) playerPlane.Y += playerPlane.Speed; if (isLeftPressed) playerPlane.X -= playerPlane.Speed; if (isRightPressed) playerPlane.X += playerPlane.Speed; // 2. 边界检测,防止飞机飞出屏幕 playerPlane.X = Math.Max(0, Math.Min(clientWidth - playerPlane.Width, playerPlane.X)); playerPlane.Y = Math.Max(0, Math.Min(clientHeight - playerPlane.Height, playerPlane.Y)); // 3. 处理射击 if (isShootPressed) { // 这里可以控制射击频率,例如每5帧发射一次 if (shootCooldown <= 0) { playerPlane.Shoot(); shootCooldown = 5; // 重置冷却时间 } else { shootCooldown--; } } // ... 其他更新和渲染逻辑 }

实操心得:使用标志位法而不是在KeyDown中直接移动,能完美支持多个方向键同时按下的斜向移动,并且移动手感更加平滑。e.Handled = true这行代码很重要,它能阻止窗体处理某些系统快捷键,避免游戏过程中出现意外行为(比如按空格键暂停)。

3.2 敌机与子弹系统的生成与管理

敌机和子弹都是动态创建和销毁的对象,管理不好容易造成内存泄漏或逻辑混乱。一个通用的做法是使用List<T>集合来管理它们。

GameForm中声明两个列表:

private List<EnemyPlane> enemyList = new List<EnemyPlane>(); private List<Bullet> bulletList = new List<Bullet>();

敌机生成:我们可以设置一个敌机生成计数器,在主循环中累积,达到一定帧数就创建一个新的敌机实例并加入enemyList。敌机的初始位置可以随机在屏幕顶部水平方向生成。

private int enemySpawnTimer = 0; private void UpdateGame() { // ... 其他更新 enemySpawnTimer++; if (enemySpawnTimer >= 60) // 假设每秒生成一个敌机(60帧) { Random rand = new Random(); int x = rand.Next(0, this.ClientSize.Width - enemyWidth); EnemyPlane newEnemy = new EnemyPlane(x, 0); // 从顶部出现 enemyList.Add(newEnemy); enemySpawnTimer = 0; } }

子弹生成:当玩家按下射击键时,在PlayerPlane.Shoot()方法中,创建一颗或多颗子弹,并加入到bulletList中。子弹的初始位置通常是玩家飞机的机头位置。

public void Shoot() { // 创建一颗从飞机中心向上飞的子弹 Bullet newBullet = new Bullet(this.X + this.Width / 2, this.Y, 0, -10); // 注意:这里需要将子弹添加到主窗体的bulletList中,通常通过事件或直接传递引用实现。 // 一种更解耦的方式是定义一个事件,让GameForm订阅。 OnShoot?.Invoke(newBullet); }

对象更新与清理:在主循环的Update阶段,我们需要遍历这两个列表,调用每个对象的Update()方法。同时,必须及时清理无效对象。对于飞出屏幕的子弹、被击毁或飞出屏幕底部的敌机,要将它们从列表中移除,否则列表会无限膨胀,导致游戏越来越卡。

// 更新并清理子弹 for (int i = bulletList.Count - 1; i >= 0; i--) { bulletList[i].Update(); if (bulletList[i].IsOutOfScreen(this.ClientRectangle)) { bulletList.RemoveAt(i); } } // 更新并清理敌机 for (int i = enemyList.Count - 1; i >= 0; i--) { enemyList[i].Update(); if (enemyList[i].IsDestroyed || enemyList[i].IsOutOfScreen(this.ClientRectangle)) { enemyList.RemoveAt(i); } }

重要提示:这里使用了从后向前遍历for (int i = list.Count - 1; i >= 0; i--))并在循环内RemoveAt(i)。这是安全的做法。如果从前向后遍历,移除元素时会改变后面元素的索引,容易导致漏删或索引越界错误。

3.3 碰撞检测的实现与优化

碰撞检测是游戏逻辑的核心。对于“飞机大战”这种2D游戏,最常用且高效的方法是矩形碰撞检测(AABB,Axis-Aligned Bounding Box)。即,为每个飞机和子弹定义一个与其图像大小相近的矩形区域,判断两个矩形是否相交。

CollisionManager类中:

public static void CheckCollisions(List<Bullet> bullets, List<EnemyPlane> enemies, ref int score) { // 同样,从后向前遍历,因为可能要在循环中移除元素 for (int i = bullets.Count - 1; i >= 0; i--) { Rectangle bulletRect = bullets[i].GetBounds(); for (int j = enemies.Count - 1; j >= 0; j--) { Rectangle enemyRect = enemies[j].GetBounds(); if (bulletRect.IntersectsWith(enemyRect)) { // 碰撞发生! // 1. 敌机被摧毁 enemies[j].IsDestroyed = true; // 2. 子弹消失 bullets.RemoveAt(i); // 3. 增加分数 score += enemies[j].ScoreValue; // 4. 播放音效(如果有) // PlaySound("explosion.wav"); // 注意:一颗子弹只能击中一个敌机,所以内层循环可以break break; } } } }

这里的GetBounds()方法在每个游戏对象类中实现,返回一个代表其当前图像位置的Rectangle对象。IntersectsWith()Rectangle结构体自带的方法,用于判断两个矩形是否相交,性能很好。

性能优化思考:上述是两重循环的暴力检测,当对象数量(N)很多时,复杂度是O(N²)。对于“飞机大战”这种规模的项目,完全够用。但如果想深入优化,可以考虑:

  1. 空间划分:将屏幕划分为网格,只检测在同一网格或相邻网格内的对象。
  2. Broad-Phase & Narrow-Phase:先进行粗略检测(如比较对象的外接圆距离),对可能碰撞的对象再进行精确的矩形检测。 不过,对于新手项目,理解并实现基础的矩形碰撞检测已经足够。

3.4 资源管理:图片、音效与游戏状态

图片资源:将飞机、子弹、背景、爆炸效果的图片文件(如.png, .jpg)添加到项目的Resources文件夹,并在项目属性中将其生成操作设置为“嵌入的资源”或更简单地,直接复制到输出目录。在代码中,可以通过Image.FromFile(“path”)或直接嵌入资源方式加载。

// 方法1:文件路径(需确保文件在exe同级目录下) playerImage = Image.FromFile(@“Resources\player.png”); // 方法2:资源文件(推荐,便于部署) // 首先在解决方案资源管理器中,将图片添加到项目,并设置其“生成操作”为“嵌入的资源”。 // 然后通过Assembly读取 System.Reflection.Assembly myAssembly = System.Reflection.Assembly.GetExecutingAssembly(); Stream imageStream = myAssembly.GetManifestResourceStream(“YourNamespace.Resources.player.png”); playerImage = Image.FromStream(imageStream);

音效播放:可以使用System.Media.SoundPlayer播放简单的.wav音效。

SoundPlayer explosionSound = new SoundPlayer(@“Resources\explosion.wav”); explosionSound.Play(); // 注意:Play()是异步的,不会阻塞游戏循环

对于短促的音效(如射击、爆炸),这种方式很合适。如果需要播放背景音乐或更复杂的音频控制,可以考虑使用NAudio等第三方库。

游戏状态管理:游戏至少应有三种状态:PlayingPausedGameOver。用一个枚举变量GameState来记录当前状态。在主循环和事件处理中,根据不同的状态执行不同的逻辑。

public enum GameState { Playing, Paused, GameOver } private GameState currentState = GameState.Playing; private void GameLoop_Tick(object sender, EventArgs e) { if (currentState != GameState.Playing) return; // 非进行状态,不更新游戏逻辑 // ... 正常的更新和渲染逻辑 } private void GameForm_KeyDown(object sender, KeyEventArgs e) { if (e.KeyCode == Keys.P) { // 按P键暂停/继续 if (currentState == GameState.Playing) { currentState = GameState.Paused; gameTimer.Stop(); } else if (currentState == GameState.Paused) { currentState = GameState.Playing; gameTimer.Start(); } } }

4. 完整实现步骤与代码剖析

4.1 第一步:创建项目与搭建主窗体

打开Visual Studio(2019/2022均可),新建一个“Windows窗体应用(.NET Framework)”项目,命名为PlaneWar。将默认的Form1重命名为GameForm

首先,设置窗体属性,使其更适合作为游戏窗口:

public partial class GameForm : Form { private System.Windows.Forms.Timer gameTimer; private Bitmap bufferBitmap; // 双缓冲用的位图 private Graphics bufferGraphics; // 双缓冲用的绘图对象 public GameForm() { InitializeComponent(); this.Text = "C# 飞机大战"; this.ClientSize = new Size(800, 600); // 设置游戏区域大小 this.FormBorderStyle = FormBorderStyle.FixedSingle; // 固定大小,不能拖拽 this.MaximizeBox = false; // 禁用最大化 this.StartPosition = FormStartPosition.CenterScreen; // 启用双缓冲,减少闪烁 this.SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint, true); this.UpdateStyles(); // 初始化游戏计时器 gameTimer = new System.Windows.Forms.Timer(); gameTimer.Interval = 16; // 约60帧/秒 gameTimer.Tick += GameLoop_Tick; // 初始化双缓冲画布(大小与客户区一致) ResetBuffer(); // 绑定键盘事件 this.KeyDown += GameForm_KeyDown; this.KeyUp += GameForm_KeyUp; this.Paint += GameForm_Paint; this.Resize += GameForm_Resize; // 窗体大小改变时,需要重置缓冲画布 } private void ResetBuffer() { if (bufferBitmap != null) { bufferBitmap.Dispose(); bufferGraphics.Dispose(); } bufferBitmap = new Bitmap(this.ClientSize.Width, this.ClientSize.Height); bufferGraphics = Graphics.FromImage(bufferBitmap); } private void GameForm_Resize(object sender, EventArgs e) { ResetBuffer(); } }

4.2 第二步:实现玩家飞机类(PlayerPlane)

在项目中添加一个名为PlayerPlane的类。

public class PlayerPlane { public int X { get; set; } public int Y { get; set; } public int Width { get; private set; } public int Height { get; private set; } public int Speed { get; set; } public int Health { get; set; } public Image Sprite { get; private set; } // 事件:当飞机射击时触发,参数是创建的子弹 public event Action<Bullet> OnShoot; public PlayerPlane(int startX, int startY) { X = startX; Y = startY; Speed = 5; Health = 3; // 加载图片,这里假设图片已作为资源嵌入或放在运行目录 // 实际项目中请使用更健壮的资源加载方式 try { Sprite = Image.FromFile(@“player.png”); Width = Sprite.Width; Height = Sprite.Height; } catch { // 如果图片加载失败,用默认的矩形代替 Width = 50; Height = 50; Sprite = null; } } public void Move(int deltaX, int deltaY) { X += deltaX; Y += deltaY; } public void Shoot() { if (OnShoot != null) { // 创建一颗子弹,位置在飞机头部中心,向上飞行 Bullet bullet = new Bullet(X + Width / 2 - 2, Y, 0, -10, true); // 最后一个参数true表示是我方子弹 OnShoot.Invoke(bullet); } } public void Draw(Graphics g) { if (Sprite != null) { g.DrawImage(Sprite, X, Y, Width, Height); } else { // 备用绘制:一个绿色矩形 using (Brush brush = new SolidBrush(Color.LimeGreen)) { g.FillRectangle(brush, X, Y, Width, Height); } } } public Rectangle GetBounds() { return new Rectangle(X, Y, Width, Height); } }

4.3 第三步:实现敌机类(EnemyPlane)与子弹类(Bullet)

EnemyPlane类与PlayerPlane类似,但移动逻辑是自动的(如匀速下落或左右摆动)。

public class EnemyPlane { public int X { get; set; } public int Y { get; set; } public int Width { get; private set; } public int Height { get; private set; } public int SpeedY { get; set; } // 垂直下落速度 public int SpeedX { get; set; } // 水平摆动速度 public bool IsDestroyed { get; set; } public int ScoreValue { get; set; } // 击毁后获得的分数 public Image Sprite { get; private set; } public EnemyPlane(int startX, int startY) { X = startX; Y = startY; SpeedY = 3; SpeedX = 2; IsDestroyed = false; ScoreValue = 100; // 加载敌机图片 try { Sprite = Image.FromFile(@“enemy.png”); Width = Sprite.Width; Height = Sprite.Height; } catch { Width = 40; Height = 40; Sprite = null; } } public void Update() { Y += SpeedY; X += SpeedX; // 简单的左右边界反弹逻辑 if (X <= 0 || X + Width >= 800) // 假设屏幕宽800 { SpeedX = -SpeedX; } } public void Draw(Graphics g) { if (!IsDestroyed) { if (Sprite != null) g.DrawImage(Sprite, X, Y, Width, Height); else { using (Brush brush = new SolidBrush(Color.Red)) { g.FillRectangle(brush, X, Y, Width, Height); } } } // 被摧毁后可以绘制爆炸动画,这里简化处理 } public Rectangle GetBounds() { return new Rectangle(X, Y, Width, Height); } public bool IsOutOfScreen(Rectangle screenBounds) { return Y > screenBounds.Height; } }

Bullet类相对简单:

public class Bullet { public int X { get; set; } public int Y { get; set; } public int Width { get; private set; } public int Height { get; private set; } public int SpeedX { get; set; } public int SpeedY { get; set; } public bool IsPlayerBullet { get; set; } // 区分敌我子弹 public Bullet(int startX, int startY, int speedX, int speedY, bool isPlayerBullet) { X = startX; Y = startY; SpeedX = speedX; SpeedY = speedY; IsPlayerBullet = isPlayerBullet; Width = 4; Height = 10; } public void Update() { X += SpeedX; Y += SpeedY; } public void Draw(Graphics g) { Color bulletColor = IsPlayerBullet ? Color.Yellow : Color.White; using (Brush brush = new SolidBrush(bulletColor)) { g.FillRectangle(brush, X - Width / 2, Y, Width, Height); // 让子弹中心点在X坐标上 } } public Rectangle GetBounds() { return new Rectangle(X - Width / 2, Y, Width, Height); } public bool IsOutOfScreen(Rectangle screenBounds) { return Y < -Height || Y > screenBounds.Height || X < -Width || X > screenBounds.Width; } }

4.4 第四步:整合游戏主循环与碰撞检测

回到GameForm类,补充完整的游戏逻辑字段和主循环:

public partial class GameForm : Form { // ... 之前已有的字段和构造函数 ... private PlayerPlane player; private List<EnemyPlane> enemies = new List<EnemyPlane>(); private List<Bullet> bullets = new List<Bullet>(); private Random random = new Random(); private int score = 0; private int enemySpawnCounter = 0; private const int ENEMY_SPAWN_INTERVAL = 90; // 每90帧生成一个敌机 private GameState gameState = GameState.Playing; private Font uiFont = new Font(“Arial”, 14); private void StartGame() { player = new PlayerPlane(this.ClientSize.Width / 2, this.ClientSize.Height - 100); player.OnShoot += (bullet) => bullets.Add(bullet); // 订阅玩家射击事件 enemies.Clear(); bullets.Clear(); score = 0; gameState = GameState.Playing; gameTimer.Start(); } private void GameLoop_Tick(object sender, EventArgs e) { if (gameState != GameState.Playing) return; // --- 1. 更新(Update) --- // 更新所有子弹 for (int i = bullets.Count - 1; i >= 0; i--) { bullets[i].Update(); if (bullets[i].IsOutOfScreen(this.ClientRectangle)) { bullets.RemoveAt(i); } } // 更新所有敌机 for (int i = enemies.Count - 1; i >= 0; i--) { enemies[i].Update(); if (enemies[i].IsOutOfScreen(this.ClientRectangle) || enemies[i].IsDestroyed) { enemies.RemoveAt(i); } } // 生成新敌机 enemySpawnCounter++; if (enemySpawnCounter >= ENEMY_SPAWN_INTERVAL) { int x = random.Next(0, this.ClientSize.Width - 40); enemies.Add(new EnemyPlane(x, -40)); // 从屏幕上方外生成 enemySpawnCounter = 0; } // --- 2. 碰撞检测(Collision) --- CheckCollisions(); // --- 3. 渲染(Draw) --- // 清空缓冲画布 bufferGraphics.Clear(Color.Black); // 绘制背景(这里简单用黑色,可以换成星空图) // bufferGraphics.DrawImage(backgroundImage, 0, 0); // 绘制所有游戏对象 foreach (var enemy in enemies) { enemy.Draw(bufferGraphics); } foreach (var bullet in bullets) { bullet.Draw(bufferGraphics); } player.Draw(bufferGraphics); // 绘制UI(分数、生命值) DrawUI(bufferGraphics); // 将缓冲画布的内容一次性绘制到窗体上 this.Invalidate(); // 触发Paint事件 } private void CheckCollisions() { // 检测我方子弹与敌机的碰撞 for (int i = bullets.Count - 1; i >= 0; i--) { if (!bullets[i].IsPlayerBullet) continue; // 只处理我方子弹 Rectangle bulletRect = bullets[i].GetBounds(); for (int j = enemies.Count - 1; j >= 0; j--) { if (enemies[j].IsDestroyed) continue; Rectangle enemyRect = enemies[j].GetBounds(); if (bulletRect.IntersectsWith(enemyRect)) { // 碰撞发生 enemies[j].IsDestroyed = true; bullets.RemoveAt(i); score += enemies[j].ScoreValue; // 可以在这里触发爆炸效果或音效 break; // 一颗子弹只能击中一个敌机 } } } // (可选)检测敌机与玩家飞机的碰撞(即敌机撞到我方) Rectangle playerRect = player.GetBounds(); foreach (var enemy in enemies) { if (!enemy.IsDestroyed && playerRect.IntersectsWith(enemy.GetBounds())) { player.Health--; enemy.IsDestroyed = true; if (player.Health <= 0) { GameOver(); } break; } } } private void DrawUI(Graphics g) { string scoreText = $"得分: {score}"; string healthText = $"生命: {player.Health}"; g.DrawString(scoreText, uiFont, Brushes.White, 10, 10); g.DrawString(healthText, uiFont, Brushes.White, this.ClientSize.Width - 100, 10); if (gameState == GameState.GameOver) { string gameOverText = "游戏结束!按R键重新开始"; SizeF textSize = g.MeasureString(gameOverText, uiFont); g.DrawString(gameOverText, uiFont, Brushes.Red, (this.ClientSize.Width - textSize.Width) / 2, (this.ClientSize.Height - textSize.Height) / 2); } } private void GameOver() { gameState = GameState.GameOver; gameTimer.Stop(); } private void GameForm_Paint(object sender, PaintEventArgs e) { // 将双缓冲位图绘制到窗体上 if (bufferBitmap != null) { e.Graphics.DrawImage(bufferBitmap, 0, 0); } } // ... 键盘事件处理(KeyDown, KeyUp) ... // 在KeyDown事件中,增加对R键(重新开始)的处理 private void GameForm_KeyDown(object sender, KeyEventArgs e) { if (e.KeyCode == Keys.R && gameState == GameState.GameOver) { StartGame(); } // ... 原有的WASDJ键处理逻辑 ... } }

5. 常见问题、调试技巧与性能优化

5.1 画面闪烁与撕裂问题

即使启用了双缓冲,有时仍可能遇到轻微的闪烁或画面撕裂。这通常是因为在Paint事件之外进行了绘制操作,或者窗体背景被自动擦除。

解决方案

  1. 确保所有绘制操作都在Paint事件或其调用的方法中完成,主循环中只更新逻辑和Invalidate()
  2. 在窗体构造函数中再添加一行:this.DoubleBuffered = true;(这是对窗体级别的双缓冲设置,与SetStyle结合使用效果更佳)。
  3. Paint事件处理程序的开头加上:e.Graphics.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;可以改善绘制图形的边缘锯齿,但会略微增加性能开销,可根据需要选择。

5.2 游戏运行速度不稳定(帧率波动)

System.Windows.Forms.Timer的精度并不高,且其Tick事件是在UI线程上执行的,如果某一帧的计算或绘制特别耗时,就会导致帧率下降,游戏卡顿。

解决方案

  1. 使用高精度计时器:考虑使用System.Threading.TimerSystem.Diagnostics.Stopwatch配合Thread.Sleep在单独的线程中控制游戏循环。但这会涉及跨线程更新UI的问题(需要使用Invoke),复杂度较高。
  2. 基于时间的运动:这是更重要的优化。目前我们的移动代码是X += Speed,这意味着帧率越高,物体移动越快。应该改为基于时间的运动:X += Speed * deltaTime,其中deltaTime是上一帧到这一帧经过的时间(以秒为单位)。这样无论帧率是30还是60,物体每秒移动的距离是恒定的。
    private Stopwatch gameStopwatch = new Stopwatch(); private float lastFrameTime; private void GameLoop_Tick(object sender, EventArgs e) { float currentTime = (float)gameStopwatch.Elapsed.TotalSeconds; float deltaTime = currentTime - lastFrameTime; lastFrameTime = currentTime; // 更新时使用deltaTime player.X += (int)(player.SpeedX * deltaTime); // ... 其他更新 }
    对于新手项目,如果卡顿不严重,可以暂不实现,但这是一个非常重要的游戏编程概念。

5.3 碰撞检测不准确或“穿透”现象

矩形检测有时会因物体速度过快,在两帧之间“穿过”彼此,导致检测失败。

解决方案

  1. 增加碰撞盒:有时图片有透明边缘,用图片大小作为碰撞盒会偏大。可以定义一个比实际图像稍小的矩形作为碰撞盒。
  2. 使用更精细的检测:对于高速移动的小物体(如子弹),可以考虑使用射线检测(从上一帧位置到当前帧位置画一条线段,检测线段是否与目标矩形相交),但这更复杂。
  3. 增加更新频率:提高游戏循环的频率(减少Timer.Interval),让每帧的位移量变小,减少穿透几率。但要注意性能平衡。
  4. 预测性碰撞:在移动前,先计算移动后的位置,预判是否会碰撞。

5.4 内存泄漏与资源管理

GDI+对象(Image,Graphics,Brush,Pen等)是非托管资源,如果不及时释放,会导致内存泄漏,游戏运行一段时间后可能崩溃。

最佳实践

  1. 使用using语句:对于在方法内部创建的Graphics,Pen,Brush,Font等对象,务必使用using语句包裹,确保其Dispose()方法被调用。
    using (Pen myPen = new Pen(Color.Red, 2)) { g.DrawRectangle(myPen, rect); } // 离开using块时,myPen会自动Dispose
  2. 管理长期存在的对象:对于需要在整个游戏生命周期中使用的图片(如playerImage),在窗体关闭时(Form_FormClosing事件)手动调用其Dispose()方法,或者将其存储为静态资源。
  3. 及时清理集合:如前所述,从List中移除对象后,如果这些对象持有大量资源(比如大图片),也要确保它们被妥善处理。

5.5 扩展思路:让游戏更好玩

完成基础版本后,你可以尝试以下扩展,这能极大提升你的编程能力:

  • 增加敌机种类:创建FastEnemyBossEnemy等派生类,重写其UpdateDraw方法,实现不同的移动模式和外观。
  • 添加武器系统:为玩家飞机设计不同的武器(散弹、激光、导弹),通过拾取道具切换。这需要设计一个Weapon基类和多个子类。
  • 实现爆炸动画:不要简单地将敌机设为IsDestroyed=true就消失。可以创建一个Explosion类,在碰撞位置生成一个爆炸对象,播放几帧爆炸图片后再消失。
  • 加入音效和背景音乐:使用SoundPlayerNAudio库丰富听觉体验。
  • 制作关卡和分数存档:设计不同的敌机出现波次(关卡),并将最高分保存到本地文件或注册表中。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询