1. 项目概述与核心价值
最近在整理硬盘,翻出来一个十多年前刚学C#时做的飞机射击小游戏项目。看着那些略显稚嫩但结构清晰的代码,感觉特别有意思。这种小游戏项目,对于学习C#桌面开发、理解面向对象编程以及游戏循环的基本原理,价值远超很多“玩具级”的Demo。它麻雀虽小,五脏俱全,涵盖了图形绘制、用户输入、碰撞检测、对象管理、状态控制等核心游戏开发概念。今天,我就把这个老项目翻新一下,结合现在更清晰的认知,从头到尾拆解一遍,目标是让你不仅能复现一个能玩的游戏,更能理解背后每一行代码的设计意图和优化空间。无论你是刚接触C# WinForms的新手,想找个有成就感的实战项目练手,还是有一定基础想深入理解游戏逻辑的同行,这篇内容都会很有参考价值。我们会从零开始,用最纯粹的C#和Windows Forms(GDI+)来实现,不依赖任何第三方游戏引擎,这样你能把基础打得更牢。
2. 项目整体设计与架构思路
2.1 技术选型:为什么是WinForms + GDI+?
看到“飞机射击游戏”,很多人第一反应可能是Unity。但对于学习C#语言特性和基础游戏原理而言,从“底层”一点开始更有益处。WinForms配合GDI+进行绘图,是一个绝佳的学习组合。
首先,WinForms提供了成熟稳定的事件驱动模型。键盘、鼠标的输入可以很方便地通过事件来捕获和处理,窗体(Form)作为容器也管理了消息循环,这为我们省去了自己实现游戏主循环的复杂工作(虽然我们仍需在循环中更新和绘制)。其次,GDI+(Graphics Device Interface)是.NET框架内置的2D图形绘制接口。虽然性能上无法与现代GPU加速的图形API相比,但对于实现一个2D小游戏,其功能完全足够,且API相对简单直观,能让我们聚焦于游戏逻辑本身,而非图形学的复杂细节。最后,纯C#实现意味着零外部依赖,项目结构清晰,所有代码都在掌控之中,非常适合教学和原理理解。
注意:选择这个技术栈的核心目的是“学习”和“理解”。如果你目标是快速开发可发布的游戏,Unity或Godot等引擎是更专业的选择。但在这里,我们追求的是“知其所以然”。
2.2 核心游戏对象抽象与类设计
一个典型的纵版飞行射击游戏,包含以下几类核心对象:
- 玩家飞机 (PlayerPlane):受玩家控制,可以发射子弹,被敌机或子弹击中会损失生命或游戏结束。
- 敌机 (EnemyPlane):由电脑控制,按一定规律移动,会发射子弹或直接撞击玩家。
- 子弹 (Bullet):由玩家或敌机发射,直线运动,击中目标后触发伤害和消失逻辑。
- 背景 (Background):通常由多层滚动背景构成,营造飞行前进的视觉效果。
- 游戏控制器 (GameController):管理游戏状态(开始、进行中、结束)、分数、生命值,并协调所有对象的更新与绘制。
基于此,我们可以设计出以下核心类结构(UML思想,非实际代码):
GameObject:一个可能的基础抽象类,包含位置(X, Y)、速度(Speed)、是否存活(IsAlive)、贴图(Image)等公共属性和更新(Update)、绘制(Draw)的虚方法。PlayerPlane : GameObject:继承自GameObject,增加生命值(Health)、射击冷却(ShootCooldown)等属性,并重写Update方法以响应键盘输入。EnemyPlane : GameObject:继承自GameObject,增加类型(普通、精英)、血量、得分值等属性,其Update方法实现自动移动(如直线下降、正弦波移动)。Bullet : GameObject:继承自GameObject,增加发射者类型(玩家、敌人)、伤害值等属性。GameController:单例或静态类,持有List<PlayerPlane>,List<EnemyPlane>,List<Bullet>等集合,并在游戏主循环中遍历这些集合,调用每个对象的Update和Draw。
这种设计遵循了面向对象的“继承”和“多态”原则,使得游戏主循环的逻辑非常简洁:foreach (var obj in allGameObjects) { obj.Update(); }。
2.3 游戏主循环与双缓冲技术
WinForms程序默认是事件驱动的,但游戏需要连续不断的更新状态(即使没有用户输入)。因此,我们需要一个游戏主循环。最常用的方法是使用一个Timer控件(例如System.Windows.Forms.Timer),将其Interval属性设置为一个较小的值(如16ms,对应约60FPS),然后在它的Tick事件处理函数中执行以下操作:
- 更新(Update):调用
GameController的更新方法,进而更新所有游戏对象的位置、状态,并检测碰撞。 - 绘制(Draw):触发窗体的重绘(调用
this.Invalidate())。
在窗体的Paint事件处理函数中,我们进行实际的绘制工作。但直接绘制会导致严重的屏幕闪烁,因为每一帧的绘制过程是可见的。为了解决这个问题,必须使用双缓冲技术。在WinForms中,可以简单地通过设置窗体或控件的DoubleBuffered属性为true来实现。其原理是在内存中先完成一整幅画面的绘制,然后一次性输出到屏幕,从而消除闪烁。
public partial class MainForm : Form { private System.Windows.Forms.Timer gameTimer; private GameController gameController; public MainForm() { InitializeComponent(); this.DoubleBuffered = true; // 开启双缓冲 this.KeyPreview = true; // 允许窗体优先接收按键事件 gameController = new GameController(this.ClientSize); gameTimer = new System.Windows.Forms.Timer(); gameTimer.Interval = 16; // ~60 FPS gameTimer.Tick += GameLoop; gameTimer.Start(); } private void GameLoop(object sender, EventArgs e) { // 1. 更新游戏逻辑 gameController.Update(); // 2. 请求重绘 this.Invalidate(); } private void MainForm_Paint(object sender, PaintEventArgs e) { // 3. 绘制游戏画面 gameController.Draw(e.Graphics); } }3. 核心模块实现与细节解析
3.1 玩家控制与输入处理
玩家飞机的控制通常使用键盘,例如WASD或方向键移动,空格键射击。在WinForms中,我们需要在窗体级别捕获键盘事件。
关键点1:实时按键状态检测KeyDown和KeyUp事件是瞬时的,但游戏需要知道“当前时刻某个键是否被按住”。因此,我们需要维护一个Dictionary<Keys, bool>来记录每个按键的按下状态。
private Dictionary<Keys, bool> keyStates = new Dictionary<Keys, bool>(); private void MainForm_KeyDown(object sender, KeyEventArgs e) { if (keyStates.ContainsKey(e.KeyCode)) keyStates[e.KeyCode] = true; else keyStates.Add(e.KeyCode, true); } private void MainForm_KeyUp(object sender, KeyEventArgs e) { if (keyStates.ContainsKey(e.KeyCode)) keyStates[e.KeyCode] = false; }然后在PlayerPlane.Update()方法中,根据keyStates的状态来更新飞机位置和触发射击。
public void Update() { // 移动 if (keyStates.ContainsKey(Keys.Left) && keyStates[Keys.Left]) X -= Speed; if (keyStates.ContainsKey(Keys.Right) && keyStates[Keys.Right]) X += Speed; // ... 上下移动同理 // 边界检测,防止飞出屏幕 X = Math.Max(0, Math.Min(GameWidth - Width, X)); Y = Math.Max(0, Math.Min(GameHeight - Height, Y)); // 射击冷却处理 shootCooldownCounter++; if (keyStates.ContainsKey(Keys.Space) && keyStates[Keys.Space] && shootCooldownCounter >= ShootCooldown) { Shoot(); shootCooldownCounter = 0; } }关键点2:射击逻辑与对象池初步思想每次按下空格键就new一个Bullet对象,在激烈的弹幕中会产生大量对象创建和销毁,引发垃圾回收(GC),可能导致游戏卡顿。一个优化思路是引入简单的对象池:预先创建一批子弹对象放入池中,需要时取出激活,子弹飞出屏幕或击中目标后不销毁,而是放回池中标记为未使用。对于这个小游戏,如果子弹数量不多(几十发),直接new的问题不大,但了解对象池的概念对未来开发复杂游戏很有帮助。
3.2 敌机生成与AI行为
敌机的管理是游戏趣味性的关键。我们需要一个EnemySpawner(敌机生成器)来负责定时、按一定规则生成不同类型的敌机。
生成策略:可以使用一个随机数配合时间间隔来控制。例如,每过1-3秒(随机),在屏幕上方水平随机位置生成一架普通敌机。每累计生成10架普通敌机后,生成一架移动模式更复杂的精英敌机。
简单的AI行为:敌机的AI主要体现在其移动模式上。
- 直线型:最简单的,只需在
Update中增加Y坐标(向下移动)。 - 正弦波型:增加一个角度变量,让X坐标按正弦规律变化,形成左右摇摆的移动轨迹。
X = InitialX + (float)(Math.Sin(angle) * amplitude); angle += speed; - 追逐型:稍微复杂,敌机需要感知玩家位置(
playerX, playerY),并朝着玩家方向移动。这需要计算方向向量并归一化。
// 追逐型敌机Update示例 public void Update(PlayerPlane player) { float dx = player.X - this.X; float dy = player.Y - this.Y; float distance = (float)Math.Sqrt(dx * dx + dy * dy); if (distance > 0) // 避免除零错误 { dx /= distance; // 归一化X方向 dy /= distance; // 归一化Y方向 this.X += dx * Speed; this.Y += dy * Speed; } // 也可以加上一个最小追踪距离的判断 }3.3 碰撞检测的实现与优化
碰撞检测是射击游戏的核心逻辑。我们需要判断:
- 玩家子弹 vs 敌机
- 敌机子弹 vs 玩家飞机
- 敌机 vs 玩家飞机(撞击)
最简单直观的方法是矩形碰撞检测:判断两个物体的矩形包围盒是否相交。对于近似矩形的飞机和子弹图片,这已经足够且效率很高。
public static bool IsColliding(GameObject objA, GameObject objB) { Rectangle rectA = new Rectangle((int)objA.X, (int)objA.Y, objA.Width, objA.Height); Rectangle rectB = new Rectangle((int)objB.X, (int)objB.Y, objB.Width, objB.Height); return rectA.IntersectsWith(rectB); }碰撞处理流程:在GameController.Update()中,我们需要进行多重循环检测。
- 遍历所有玩家子弹,与所有敌机进行碰撞检测。若碰撞,则子弹和敌机标记为待移除(或敌机减血),玩家得分增加。
- 遍历所有敌机子弹,与玩家飞机检测碰撞。若碰撞,则子弹标记为待移除,玩家减血或游戏结束。
- 遍历所有敌机,与玩家飞机检测碰撞(撞击)。若碰撞,则敌机标记为待移除,玩家减血。
注意:这里存在一个经典的性能问题——嵌套循环。如果对象数量很多(N个子弹,M个敌机),复杂度是O(N*M)。对于小规模游戏尚可接受。优化方法包括空间分割(如网格法)或使用更高效的数据结构,但在此项目中,优先保证逻辑正确。
“待移除”标记技巧:不要在遍历集合时直接Remove元素,这会导致集合改变和迭代器异常。通常的做法是,在碰撞检测时,将被击中的对象添加到一个List<GameObject>的objectsToRemove列表中。在本次Update循环的最后,再统一从主集合中移除这些对象。
3.4 游戏状态管理与UI绘制
游戏需要有明确的状态,例如:Menu(开始菜单),Playing(游戏中),Paused(暂停),GameOver(游戏结束)。GameController需要有一个GameState枚举属性来记录当前状态,并且Update和Draw的逻辑会根据状态不同而改变。
public enum GameState { Menu, Playing, Paused, GameOver }在GameController.Draw(Graphics g)方法中:
- 无论何种状态,先绘制滚动背景。
- 在
Playing和Paused状态,绘制所有游戏对象(飞机、子弹)。 - 在
Paused状态,可以在屏幕中央半透明覆盖层上绘制“游戏暂停”文字。 - 在
GameOver状态,绘制最终得分和“游戏结束,按R键重试”的提示。 - 在
Menu状态,绘制游戏标题和开始按钮(或提示按Enter开始)。
分数与生命值的显示:使用Graphics.DrawString方法在屏幕固定位置(如左上角)绘制。字体、颜色可以自定义,以清晰美观为准。
// 在Draw方法中 using (Font font = new Font("Arial", 16, FontStyle.Bold)) using (SolidBrush brush = new SolidBrush(Color.White)) { g.DrawString($"Score: {score}", font, brush, 10, 10); g.DrawString($"Life: {playerLife}", font, brush, 10, 40); }4. 性能优化与代码组织实战
4.1 图像资源加载与管理
游戏中的飞机、子弹、背景、爆炸特效都需要图片。直接在每个游戏对象的构造函数里用Image.FromFile加载图片是大忌,会导致大量磁盘IO,极其缓慢。正确的做法是集中加载,全局复用。
我们可以创建一个ResourceManager静态类,在游戏初始化时(如主窗体加载时)一次性将所有需要的图片加载到内存中的Dictionary里。
public static class ResourceManager { public static Dictionary<string, Image> Images = new Dictionary<string, Image>(); public static void LoadResources() { try { Images["player"] = Image.FromFile(@"Resources\player.png"); Images["enemy1"] = Image.FromFile(@"Resources\enemy1.png"); Images["bullet_player"] = Image.FromFile(@"Resources\bullet_blue.png"); Images["bullet_enemy"] = Image.FromFile(@"Resources\bullet_red.png"); Images["background"] = Image.FromFile(@"Resources\bg.png"); // ... 加载所有图片 } catch (FileNotFoundException ex) { MessageBox.Show($"资源文件丢失: {ex.FileName}"); Environment.Exit(1); } } public static Image GetImage(string key) { if (Images.ContainsKey(key)) return Images[key]; return null; } }这样,每个GameObject在绘制时,只需要通过ResourceManager.GetImage("player")来获取对应的Image对象,效率极高。记得在游戏退出时,要遍历Images.Values对每个Image调用.Dispose()方法释放资源,或者更好的办法是让ResourceManager实现IDisposable接口。
4.2 游戏循环与帧率控制
我们之前使用了System.Windows.Forms.Timer,它的精度不高,且Tick事件是在UI线程上执行的。如果某次Update或Draw逻辑过于复杂,超过了16ms,就会导致游戏变慢、卡顿,并且由于Timer的Tick是排队执行的,还会造成输入延迟。
更专业的做法是使用多线程或高精度计时器,并将游戏逻辑更新与渲染分离。一个经典的架构是:
- 使用一个独立的线程(或
System.Threading.Timer)以固定时间步长(如每秒60次)运行Update逻辑。 - 渲染(
Draw)则在UI线程上,以尽可能快的速度进行(或与更新同步),但只绘制最新的游戏状态。
对于初学者项目,使用Forms.Timer的简单性 outweighs 其缺点。但我们可以引入一个简单的帧率独立移动概念,让游戏在不同性能的电脑上速度一致。原理是:在Update中,根据从上一次更新到这一次更新所经过的真实时间(DeltaTime)来计算物体应该移动的距离,而不是假设每一帧都是固定的16ms。
private DateTime lastUpdateTime = DateTime.Now; private void GameLoop(object sender, EventArgs e) { DateTime now = DateTime.Now; float deltaTime = (float)(now - lastUpdateTime).TotalSeconds; // 转换为秒 lastUpdateTime = now; gameController.Update(deltaTime); // 将deltaTime传入 this.Invalidate(); } // 在GameObject的移动逻辑中 public void Update(float deltaTime) { // 位置变化基于时间和速度 this.X += this.SpeedX * deltaTime; this.Y += this.SpeedY * deltaTime; }这样,即使某次循环因为GC或其他原因花了32ms,物体也只会移动“2倍距离”,而不是“跳过了1帧”,从宏观上看,游戏世界的“时间”流逝是均匀的。
4.3 代码结构优化与设计模式应用
随着项目扩大,GameController类可能会变得非常臃肿,管理所有对象列表、碰撞检测、状态切换。我们可以考虑引入一些简单的设计模式来解耦。
观察者模式(Observer Pattern):用于处理游戏事件。例如,当一架敌机被摧毁时,它不应该直接去修改GameController里的分数,而是触发一个“敌机被摧毁”的事件。GameController(或其他负责计分的模块)订阅这个事件,在事件发生时增加分数。这样降低了敌机类和GameController的耦合度。
// 定义事件参数 public class EnemyDestroyedEventArgs : EventArgs { public int ScoreValue { get; set; } } // 在敌机类中定义事件 public class EnemyPlane : GameObject { public event EventHandler<EnemyDestroyedEventArgs> OnDestroyed; public void TakeDamage() { Health--; if (Health <= 0) { IsAlive = false; // 触发事件 OnDestroyed?.Invoke(this, new EnemyDestroyedEventArgs { ScoreValue = this.ScoreValue }); } } } // 在GameController中订阅 enemy.OnDestroyed += (sender, e) => { this.Score += e.ScoreValue; };状态模式(State Pattern):用于管理复杂的游戏状态。我们可以为GameState的每个枚举值创建一个对应的状态类(如PlayingState,PausedState),每个状态类负责自己状态下的Update和Draw逻辑。GameController只持有当前状态对象的引用,并将Update和Draw调用委托给它。这使得增加新的游戏状态(如BossStageState)变得非常容易,且符合开闭原则。
5. 功能扩展与效果增强思路
一个基础版本完成后,可以从以下几个方面进行扩展,让游戏更完整、更好玩。
5.1 增加视觉与音效反馈
粒子特效:爆炸效果不只是一张静态图片。可以创建一个ParticleSystem类,在敌机或玩家被击中时,在相应位置生成一堆具有随机速度、大小、生命周期和颜色的小粒子(用Graphics.FillEllipse绘制小圆点即可模拟),然后让这些粒子在生命周期内运动、淡出、消失。这能极大提升打击感。
音效播放:使用System.Media.SoundPlayer可以播放WAV格式的音效。在射击、击中、爆炸、游戏结束时播放对应的音效。同样,音效文件也应该在ResourceManager中集中加载(SoundPlayer对象),避免每次播放都从磁盘读取。
public static class AudioManager { private static Dictionary<string, System.Media.SoundPlayer> sounds = new Dictionary<string, System.Media.SoundPlayer>(); public static void LoadSound(string key, string filePath) { sounds[key] = new System.Media.SoundPlayer(filePath); sounds[key].LoadAsync(); // 异步加载 } public static void PlaySound(string key) { if (sounds.ContainsKey(key)) sounds[key].Play(); // 注意:Play是同步的,对于短音效可以,长音乐建议用其他库 } }5.2 设计关卡与敌机波次
让游戏不是无限循环的敌机生成,而是有明确的关卡概念。
- 关卡数据:可以定义一个
Level类,包含该关卡敌机波次列表、背景音乐、Boss出现条件等。 - 波次(Wave):每个波次定义在什么时间点、生成什么类型、多少数量的敌机,以及生成后的移动模式。可以用一个脚本或配置文件(如JSON)来定义,游戏运行时读取并解析。
- Boss战:当普通敌机清理到一定数量,或时间到达某个点,生成一个大型的Boss敌机。Boss拥有多段血量、多种攻击模式(扇形弹幕、追踪弹、激光等),击败后进入下一关。
5.3 加入道具系统与玩家成长
击败特定敌机(如精英敌机)后,有概率掉落道具。道具可以用一个独立的Item类表示,继承GameObject,下落并可以被玩家飞机接触拾取。
- 道具类型:火力升级(子弹从单发变为双发、激光等)、护盾(暂时无敌)、生命恢复、得分倍增等。
- 玩家状态管理:
PlayerPlane类需要增加一个PowerLevel属性来管理当前火力等级,以及一个ShieldActiveTime来管理护盾剩余时间。拾取对应道具后修改这些状态。 - 状态衰减:火力升级可以设定持续时间,时间到了自动降级,增加策略性。
5.4 实现游戏存档与设置
使用System.IO和序列化(如Json.NET库)来实现简单的存档功能。
- 存档内容:最高分、已解锁的关卡、玩家设置(如音量、按键配置)。
- 序列化:将包含存档数据的类实例序列化为JSON字符串,保存到本地文件。
- 反序列化:游戏启动时读取文件并反序列化,加载存档。
- 设置界面:可以创建一个新的
Form作为设置窗口,允许玩家调整音量大小、重新定义控制按键。按键重定义的核心是记录玩家在设置界面按下的新键,并更新keyStates字典所监听的键位。
6. 调试技巧与常见问题排查
开发过程中,你肯定会遇到各种奇怪的问题。以下是一些常见坑点和排查方法。
问题1:游戏画面闪烁严重。
- 原因:未启用双缓冲,或是在
Paint事件中频繁创建/销毁Graphics对象、画笔(Pen)、画刷(Brush)。 - 解决:
- 确保窗体或自定义绘制控件的
DoubleBuffered属性设为true。 - 在
Paint事件处理函数中,尽量重用传入的PaintEventArgs e中的Graphics对象(e.Graphics)。 - 对于需要重复使用的
Pen,Brush,Font等GDI+对象,在类级别声明并初始化,而不是在Draw方法内部每次新建。并在窗体销毁时(Dispose方法中)确保释放它们。
- 确保窗体或自定义绘制控件的
问题2:键盘控制有延迟或不灵敏。
- 原因:
Forms.Timer的精度问题,或者KeyDown/KeyUp事件被其他控件“吃掉”了。 - 解决:
- 确保窗体
KeyPreview = true。 - 检查是否有其他获得焦点的控件(如Button)拦截了按键事件。
- 考虑使用
GetAsyncKeyStateAPI进行更低级别的键盘状态查询,但这更复杂。对于本项目,确保按键处理逻辑在Update中基于keyStates字典进行,通常可以满足需求。
- 确保窗体
问题3:碰撞检测不准,感觉“打中了却没反应”或“没碰到却判定击中”。
- 原因:矩形碰撞检测对于非矩形物体(如圆形飞机)本身就不精确。或者图片的透明边缘也被算入了碰撞矩形。
- 解决:
- 调试绘制:在开发阶段,可以在
Draw方法中,除了绘制图片,再用一个红色的矩形框画出碰撞区域,直观地看到问题所在。g.DrawRectangle(Pens.Red, collisionRect)。 - 调整碰撞盒:可以定义一个比实际图像小的矩形作为碰撞盒(
CollisionBox),例如Width-10, Height-10,这样感觉上会更符合视觉。 - 圆形碰撞检测:如果物体近似圆形,可以使用圆形检测,计算两个圆心距离是否小于半径之和。
distance = Math.Sqrt(dx*dx + dy*dy); if (distance < radiusA + radiusB) { //碰撞 }。 - 像素级碰撞检测:最精确但最耗性能,比较两个精灵透明像素的重叠部分,对于此项目不推荐。
- 调试绘制:在开发阶段,可以在
问题4:游戏运行一段时间后越来越卡。
- 原因:内存泄漏或资源未释放。最常见的是GDI+对象(
Image,Pen,Brush,Font)没有Dispose,或者游戏对象(子弹、敌机)被“销毁”后没有从集合中移除,导致Update和Draw仍然在遍历大量已“死亡”的对象。 - 解决:
- 确保所有
IDisposable对象(特别是Graphics、从文件加载的Image)在使用后妥善处理。对于需要长期存在的资源(如玩家飞机图片),在游戏开始时加载一次并持有引用,游戏结束时统一释放。 - 在
GameController.Update()的末尾,务必清理objectsToRemove列表中的所有对象,将它们从活动对象集合中移除。同时,如果这些对象持有非托管资源,也要确保释放。 - 使用性能分析工具(如Visual Studio自带的诊断工具)监控内存和CPU使用情况,定位泄漏点。
- 确保所有
问题5:敌机或子弹移动速度不稳定,时快时慢。
- 原因:游戏逻辑更新没有与时间增量(
deltaTime)挂钩,其速度依赖于不稳定的帧率。 - 解决:如前所述,在
Update方法中引入deltaTime参数,所有基于时间的移动和动画都乘以deltaTime。
把这个项目从头到尾实现一遍,你对C#面向对象、事件处理、资源管理、基础游戏循环的理解会上一个大台阶。它就像一块坚实的基石,以后无论你是想深入研究更复杂的游戏架构,还是转向Unity等引擎,都会发现这些核心概念是相通的。编程最有乐趣的地方之一,就是看着自己写的代码变成一个可以交互、有反馈的鲜活世界,哪怕它最初只是一个在窗体上移动的小方块。动手去做,遇到问题就按上面的思路去排查,你收获的将远不止一个游戏。