ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C#飞机小游戏源码实战:WinForms双缓冲与碰撞检测全解析

C#飞机小游戏源码实战:WinForms双缓冲与碰撞检测全解析 简介这款C#极品飞机小游戏源码是一份面向C#初学者的飞行射击游戏完整工程涵盖对象创建、碰撞检测、游戏循环等核心模块适合希望将语言基础落地到实际项目、或入门游戏开发的学习者研读。压缩包共61个文件包含17个cs源码文件、5个png图片、4个wav与2个mp3音频、4个dll、3个exe以及sln解决方案等整体大小1.25MB结构精简、便于直接打开运行与逐段分析。已有259人学习下载评价与参考价值较为直观。通过阅读代码可掌握Windows窗体程序的事件驱动机制、GDI绘图流程、定时器帧率控制、碰撞检测算法、资源加载与多线程分工等知识点同时项目中的DiuLei、MCIPlay、DXPlay等类模块也展示了如何组织飞机、子弹、敌人等游戏对象为后续扩展成完整商业小游戏提供了可复用的骨架与排查思路。1. 从控制台到游戏窗口C#极品飞机小游戏源码为什么值得复现把C#和源码放到一起搜结果大多是控制台程序或工具类脚本但飞机小游戏是个例外。C#极品飞机小游戏源码常年出现在课程设计和源码分享站里看起来像个玩具实际上它是C#入门到进阶之间性价比最高的一段距离一个空窗体几百行代码就能把事件驱动、GDI绘图、泛型集合、碰撞检测这些零散的知识点串成一个能玩的东西。如果你刚看完语法书或者正在为一个C#练手项目发愁这篇笔记从建项目到调手感的完整过程都写清楚了照着做就能跑通。2. 先定架构再敲代码飞机小游戏的选型与骨架很多人拿到这类源码的第一反应是双击 Form1.cs然后被一大坨事件处理代码劝退。我建议反过来先别碰窗体层把游戏逻辑的骨架搭出来让窗口只做窗口的事游戏只做游戏的事。2.1 为什么是 WinFormsC#新手的第一窗口技术飞机小游戏源码的常见载体有三个WinForms、WPF、Unity。搜C#小游戏源码时三种混在一起但要论易读、易改、环境门槛低WinForms 几乎是最优解。方案绘图方式入门门槛适合场景WinFormsGDIGraphics 类低装好 Visual Studio 就能跑2D 逻辑游戏、课程设计WPFDrawingVisual / WriteableBitmap中要理解依赖属性、模板界面复杂但逻辑简单的 DemoUnity引擎渲染高场景、组件、生命周期都得学想往游戏行业发展的人我一般会选择 WinForms 来写飞机大战不是因为 WPF 或 Unity 不好而是做这个项目的核心目的是把 C# 语言层面的东西练熟不是练引擎操作。WinForms 里每一个像素都是你用 Graphics 画出来的碰撞靠 Rectangle 判断这迫使你真正去理解坐标系统、Paint 事件和对象生命周期。如果你学 C# 的目标是上位机或业务系统这套经验同样成立窗体、队列、定时器、委托的配合方式能直接搬到上位机项目里。提示飞机小游戏里这套窗体只负责绘制和输入、逻辑单独成类的结构就是小型上位机框架的原型别觉得游戏代码没用。2.2 双缓冲不装第三方渲染库也能稳住帧率飞机小游戏最常见的翻车现场就是闪屏。原因很好理解每次 Paint 都要先清空窗口背景再画新画面窗口一刷新前一帧被擦掉、后一帧还没画出来肉眼看到的就是闪烁。GDI 的解法是双缓冲。Windows Forms 里最省事的开启方式是在窗体构造函数里设置这几个属性public partial class MainForm : Form { public MainForm() { InitializeComponent(); // 双缓冲先在内存画布上绘制再一次性拷贝到屏幕 this.SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true); this.UpdateStyles(); // 飞机小游戏固定窗口尺寸比自适应省太多事 this.ClientSize new Size(480, 700); this.DoubleBuffered true; } }关键参数说明AllPaintingInWmPaint让系统在 WM_PAINT 消息里一次画完减少擦除请求。OptimizedDoubleBuffer启用 GDI 内部缓冲画面先画到内存位图再整体输出。DoubleBuffered true和前面三个风格配合时效果最稳。有个常见误解只设DoubleBuffered true其实也能缓解闪屏但如果你同时在 OnPaint 里调了Graphics.Clear()闪烁依然会回来。真正的双缓冲要避免在绘制路径上做耗时操作比如每帧都new Font或者LoadBitmap。资源只加载一次绘制只负责画。2.3 游戏循环System.Windows.Forms.Timer 背后的委托与事件飞机大战和业务程序最大的区别在于业务程序是等事件来了再处理游戏是每过几十毫秒就必须重画一次。这个循环如果放在独立线程里所有对 UI 控件的访问都要用 Invoke 封送新手很容易在跨线程访问上翻车如果放在 UI 线程的 Timer 里代码简单但优先级受窗口消息影响。对这种小游戏源码来说System.Windows.Forms.Timer足够。它的 Tick 事件本身就在 UI 线程执行逻辑和绘图不用加锁。这里的语法本质就是 C# 委托和事件事件源Timer不关心谁在听它只负责按固定间隔广播通知订阅者各自干活。private System.Windows.Forms.Timer _gameTimer; private void InitGameLoop() { _gameTimer new System.Windows.Forms.Timer(); _gameTimer.Interval 16; // 约60FPS _gameTimer.Tick OnGameTick; _gameTimer.Start(); } private void OnGameTick(object sender, EventArgs e) { UpdateGameLogic(); // 先更新坐标、碰撞、生成 Invalidate(); // 请求重绘OnPaint 里做实际绘制 }帧率从哪来1000 / 16 ≈ 62.5这就是 60FPS 的来历。注意 WinForms 的 Timer 不是高精度定时器负载高时实际间隔可能漂到 20ms 以上。所以游戏逻辑里的位移量应该用每次 Tick 移动多少像素来度量而不是依赖绝对时间。飞机小游戏复杂度不高这种简化完全可接受。参数上有个血泪经验Interval 不要小于 10。Windows 消息循环本身有分辨率下限低于 10ms 时 Timer 会失效表现为掉帧、敌机瞬移。那不是你代码的 Bug是 Timer 的物理极限。3. 最小可玩版实现把飞机、子弹和敌机跑起来骨架定了之后剩下来的就是把三类对象填进去玩家、子弹、敌机。这部分代码按最简可运行的顺序给你可以照着敲也可以拿它当目录结构去对照别人源码的实现。3.1 项目骨架Game 类、Bullet 类和 Enemy 类的职责划分我见过不少飞机大战源码把所有逻辑堆在 Form 里窗体里既有图片加载又有碰撞检测还有成绩统计看着很爽改起来很苦。这里建议用一个 Game 类管理所有实体窗体只负责两件事接收键盘输入、在 Paint 里画 Game 的画面。public class Game { public const int PlayAreaWidth 480; public const int PlayAreaHeight 700; public Player Player { get; private set; } public ListBullet Bullets { get; private set; } public ListEnemy Enemies { get; private set; } public int Score { get; private set; } public int Life { get; private set; } 5; public bool GameOver { get; private set; } private readonly Random _rand new Random(); private int _enemySpawnCounter; public Game() { Player new Player(PlayAreaWidth / 2, PlayAreaHeight - 80); Bullets new ListBullet(); Enemies new ListEnemy(); } }三个集合的语义要定清楚Bullets是玩家发射的子弹Enemies是当前存活敌机。为什么用ListT而不是数组因为要往集合中间反复插入和移除对象ListT的Add和RemoveAt配合倒序遍历是这类源码最顺手的写法。如果你看到某份源码在 Update 里用foreach遍历 List 并删除元素那它大概率会在运行几秒后抛InvalidOperationException这个坑下文避坑章节还会展开。3.2 玩家控制与子弹发射键盘状态与冷却参数玩家的基本行为只有两个左右移动和发射子弹。移动在窗体层处理发射在 Game 层处理中间用键盘事件传方向。先定义玩家和子弹两个实体类public class Player { public int X { get; set; } public int Y { get; set; } public int Speed { get; set; } 6; // 每次Tick移动的像素数 public int ShootCooldown { get; set; } // 帧数 public int CooldownCounter { get; set; } public Player(int x, int y) { X x; Y y; ShootCooldown 10; // 每10帧发射一次60FPS下约6发/秒 } } public class Bullet { public const int Width 8; public const int Height 16; public int X { get; set; } public int Y { get; set; } public int VelocityY { get; set; } // 负数表示向上 public Bullet(int x, int y, int velocityY) { X x; Y y; VelocityY velocityY; } }窗体侧监听键盘状态。构造函数里记得设this.KeyPreview true;否则焦点在窗体空白处时可能收不到按键消息private bool _leftPressed; private bool _rightPressed; private bool _spacePressed; private void MainForm_KeyDown(object sender, KeyEventArgs e) { if (e.KeyCode Keys.Left) _leftPressed true; if (e.KeyCode Keys.Right) _rightPressed true; if (e.KeyCode Keys.Space) _spacePressed true; } private void MainForm_KeyUp(object sender, KeyEventArgs e) { if (e.KeyCode Keys.Left) _leftPressed false; if (e.KeyCode Keys.Right) _rightPressed false; if (e.KeyCode Keys.Space) _spacePressed false; }为什么不用 KeyDown 直接移动因为按住按键时 Windows 会先触发一次 KeyDown然后延迟约 500ms 才开始重复触发直接移动会感到飞机顿一下才动。用布尔标记配合 Timer 的 Tick 做连续移动手感是平滑的。Game 层把按键状态转成移动和射击public void UpdatePlayerInput(bool left, bool right, bool space) { if (left) Player.X - Player.Speed; if (right) Player.X Player.Speed; Player.X Math.Max(0, Math.Min(PlayAreaWidth - 40, Player.X)); if (space Player.CooldownCounter 0) { Bullets.Add(new Bullet(Player.X 15, Player.Y, -12)); Player.CooldownCounter Player.ShootCooldown; } if (Player.CooldownCounter 0) Player.CooldownCounter--; }两个参数值得解释Player.X 15是弹道出生位置让子弹从机头而不是机身中间飞出-12是子弹垂直速度负数向上Y 轴向下为正这是 GDI 坐标系里最容易搞反的一点。如果你发现子弹往地下钻先查这里。3.3 敌机生成与碰撞检测矩形相交与越界清理敌机不要一次性全生成用一个计数器控制节奏更自然。Game 的 Update 方法是每帧的唯一入口public void Update() { // 每50帧生成一架敌机60FPS下约1.2秒一架 _enemySpawnCounter; if (_enemySpawnCounter 50) { _enemySpawnCounter 0; Enemies.Add(new Enemy(_rand.Next(0, PlayAreaWidth - 40), -30)); } // 敌机向下移动 foreach (var enemy in Enemies) { enemy.Y enemy.Speed; // 初期Speed设3~5 } // 子弹向上移动 foreach (var bullet in Bullets) { bullet.Y bullet.VelocityY; // 负数向上 } CheckCollisions(); RemoveOutOfBounds(); }碰撞检测用最简单的矩形相交private void CheckCollisions() { // 倒序遍历移除元素时索引不会乱 for (int i Bullets.Count - 1; i 0; i--) { for (int j Enemies.Count - 1; j 0; j--) { Rectangle bulletRect new Rectangle( Bullets[i].X, Bullets[i].Y, Bullet.Width, Bullet.Height); Rectangle enemyRect new Rectangle( Enemies[j].X, Enemies[j].Y, 40, 30); if (bulletRect.IntersectsWith(enemyRect)) { Score 10; Enemies.RemoveAt(j); Bullets.RemoveAt(i); break; // 一颗子弹只打一架敌机 } } } }这里有个边界细节IntersectsWith处理的是瞬间位置相交。子弹速度大于敌机宽度时两帧之间的移动可能让子弹直接穿过去。现阶段速度 12、敌机宽度 40单帧位移小于宽度当前逻辑不会漏判。如果你把子弹改成 30px/帧就得用第 5 章的连续碰撞检测方案。4. 参数与手感从能跑到能玩的四个关键设置代码能跑是一回事玩起来有没有飞机大战的手感是另一回事。下面四个参数是我调这类小游戏时每次都要碰的。4.1 帧率与位移Timer.Interval 和 Speed 参数的换算表Timer.Interval 定的是每帧的墙钟时间像素位移定的是每帧移动多少像素。二者相乘就是实际速度Interval16ms、位移6px约 375px/s。常见的误区是直接照搬别人的数值玩家飞机通常在 40px 宽边界只有 480px 宽6px/帧已经偏快再往上加就会显得飘。我常用的初版参数表对象60FPS 下建议像素位移说明玩家左右移动5~7 px/帧手感从飘到跟手的区间玩家子弹10~14 px/帧要明显快过玩家移动才有射击感敌机2~5 px/帧超过6就接近躲不开调参步骤我一般固定先把玩家位移设 5子弹设 12敌机设 3跑两分钟看哪里不对劲再改。一次只改一个参数。一次改三个出了问题你分不清是谁的锅。4.2 难度曲线生成间隔按得分动态衰减的写法固定 50 帧出一架敌机是压测不是游戏。合理的做法是让生成间隔随得分变化private int GetSpawnInterval() { // 基础间隔80帧每得200分减少5帧下限20帧 int interval 80 - (Score / 200) * 5; return Math.Max(20, interval); }这里两个数值值得讲Score / 200是难度台阶200 分升一级下限 20 帧意味着最密集时约每秒 3 架敌机。如果低于 20玩家的发射冷却10 帧会被大量敌机淹没游戏从有挑战变成不公平。另一种常见做法是给敌机加血量让后期敌机撞一下不死public class Enemy { public int Hp { get; set; } public int Speed { get; set; } public Enemy(int hp 1, int speed 3) { Hp hp; Speed speed; } }碰撞逻辑里把Enemies.RemoveAt(j)改成Enemies[j].Hp - 1; if (Enemies[j].Hp 0) Enemies.RemoveAt(j);即可。加血量比加速更有效因为速度有上限超过一定阈值玩家会认为这不是难度这是 Bug。4.3 对象生命周期越界清理与内存只增不减的真相飞机大战源码里最容易埋雷的地方不是碰撞而是对象的生老病死。敌机移出屏幕后如果不移除ListEnemy只会越长越大每帧遍历耗时不断上升最终表现为越打越卡。典型解法是每帧检查越界对象private void RemoveOutOfBounds() { // 玩家子弹飞出屏幕顶部就删 for (int i Bullets.Count - 1; i 0; i--) { if (Bullets[i].Y -20) { Bullets.RemoveAt(i); } } // 敌机飞出屏幕底部就删同时扣一条命 for (int i Enemies.Count - 1; i 0; i--) { if (Enemies[i].Y PlayAreaHeight) { Life - 1; Enemies.RemoveAt(i); } } }注意两个边界子弹用-20是因为子弹高度 16完全离开画布再删敌机用PlayAreaHeight是因为敌机顶部超出窗口就不会再被看到不用等它完全离开。每帧删除在 List 上其实有开销但场内只有几百个对象时连 1ms 都不到不必为了这点性能硬写对象池——那是 Unity 场景才需要操心的事。4.4 资源释放音效文件的加载与 Dispose 时机如果源码里带了音效一般用System.Media.SoundPlayer。这个类在某些环境下会因为文件句柄没释放而锁住资源常见做法是在窗体的 Dispose 里一起释放private System.Media.SoundPlayer _shootSound; protected override void Dispose(bool disposing) { if (disposing) { _shootSound?.Dispose(); _shootSound null; } base.Dispose(disposing); }飞机小游戏的音效不是关键路径如果你还在把逻辑跑通的阶段音效可以最后加。不建议用 WAV 循环播放背景音乐那会让 CPU 占用肉眼可见地上升。WinForms 没有原生低开销的 MP3 播放方案背景音乐直接砍掉手感和代码结构都不会受影响。5. 飞机小游戏源码的 5 个高频坑现象、原因、解法飞机小游戏源码在不同人手里写法千差万别但翻车的地方高度一致。下面 5 个问题我在问答区反复看到每条按现象、原因、解决整理你复现时可以直接对照排查。5.1 窗体一开就闪屏双缓冲没生效现象窗口刚开始跑就闪得厉害尤其敌机数量上来之后能看到背景被擦成白色再重新画出整个游戏区域像老电视一样稳定不下来。原因窗体默认先擦除背景再画前景双缓冲根本没生效。常见是两个极端要么只设了DoubleBuffered true但绘制路径里有Graphics.Clear()反复清屏要么在 OnPaint 里每帧都new Bitmap()加载图片把绘制路径变成了 IO 操作。解决构造函数里确认SetStyle(ControlStyles.OptimizedDoubleBuffer, true)已设置然后检查 OnPaintprotected override void OnPaint(PaintEventArgs e) { // 不要在OnPaint里创建Bitmap、Font、Brush // 所有资源在构造阶段加载成字段OnPaint只做Draw Graphics g e.Graphics; Game.Draw(g); // 外部传入Graphics内部只管画 }判断标准很简单OnPaint 里如果出现new关键字就是性能隐患。资源加载一次绘制只做拷贝。5.2 子弹穿透敌机高速物体的矩形漏判现象子弹明明从敌机身上飞过就是打不中把子弹速度调慢命中又恢复正常。原因碰撞检测只做了本帧两矩形是否相交的静态判断。子弹 12px/帧、敌机宽度 40px 时没问题但如果子弹改成 20 甚至 30px/帧单帧位移超过目标宽度就会发生子弹本帧在敌机上方、下一帧已在敌机下方的漏判。解决限制速度是一种做法让子弹位移小于目标最小边长。更可靠的是连续碰撞检测——把敌方矩形沿子弹运动方向扩展一个位移量private bool BulletHitEnemy(Bullet bullet, Enemy enemy) { int dy Math.Abs(bullet.VelocityY); // 单帧子弹位移量 Rectangle bulletRect new Rectangle(bullet.X, bullet.Y, Bullet.Width, Bullet.Height); // 把敌机矩形沿Y方向上下各扩展一个dy覆盖子弹一帧内可能经过的区间 Rectangle enemyRect new Rectangle( enemy.X, enemy.Y - dy, 40, 30 dy * 2); return bulletRect.IntersectsWith(enemyRect); }注意enemy.Y - dy把矩形顶部向上扩展Height dy * 2把底部向下扩展。这样子弹无论落在上一帧和当前帧之间的哪个位置都能被检测到。代价是敌机判定范围变大了一点点但子弹只有 8px 宽这个误差肉眼根本看不出来。5.3 越打越卡List 里的失效对象没有清干净现象游戏跑一分钟流畅跑五分钟开始掉帧十分钟后移动都是一顿一顿的打开任务管理器看到进程内存持续上涨。原因敌机和子弹只增不减。很多人写了生成逻辑却忘了删除逻辑或者用foreach遍历 List 并在循环体里Remove抛异常后干脆放弃清理。这样一来集合规模只涨不跌每帧遍历的耗时越来越高。解决每帧调用RemoveOutOfBounds()删除统一用倒序 for 循环。遇到内存上涨但不知道哪来的情况排查步骤任务管理器里盯进程内存确认在涨。在RemoveOutOfBounds里打断点看每帧是否真的有删除发生。在 Game.Update 开头输出Bullets.Count和Enemies.Count观察是否持续递增。提示如果游戏里加了爆炸特效特效对象同样要放进集合并按时清理。特效是看不到的泄漏等你看到卡顿时集合里已经堆了几千个废弃对象了。5.4 长按方向键一顿一顿键盘重复延迟在作祟现象按住左方向键飞机动一下、停一下、再继续动松开再按又重复这个过程玩起来手感非常肉。原因Windows 键盘的按键重复机制在 WinForm 里表现为按下瞬间触发一次 KeyDown约 500ms 延迟后才开始连续触发。如果代码直接用 KeyDown 移动玩家感受到的就是卡顿的起步。解决用 KeyDown/KeyUp 维护布尔状态在 Timer 的 Tick 里读取状态并移动。这个方案第 3 章已经实现关键点是把按键和移动解耦成两个事件源private void OnGameTick(object sender, EventArgs e) { if (_leftPressed) Player.X - Player.Speed; if (_rightPressed) Player.X Player.Speed; if (_spacePressed) TryShoot(); }Keys.Left和Keys.A可以同时监听满足喜欢用左手操作键盘的玩家。键位冲突时用e.KeyCode判断不会互相覆盖。5.5 背景滚动接缝抖动取模运算解决的一切玄学现象用两张背景图循环向下滚动接缝处每隔几秒闪一下或者错位几个像素。有人说是缓存问题有人说是显存问题实际上跟硬件一点关系都没有。原因背景图片高度不是窗口高度的整数倍或者滚动增量和图片高度不整除。比如背景 700px、每次滚动 3px700 不能被 3 整除跑几轮之后偏移错位接缝自然就出来了。解决把滚动增量定为背景高度的约数推荐 1、2、5、7、10 这类因数或者干脆在绘制时用取模控制偏移// _bgOffset 每次Tick加1始终落在0~_bgHeight-1区间 int offset _bgOffset % _bgHeight; g.DrawImage(_bg, 0, offset - _bgHeight); // 上一段 g.DrawImage(_bg, 0, offset); // 下一段两次DrawImage就够了关键在于offset永远在背景高度范围内做周期循环模运算保证接缝严格对齐。从此告别每隔几秒抖一下的玄学问题。6. 最后一跃从一份可玩源码到自己的 C# 游戏框架整个项目跑通之后我建议你做一次重构把 Game 类、碰撞检测、对象管理从窗体里彻底剥出来让 Form 只负责输入和渲染。一旦边界划清楚这份代码就能复用到其他 2D 小游戏上——换成贪吃蛇只需要改对象模型换成弹球只需要改碰撞响应。这就是源码和框架的分界线。一个具体的验证方法把 Game 类抽成独立类库再加一个接口public interface IRenderable { void Draw(Graphics g); void Update(); }让 Player、Bullet、Enemy 都实现它Game 的 Update 和绘制循环就可以统一遍历ListIRenderable。做到这一步你手里这份源码就已经不是飞机小游戏源码了而是一个能装下很多 2D 小游戏的壳。以后课程设计换题目你只改实体类不用再碰绘图和循环。我自己的习惯是每次改完这类小游戏都会顺手做一次掉帧测试把 Timer 的 Interval 从 16 改成 8观察游戏是否还能稳定跑。如果掉帧明显说明绘制里有不该出现的开销。这个习惯后来做上位机图表时也帮了大忙很多卡顿问题在写业务代码之前就暴露了。希望帮到你。本文还有配套的精品资源点击获取
返回列表