简介:一款基于C#开发的飞行射击小游戏源码,定位为面向C#初学者与游戏开发爱好者的实战练手项目。源码包含完整的游戏逻辑,涵盖游戏对象创建、交互处理、碰撞检测、游戏循环等关键模块,飞机、子弹、敌人均以独立对象呈现,可帮助理解面向对象编程、事件驱动、定时器控制以及GDI+绘图等常见游戏开发技术。资源包共61个文件,压缩包大小仅1.25MB,其中以17个.cs源码文件、5个.png图片素材、4个.wav音效、4个依赖.dll以及可直接运行的.exe程序为主,同时包含项目配置文件与调试信息,结构精简,适合按模块拆解学习。目前已有259人学习下载,对于希望透过完整小游戏源码掌握C#实战应用、快速建立游戏开发整体认知的学习者来说,是一份轻量而有价值的参考资料。
1. 为什么一个 2D 飞机小游戏源码,值得你花一下午拆
先给结论:C# 飞机小游戏源码,看起来是个课程设计级别的玩具项目,但它是你从「会写 C# 语法」到「敢说自己懂 C# 桌面开发」之间,成本最低的一块跳板。一个典型的极品飞机小游戏,背后几乎踩遍了 C# 桌面开发的所有核心问题:GDI+ 绘制性能、游戏主循环、碰撞检测、对象生命周期管理、委托与事件解耦、甚至是 Task 和异步加载的边界——这些恰恰是新手读十篇理论博客也建立不起肌肉记忆的东西。
我见过太多人,C# 语法背得滚瓜烂熟,一到真正写窗体程序就卡在「画面为什么闪」「子弹为什么越打越卡」「敌机一多就掉帧」这三个坎上。这三个坎,恰恰全部藏在一个飞机小游戏的源码里。本文不假设你手上有什么具体源码包,就按这类项目最常见、最可靠的实现路径,把架构、关键代码、参数调法和踩坑点完整拆给你。你读完之后,不仅能把这个项目从零搭出来,还能顺手修好别人的代码里最典型的几个坑。
2. GDI+ 还是 WPF:选型错了,后面全是血泪
飞机小游戏这类 2D 实时刷新项目,第一步不是写代码,是选渲染方案。这一步选错,后面优化到吐也救不回来。绝大多数网上下到的 C# 飞机小游戏源码,走的是 GDI+ 路线,少数新写的会走 WPF 的 DrawingVisual 或 WriteableBitmap。先说为什么老源码偏爱 GDI+,再说你该怎么选。
2.1 GDI+ 为什么是这类源码的默认答案
GDI+ 在 .NET Framework 时代就是 System.Drawing 的核心,它做 2D 绘制的逻辑极其直接:拿到 Graphics 对象,往窗体上画图。飞机小游戏的全部画面元素——背景、玩家飞机、敌机、子弹、爆炸特效——本质上就是一堆矩形贴图在每一帧里被重新绘制一遍。GDI+ 的 DrawImage 方法在单帧绘制几十个对象的场景下,性能完全够用。更关键的是,GDI+ 的代码路径短,理解成本极低,一个新手看三十分钟就能改出自定义飞机造型,这对学习期的项目来说比性能更重要。
常见的源码结构是这样的:一个 GameForm 继承自 Form,一个 Timer 作为游戏主循环,Tick 事件里做逻辑更新和重绘。整个项目的核心压力全压在这一个 Timer 的回调函数上。这就是这类源码「质朴」的原因——它把游戏循环、碰撞检测、绘制全部集中在一个方法里,你不需要理解组件架构,只需要关注每一帧要做什么。
2.2 WPF 的优势与陷阱:什么时候别碰它
WPF 的渲染走的是 DirectX 的软件或硬件加速管线,理论上比 GDI+ 平滑得多。但 WPF 的默认布局系统是每帧测量、排列、渲染三步走,UIElement 一多,布局开销会吃掉你省下来的渲染性能。这就是为什么很多用 WPF 写飞机游戏的人,做到一半发现 Airplane 控件超过二十个就开始卡——问题不在渲染,在布局通道路上的依赖属性变更通知风暴。
我一般建议:如果你是从网上下源码来学习和改造,首选 GDI+。因为你这个阶段的核心目标是读得懂、改得动。等你把游戏循环和碰撞检测的肌肉记忆建立起来之后,再考虑用 WPF 的 WriteableBitmap 做一版高性能重写,那时候你才分得清哪些卡顿是绘制引起的,哪些是布局引起的。WPF 不是不好,是它在 2D 游戏这个场景下需要你有更强的框架认知,否则你连坑在哪都找不到。
2.3 最核心的选型判断标准:看你的目标帧率
把选型问题简化成一句话:你要跑多少帧?30 帧每秒意味着每帧只有 33 毫秒的预算,GDI+ 完全能覆盖。如果你想要 60 帧甚至更高,GDI+ 依然能跑,前提是用对双缓冲和绘制裁剪。真正压垮 GDI+ 的不是帧率本身,而是你在每帧里做了多少次「昂贵的操作」——频繁创建画笔、反复 SetStyle、全屏重绘不裁剪。
这里给出一个具体的判断矩阵供你参考:
| 你的目标 | 推荐方案 | 核心原因 |
|---|---|---|
| 学习 C# 桌面游戏开发,读懂并改造源码 | GDI+ + Timer | 代码路径最短,概念最直接 |
| 想要更平滑的 60 帧体验 | GDI+ 双缓冲 + 局部重绘 | 性能瓶颈在绘制裁剪而非渲染管线 |
| 追求特效(透明、粒子、着色器) | WPF + WriteableBitmap | 需要逐像素控制 |
| 以 UI 布局为主,游戏为辅 | WPF | UI 开发效率高于 GDI+ |
这个矩阵的核心逻辑是:飞机小游戏的复杂度撑不起高端渲染方案的优势,反而更容易被复杂框架自身的开销拖累。GDI+ 在这个场景下不是退而求其次,它是这个体量下最稳的答案。
3. 从空窗体到能玩的循环:核心代码逐个拆
选好了 GDI+,接下来就是把这个小游戏从「一个会显示窗体的空壳」变成「一个能玩十分钟不腻的循环」。这一章按模块拆,每个代码块都是完整可抄的,抄完再讲为什么这么写。
3.1 游戏主循环:Timer 还是循环线程?
先说最常见也是最稳的写法:用 System.Windows.Forms.Timer 做主循环。它的优势在于 Tick 事件跑在 UI 线程上,你可以在回调里直接操作控件和 Graphics,不需要 Invoke。劣势是它的精度不是实时的,默认间隔也就是毫秒级的近似值,但这对飞机游戏完全够用。
// GameForm.cs 核心主循环配置 public partial class GameForm : Form { private Timer gameTimer; private DateTime lastUpdateTime; private double deltaTime; // 每帧实际经过的秒数 public GameForm() { InitializeComponent(); // 双缓冲关键设置:防闪烁的核心就在这三行 SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true); gameTimer = new Timer(); gameTimer.Interval = 16; // 约 60 帧/秒的刷新间隔 gameTimer.Tick += GameLoop; gameTimer.Start(); lastUpdateTime = DateTime.Now; } private void GameLoop(object sender, EventArgs e) { // 计算真实经过的帧时间,而不是假设每帧固定 16ms DateTime now = DateTime.Now; deltaTime = (now - lastUpdateTime).TotalSeconds; lastUpdateTime = now; UpdateGame(deltaTime); // 逻辑更新:移动、碰撞、生成 Invalidate(); // 请求重绘,OnPaint 会被自动调用 } }这里有个非常重要的细节:为什么用 Invalidate 而不是在 Tick 里直接调用绘制方法?因为 Invalidate 会合并同一时间片内的多次重绘请求,让 .NET 的消息循环决定什么时候真正去重绘。如果你在 Tick 里直接画,绘制频率会失控,而且容易和系统的 WM_PAINT 消息冲突。deltaTime 的计算也是必要的——它保证游戏在你电脑上和在别人电脑上的速度一致,而不是绑定在 Timer 的标称间隔上。
提示:Interval 设 16 不代表真的每 16ms tick 一次。Windows 窗体 Timer 的精度受系统定时器分辨率影响。用 deltaTime 来校准,比改 Interval 值靠谱得多。
3.2 玩家控制与移动:键盘状态采集的两种姿势
飞机移动最忌讳的做法是在 KeyDown/KeyUp 事件里直接改飞机坐标。那样会产生延迟问题:按下方向键时 tick 还没来,松开时 tick 还没来,操作手感发飘。正确的做法是把键盘状态记录下来,在 UpdateGame 里统一消费。
private HashSet<Keys> pressedKeys = new HashSet<Keys>(); protected override void OnKeyDown(KeyEventArgs e) { base.OnKeyDown(e); if (!pressedKeys.Contains(e.KeyCode)) pressedKeys.Add(e.KeyCode); } protected override void OnKeyUp(KeyEventArgs e) { base.OnKeyUp(e); pressedKeys.Remove(e.KeyCode); } private void UpdatePlayer(double deltaTime) { float moveSpeed = 300f; // 像素每秒,手感调校的核心参数 float dx = 0f, dy = 0f; if (pressedKeys.Contains(Keys.Left)) dx -= moveSpeed * (float)deltaTime; if (pressedKeys.Contains(Keys.Right)) dx += moveSpeed * (float)deltaTime; if (pressedKeys.Contains(Keys.Up)) dy -= moveSpeed * (float)deltaTime; if (pressedKeys.Contains(Keys.Down)) dy += moveSpeed * (float)deltaTime; // 边界约束:不要让飞机飞出窗体外 playerX = Math.Max(0, Math.Min(ClientSize.Width - playerWidth, playerX + dx)); playerY = Math.Max(0, Math.Min(ClientSize.Height - playerHeight, playerY + dy)); // 按住空格连发:射击频率在 UpdateGame 里做节流 if (pressedKeys.Contains(Keys.Space)) TryShoot(deltaTime); }HashSet 的好处是天然去重,配合 Contains 判断,可以支持多键同时按下(比如斜向移动加射击),这是用 bool 变量做单键标记做不到的。moveSpeed 用「像素每秒」而不是「每帧像素数」,就是为了配合 deltaTime 实现跨帧率的稳定手感。如果你在别人的源码里看到「每帧移动 5 像素」,那这个游戏在高低帧率的两台机器上速度会差一大截——这是判断源码质量最直接的一个细节。
3.3 子弹、敌机与碰撞检测:矩形碰撞的极限优化
子弹和敌机都是动态对象。最原始的管理方式是 List 和 List ,每帧全量遍历、移动、检查越界、检查碰撞。这个写法在对象数量几百以内没问题,但子弹一多就会产生两个问题:一是每帧都要 new 产生 GC 压力,二是碰撞检测是 O(n*m) 的嵌套循环。
private List<Bullet> bullets = new List<Bullet>(); private List<Enemy> enemies = new List<Enemy>(); private void CheckCollisions() { // 倒序遍历是为了安全地对 List 做移除操作 for (int i = bullets.Count - 1; i >= 0; i--) { bool bulletHit = false; for (int j = enemies.Count - 1; j >= 0; j--) { if (IsCollide(bullets[i], enemies[j])) { enemies[j].Hp -= bullets[i].Damage; if (enemies[j].Hp <= 0) { enemies.RemoveAt(j); score += 100; // 击毁加分 } bulletHit = true; break; // 一颗子弹同一帧只打中一架敌机 } } if (bulletHit || bullets[i].IsOutOfBounds(ClientSize)) bullets.RemoveAt(i); } } private bool IsCollide(Bullet b, Enemy e) { // 矩形相交判定:两个矩形的边界互相渗透即算碰撞 return b.X < e.X + e.Width && b.X + b.Width > e.X && b.Y < e.Y + e.Height && b.Y + b.Height > e.Y; }嵌套循环在高密度弹幕下会迅速恶化。一个常见的优化套路是「减少内层循环的候选数量」:在多个区域里只遍历与子弹屏幕位置相近的敌机。但说句实在话,飞机小游戏到不了那个规模,真正需要优化的是移除操作的代价值——用 List.RemoveAt 从尾部移除是 O(1),从头部移除是 O(n)。倒序遍历就是利用这个特性把性能损失压到最低。至于碰撞检测的形状:矩形碰撞是性价比最高的方案,圆形碰撞(算距离平方)精度更高但要开方,实际项目中矩形碰撞配合适当缩小碰撞框,手感会比视觉看起来更公平。
3.4 绘制全流程:一帧画面是怎么出来的
绘制这块是 GDI+ 源码里最两极分化的地方。质量差的源码在 OnPaint 里直接画所有对象,每一帧都全量重绘。质量好的源码会做局部裁剪、会缓存静态背景。下面是带帧率控制的完整绘制路径。
protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); Graphics g = e.Graphics; g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.NearestNeighbor; // 1. 绘制星空背景(缓存成一张 Bitmap,避免每帧重复画星星) g.DrawImage(backgroundBitmap, 0, 0); // 2. 绘制子弹:用一个 SolidBrush 循环,不要每颗子弹重新创建 using (SolidBrush bulletBrush = new SolidBrush(Color.Yellow)) { foreach (var bullet in bullets) { g.FillRectangle(bulletBrush, bullet.X, bullet.Y, bullet.Width, bullet.Height); } } // 3. 绘制敌机:用缓存的 Bitmap 列表,按帧切换做简单动画 foreach (var enemy in enemies) { g.DrawImage(enemyFrames[enemy.CurrentFrame], enemy.X, enemy.Y, enemy.Width, enemy.Height); } // 4. 绘制玩家飞机 g.DrawImage(playerBitmap, playerX, playerY, playerWidth, playerHeight); // 5. 绘制得分:用 TextRenderer 比 g.DrawString 更快更清晰 TextRenderer.DrawText(g, $"SCORE: {score}", new Font("Consolas", 14f, FontStyle.Bold), new Point(10, 10), Color.White); }这里最容易踩的坑是 using 的误用和滥用。SolidBrush 放进 using 里每帧创建销毁是正确做法,为什么?因为它实现 IDisposable,GDI+ 句柄不及时释放会让程序跑半小时后开始报「内存不足」。但 Font 对象如果你每帧都 new 一个,那又是另一个资源灾难——所以把这个 Font 提出来做成窗体字段,只创建一次。每个绘制操作的性能特征是「创建成本 > 绘制成本」,所以代码里的优化主线和你的直觉相反:不是减少绘制次数,而是减少对象的创建次数。
3.5 敌机生成与移动:从哪里来、往哪里去、怎么死
敌机的生成逻辑决定了游戏的爬坡曲线。最粗糙的做法是在 Timer 里按固定概率生成,这样游戏节奏是线性的。稍微像样的源码会引入「生成间隔随分数动态缩小」的逻辑。
private float enemySpawnInterval = 2.0f; // 初始每 2 秒一架 private float timeSinceLastSpawn = 0f; private Random rand = new Random(); private void SpawnEnemyLogic(double deltaTime) { // 分数越高,生成间隔越短,形成难度爬坡 enemySpawnInterval = Math.Max(0.3f, 2.0f - score / 1000f); timeSinceLastSpawn += (float)deltaTime; if (timeSinceLastSpawn >= enemySpawnInterval) { timeSinceLastSpawn = 0f; Enemy enemy = new Enemy(); // 横向随机出生,出生在窗体顶部之外 enemy.X = rand.Next(0, Math.Max(1, ClientSize.Width - enemy.Width)); enemy.Y = -enemy.Height; enemy.Speed = 80f + score / 500f * 10f; // 敌机也随分数变快 enemy.Hp = 1; enemies.Add(enemy); } }这个逻辑有一个常被忽视的点:enemySpawnInterval 的计算不应该放在 SpawnEnemyLogic 里,应该抽到分数变更的时候算一次。放在这里只是为了每帧都拿到最新分数,但每帧做一次除法是无谓的浪费。真正的源码优化会在 GameScore 属性里做变更通知,分数变了才重算难度参数。用 Random 生成随机数时也要注意:不要在循环里反复 new Random(),那样在短时间内生成的随机数会完全一样——这是 .NET 里一个非常经典的翻车点。
4. 手感和难度曲线:决定「能不能玩」的 5 个参数
一个飞机小游戏源码能不能被称得上「极品」,从来不是看它特效多炫,而是看它玩起来顺不顺手。这一章专门拆手感相关的参数。这些参数在源码里往往就是几个魔法数字,但每个位置都对应着一个明确的游戏设计意图。
4.1 玩家移动速度:手感的第一个分水岭
moveSpeed 是 300f 还是 150f,决定了这个游戏是「灵敏得失控」还是「迟钝得像拖泥」。300f 的基准速度是一个比较折中的起点。判断依据很简单:从窗体最左端移动到最右端,应该耗时约 1.5 到 2 秒。如果小于 1 秒,玩家会觉得难以瞄准;如果大于 2.5 秒,玩家会觉得逃不开敌机的弹幕。
速度到底定多少还取决于窗体尺寸。如果你的窗体宽度是 800,那 300f 意味着 2.6 秒横穿,偏慢了。建议的做法是把窗体宽度引入计算:moveSpeed = 窗体宽度 * 0.4。窗体大一些,飞机也要跑得快一些,否则同样的速度参数在大窗体和小窗体上的手感会差很多,而网上下到的源码往往是为某一个固定窗体尺寸调好的,搬到别的分辨率上就会变味。
4.2 射击间隔:连发 vs 点射的节流参数
射击间隔是另一个决定手感的核心参数。每 0.15 秒一发(约每秒 6.7 发)是常见的基准值。间隔太短(如 0.05 秒)会让子弹铺满全屏,看起来爽但敌机血量设计就全乱了;间隔太长(如 0.5 秒)玩家会着急。
private float shootCooldown = 0f; private const float SHOOT_INTERVAL = 0.15f; // 主要手感参数 private void TryShoot(double deltaTime) { shootCooldown -= (float)deltaTime; if (shootCooldown > 0) return; shootCooldown = SHOOT_INTERVAL; bullets.Add(new Bullet(playerX + playerWidth / 2 - bulletWidth / 2, playerY)); }这里没有用 DateTime 比较时间点,而是用累积冷却时间加 deltaTime 倒计时的模式。两种写法都能用,但后者和主循环的时序天然协同,而且在游戏暂停时只需要停止更新 deltaTime 就能自动冻结冷却状态,不需要单独维护每个 Timer。枪口位置的计算用到了局部变量计算:playerX 加飞机宽度的一半再减子弹宽度的一半,这个偏移量算错是最常见的 bug 来源——子弹从机翼飞出去而不是从机头出去。
4.3 敌机血量与速度的非线性增长
经典的难度曲线设计是:敌机血量随关卡线性增长,速度随分数非线性增长。但实际操作中,血量增长必须比速度增长慢,因为玩家火力输出也是线性增长的。如果敌机血量按每波次 +2,玩家火力因为有了升级道具变成每秒伤害翻倍,那游戏就会在第四分钟左右变得「打不死怪物」——因为输出增长追不上防御增长。
一个稳妥的成长公式是:Hp = 1 + floor(score / 3000)。速度则用分段函数:前 1000 分保持基准速度,1000 分以后每 500 分提速 5%。这个设计的目的是给新手一个「前两分钟很轻松」的上手期,然后再逐步收紧。很多源码在这块做得很粗——要么血量是固定 1,游戏两分钟后变成纯躲避游戏;要么速度不涨,游戏玩十分钟后变得无聊。
4.4 碰撞框缩小:让玩家觉得「明明没碰到」
玩家飞机的碰撞框不应该等于图片的边界。图片上有透明像素、有机翼装饰、有尾部火焰,这些区域都不应该算作被击中。常见做法是把实际碰撞框缩小到图片尺寸的 60% 到 70%,并且做一个「视觉居中」的偏移。
// 玩家碰撞框:宽度为图片的 60%,高度为 70%,并居中偏移 private Rectangle GetPlayerCollisionBox() { int shrinkW = playerWidth / 3; int shrinkH = playerHeight / 3; int offsetX = playerX + shrinkW / 2; int offsetY = playerY + shrinkH / 2; return new Rectangle(offsetX, offsetY, playerWidth - shrinkW, playerHeight - shrinkH); }这个「图片大、碰撞框小」的设计,会让玩家觉得自己的操作更游刃有余,死亡时也更容易接受。这是几乎所有的商业弹幕游戏都用的默认策略。很多徒手写的小游戏源码忽略了这一点,玩家明明看着没碰到飞机却死了,这种体验是最让人挫败的死亡方式——它让玩家觉得游戏不公平。
4.5 音效与视觉反馈:给动作套一个「确认」层
音效和视觉反馈是手感的一部分。子弹射击要有音效,敌机爆炸要有音效,玩家受伤要有不同的音效。使用 SoundPlayer 类是 .NET 里最简单的方式,但它只支持 WAV 格式,而且不能并发播放两个音效。这就带来了一个真实的限制:子弹音效(高频触发)会和爆炸音效(低频触发)互相截断,体验很差。
常见替代是引入一个简单的音频管理器,为每一类音效维护独立的 SoundPlayer 实例,这样不同音效之间不会互相打断。同一类音效的并发冲突在子弹音效这种高频场景下是可以接受的——它本来就是短促的。视觉反馈方面,爆炸时至少要做一个扩散圆环的动画帧,配合简单的缩放和透明度变化。这些反馈的粒度决定了玩家对游戏的「档次感」的感知,它是技术之外最能体现一个源码用心程度的地方。
5. 避坑手册:C# 飞机小游戏最常见的 6 个翻车现场
这一章全部来自实际写这类项目的血泪经验。每一条都是「现象 → 原因 → 解决」的格式,你可以直接当排查清单用。
5.1 窗体一跑起来画面疯狂闪烁
现象:游戏启动后,窗体区域不断闪烁,尤其是飞机移动和子弹飞行的过程中。
原因:没有启用双缓冲,或者窗体背景清屏和绘制之间发生了直接暴露。GDI+ 的默认行为是每次绘制前将旧画面完全擦除,这个擦除动作会在屏幕上产生一个空白帧,然后新的绘制内容才被画上去——不同步就产生了闪烁。
解决:在 Form 构造函数里加上 SetStyle 三行组合:AllPaintingInWmPaint、UserPaint、OptimizedDoubleBuffer。如果你用的是 UserControl 或 Panel,把同样的 SetStyle 调用放到它的构造函数里。这是 GDI+ 绘制中最经典且最有效的防闪烁方案。
5.2 游戏跑一段时间后越来越卡
现象:起初游戏流畅,两三分钟后帧率明显下降,打开任务管理器看到内存只增不减。
原因:每帧都在创建新的 Bitmap、Font、SolidBrush 或 Pen,且没有 Dispose。GDI+ 的句柄是有数量上限的,到达上限后新的绘制操作会排队直到句柄释放。这不是 .NET 内存回收器能处理的——GDI+ 句柄不是托管对象,必须显式释放。
解决:在绘制方法里,所有实现 IDisposable 的对象都必须包在 using 里或手动 Dispose。但这也要讲分寸——把 Font 做成字段只创建一次是正解,把 SolidBrush 做成字段复用同样可行。核心思路就是三个词:能复用的不创建,要创建的就释放,释放不了的放字段。
5.3 子弹和飞机的碰撞判定错位
现象:玩家明明看到子弹穿过敌机却判定没命中,或者明显没碰到却被判定命中。
原因:碰撞框设置错误是主要因素。要么直接把图片矩形当作碰撞框(碰到透明区域也算命中),要么子弹坐标和子弹绘制坐标不一致(比如子弹绘制时有偏移,碰撞检测却没用偏移后的坐标)。
解决:统一维护一个公共坐标原点。所有对象的 X、Y 属性含义必须是绘制和碰撞共用的同一套坐标。然后把碰撞框计算独立成一个方法,单独拉出来调试:加一个调试开关,把碰撞框画成半透明红色矩形,运行时开启就能直观看到碰撞框的位置和大小对不对。
5.4 敌机生成时出现空引用异常
现象:游戏刚开始能跑,一分钟后爆 NullReferenceException,异常堆栈指向敌机列表遍历。
原因:典型的「遍历时修改集合」问题。在 foreach 循环里直接调用 enemies.Remove() 会抛出 InvalidOperationException。再有一种原因是在敌机初始化时引用了尚未赋值的字段——比如敌机生成后立即被碰撞检测访问,但它的 Width 或 Height 还没设置。
解决:遍历时需要修改集合一律改 for 循环配合倒序(这是飞机小游戏里的标准姿势)。敌机初始化时,在构造函数或工厂方法里把 Width、Height、Hp 全部先赋值再加入列表,不要把「半初始化」对象暴露给其他系统。另外养成一个习惯:每次在列表里访问对象属性前,先问自己「这个对象会不会还没构造完就被人访问了」。
5.5 飞机移出窗体外回不来
现象:玩家把飞机往左移,飞机半截移到窗体外面,再往右移却发现飞机回不来了,卡在屏幕边缘。
原因:边界约束写错了。常见写法是 if (playerX < 0) playerX = 0; 只处理了左边界,没处理右边界。或者右边界用的是 ClientSize.Width(窗体客户区宽度),但飞机坐标用的是包含边框的窗体坐标,两边差几个像素导致飞机卡在边缘。
解决:边界约束必须四个方向都处理,且统一用 ClientSize 作为参照。注意不要用 Form.Width——它包含标题栏和边框,会导致飞机的右边界拥堵。正确公式是 Math.Max(0, Math.Min(ClientSize.Width - playerWidth, playerX))。上边界同理用 ClientSize.Height 而非 Form.Height。
5.6 最小化后再恢复,画面黑屏或错乱
现象:游戏运行中最小化窗体,再点回来时画面变黑,或者敌机和子弹全部不见了,只有背景还在。
原因:最小化和恢复会触发窗体的重新创建过程,有时会清掉绘制缓存。Graphics 对象在恢复后没有重新获取,或者绘制代码依赖的某些「缓存到局部变量」的数据没有重新初始化。
解决:在 OnPaint 里不要依赖任何在方法外缓存好的 Graphics 对象。每次 OnPaint 都从 PaintEventArgs 里重新取。其次,在 OnResize 或 Shown 事件里重新初始化背景缓存 Bitmap。如果你把背景星空画到了一张 Bitmap 上,那这张 Bitmap 应该在窗体尺寸变化时重建,否则缩放的窗体里会出现黑边或画面拉伸。
6. 进阶优化:对象池和双缓冲,把帧率从 30 稳定到 60
这一章把节奏收在「验证和进阶」上,做两个明确的优化动作:对象池优化批量创建销毁的性能损耗,以及双缓冲的进阶用法。这两个优化做完,你会直观感受到同一份源码在不同写法下的性能差距。做完顺手把帧率显示在窗体标题栏上,这是验证优化效果最透明的手段。
对象池的核心逻辑是:不再每帧 new Bullet() 再等待 GC 回收,而是维护一个空闲池和活跃列表。子弹「死亡」时不真正移除,而是把它标记为不可见,塞回池子;射击时优先从池子拿,池子空了再 new。
private Stack<Bullet> bulletPool = new Stack<Bullet>(); private Bullet GetBulletFromPool(float x, float y) { Bullet b; if (bulletPool.Count > 0) { b = bulletPool.Pop(); b.X = x; b.Y = y; b.IsActive = true; } else { b = new Bullet(x, y); } return b; } private void ReleaseBulletToPool(Bullet b) { b.IsActive = false; bulletPool.Push(b); // 不销毁,留待复用 }这个优化带来的效果在小规模弹幕下不明显,但当你的发射频率提高(比如双发、散弹道具)后,GC 压力会显著降低。注意池子里的 Bullet 对象不要保留跨场景引用,比如不要把它塞进某个事件订阅里——否则你复用的是个「带尾巴」的对象,行为会变得难以捉摸。
双缓冲的进阶用法不是简单地依赖 SetStyle,而是手工管理一块后台 Bitmap。在 OnPaint 里把所有的绘制先画到这张后台图上,再一次 DrawImage 到屏幕上。这比系统级双缓冲更可控:你可以只重绘变化区域,把静态背景完全留在后台图上不动。每帧过后把后台图中「上一帧的子弹」区域用背景覆盖掉,再画上新的子弹位置,这样重绘面积从全屏变成几个小块,性能提升立竿见影。
帧率监控的代码可以放到 OnPaint 末尾:
// 每秒统计一次帧率,刷新标题栏 frameCount++; double elapsed = (DateTime.Now - fpsTimer).TotalSeconds; if (elapsed >= 1.0) { double fps = frameCount / elapsed; Text = $"飞机小游戏 - {fps:F0} FPS"; frameCount = 0; fpsTimer = DateTime.Now; }这三个技巧(对象池、手工双缓冲、帧率监控)结合起来,足以把一个勉强 30 帧的项目推到稳定 60 帧。判断优化是否成功的标准很简单:标题栏的 FPS 是否稳定在接近 60,而不是在 30 到 60 之间反复横跳。反复横跳说明某个帧里有偶发的性能尖峰——比如运行时创建对象、GC 触发、或者某帧的碰撞检测遍历数量突增。真正的目标不是跑满 60,而是保持波动幅度在正负 5 帧以内,这才是玩家感知上「流畅」的门槛。
我自己做这类项目有一条习惯:任何参数改动之后,先不玩,先跑三分钟看 FPS 曲线。如果你也是看 FPS 曲线不再波动才收手,你会少走很多弯路。希望这份拆解对你有用。
本文还有配套的精品资源,点击获取