ARTICLE DETAIL

资讯详情

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

C#飞机小游戏源码拆解:游戏循环、GDI+双缓冲与碰撞检测

C#飞机小游戏源码拆解:游戏循环、GDI+双缓冲与碰撞检测 简介这款C#飞机小游戏源码是一套完整的飞行射击游戏项目面向初步接触C#或希望系统了解游戏开发流程的学习者以短小精悍的代码呈现了游戏从启动到运行的完整闭环适合课程设计、自学实践或作为二次开发的基础模板。资源打包为zip压缩包共61个文件主要包含.cs源码文件、Windows窗体界面设计文件、.resx资源文件、png/jpg图片素材以及wav/mp3音频资源并附有可直接运行的exe和程序集dll整包仅1.25MB轻量便携目前已有259人学习下载。源码中不仅展示了变量、循环、函数等C#基础语法还通过飞机、子弹、敌人等对象体现了面向对象编程思想并深入涉及游戏循环、定时器控制、碰撞检测、事件监听、多线程渲染和资源加载等关键技术点。分析和调试这套代码能帮助开发者理解游戏状态更新、用户输入响应和性能优化的常见手段是一份实操性很强的入门练手资源。1. 为什么 C# 飞机小游戏源码值得一拆一个循环里的完整游戏世界观刚学完 C# 基础最让人心里没底的从来不是委托怎么用、LINQ 怎么写而是“我能不能独立写出一整个能跑的项目”。飞机大战刚好是这个阶段的试金石没有数据库也没有网络通信但游戏循环、对象管理、GDI 渲染、碰撞检测这些游戏开发的骨架它全占了。这套 C# 飞机小游戏源码用 WinForms 搭界面、GDI 画图、定时器驱动循环没有任何引擎依赖打开解决方案就能直接跑。它适合三类人刚完成 C# 入门、想验证自己动手能力的新手需要结课作业或面试作品的学生以及想拿项目带学员的讲师。它的核心价值一句话说清——让你亲眼看到 60FPS 循环在 C# 里怎么从 Timer 一路走到碰撞检测。2. 游戏主循环从 WinForms 定时器到帧率控制2.1 三种驱动方式选型先看场景在 C# 桌面环境里跑游戏主循环绝大多数项目绕不开三条路WinForms 自带的 System.Windows.Forms.Timer、Thread while 死循环、以及基于 Stopwatch 的手动插帧循环。很多人一上来就搜“游戏循环怎么写”照着一篇 Thread while 的文章改结果卡在跨线程访问控件上心态直接崩掉。先给结论教学场景、源码演示场景下Timer 是最稳的入门方案。它的 Tick 事件跑在 UI 线程上可以放心操作界面对象不触发跨线程异常代码也短。代价是精度依赖 Windows 消息泵窗口拖动、弹窗出现时帧率会有波动但飞机小游戏对这种波动并不敏感。Thread while 更适合进阶改造把游戏循环独立到后台线程用 Invoke 或 BeginInvoke 把绘制结果推回 UI 线程。这套方案才能自由控制帧率上限和逻辑更新频率也是很多开源 C# 小游戏的最终形态。Stopwatch 手动插帧通常作为辅助工具出现——它擅长精确计算 DeltaTime而不是直接驱动循环。我一般建议按“Timer 跑通全局 → 看懂循环结构 → 再改造成线程版”的路径走。一上来就把循环结构写成线程版新手大概率在异常处理和双缓冲上两头踩坑不容易收口。选型这件事没有绝对的对错关键是你当前阶段能不能闭环把项目跑到能玩的状态。2.2 核心循环代码与 Interval 参数的换算主循环写在 Timer 的 Tick 事件里思路很直接每帧先更新逻辑再触发重绘。逻辑更新包括玩家移动、子弹发射、敌机生成、碰撞检测这些都在内存里操作数据重绘交给 OnPaint保证画面和逻辑不同步造成撕裂。public partial class GameForm : Form { private Timer gameLoopTimer; private int frameCount 0; private Stopwatch fpsStopwatch Stopwatch.StartNew(); public GameForm() { InitializeComponent(); // Interval16 是目标帧周期的近似值60FPS 对应的理想值是 16.67ms gameLoopTimer new Timer(); gameLoopTimer.Interval 16; gameLoopTimer.Tick GameLoop_Tick; gameLoopTimer.Start(); } private void GameLoop_Tick(object sender, EventArgs e) { // 先更新所有游戏对象再请求重绘顺序颠倒会引发绘制错位 UpdateLogic(); Invalidate(); // 帧率统计每秒刷新一次标题栏验证实际帧率与 Interval 是否一致 frameCount; if (fpsStopwatch.ElapsedMilliseconds 1000) { this.Text $C# 飞机大战 - FPS: {frameCount}; frameCount 0; fpsStopwatch.Restart(); } } }几个参数要单独说清楚。Interval 单位是毫秒16ms 是理想值因为 Timer 的实际触发间隔还取决于消息泵调度真实帧率大概在 55 到 60 之间跳动标题栏的 FPS 就是干这个用的。UpdateLogic() 内部如果做了较多集合遍历和碰撞检测单帧耗时增加FPS 会掉到 45 左右这时优先优化的不是 Interval而是 UpdateLogic 的复杂度——把 Interval 调小并不会让变慢的逻辑变快。Tick 事件里不应该直接写绘制代码更不该调用 CreateGraphics 画图。主循环里只调用 Invalidate() 告诉系统“该重绘了”真正的绘制写在 OnPaint 里。这样系统在处理窗口遮挡、尺寸变化时会自动重绘不会出现“画面被挡住再露出来就花了”的问题。这个习惯也直接影响后续的双缓冲实现是否能生效。2.3 用 FPS 计数器验证循环是否达标帧率统计那段代码不只是显示一个数字它是检验循环行为的第一道工具。判断一套循环逻辑有没有问题最直观的做法是把 FPS 显示在窗体标题栏然后拖动窗体、弹出提示框观察数字变化幅度。如果从 60 掉到 30 以下说明 UpdateLogic 里有阻塞点最常见的两种一是在逻辑更新里做了磁盘或资源加载二是某个对象集合在遍历过程中被修改导致异常反复创建和捕获。网上的 C# 教程很多但能让循环跑出真实 FPS 数字的很少。这里顺带说一个新手容易混淆的点Timer 每帧之间的时间不是严格等距的。Windows 消息泵的优先级低于硬件中断系统忙时两次 Tick 的间隔可能从 16ms 波动到 40ms。所以严谨的做法不是依赖 Timer 的周期而是在 UpdateLogic 里传入本次与上次的时间差用 DeltaTime 计算移动距离。这套源码作为教学版没有做这一步但它保留了 fpsStopwatch——后续你改造成 DeltaTime 驱动时基准就是它。注意Interval 的最小有效值与系统定时器分辨率有关Windows 默认定时器分辨率约 15.6ms所以 Interval 设 10 和设 16 实际触发间隔可能相差不大。要严格锁帧 60需要调用 timeBeginPeriod(1) 提高系统分辨率或者直接上 Stopwatch 手写循环。3. 对象管理用 List 和对象池撑起整个战场3.1 GameObject 基类把共性收敛到一处飞机、子弹、敌机、爆炸特效看起来是四种完全不同的东西但在游戏逻辑眼里都是“每一帧需要更新、需要绘制、有位置和范围”的对象。把这部分共性抽成基类是这套源码里最值得抄的设计。抽完之后主循环的 UpdateLogic() 只需要遍历基类集合不需要对每种对象各写一套循环。public abstract class GameObject { public Rectangle Bounds; // 位置和尺寸碰撞检测直接读它 public bool IsActive true; // 生命周期标记主动失效比 remove 更可控 public int MoveSpeed; // 移动速度单位是像素/帧 public abstract void Update(); public abstract void Draw(Graphics g); }先解释这三个成员的决定。Bounds 用一个 Rectangle 同时表达坐标和碰撞体积比单独维护 X、Y、Width、Height 四个字段方便得多碰撞检测时直接把 Bounds 丢给 IntersectsWith 就行。IsActive 是懒删除的关键——对象逻辑上死亡时先置为 false等遍历结束统一清理避免在循环中间修改集合。MoveSpeed 用“像素/帧”而不是“像素/秒”原因前面提过Timer 教学版不计算 DeltaTime直接用每帧固定步长最直观。玩家、敌机、子弹继承之后各自实现 Update 和 Draw。Update 里做位置移动和状态判断Draw 里按当前状态绘制贴图或图形。这里有一个隐藏约束Update 和 Draw 之间不能有其他逻辑介入否则两个方法拿到的对象状态可能不一致表现出来就是“飞机被击中后还往前飞了半帧”。3.2 对象池与复用把 GC 压力从战场上移走游戏循环每帧都在产生对象特别是子弹发射间隔一短一秒可能新增几十个实例。如果每发子弹都 newGC 会在某个瞬间一次性清理几百个废弃对象表现就是游戏突然卡顿半秒。对象池的做法是预先分配一批对象使用时不 new而是从池里取一个“已失效且可复用”的对象。public class BulletPool { private ListBullet pool new ListBullet(100); public Bullet Acquire() { foreach (Bullet b in pool) { if (!b.IsActive) { b.Reset(); // 重置位置、速度、伤害后复用 return b; } } // 池不够时扩容一次补 20 个避免频繁扩容 for (int i 0; i 20; i) { pool.Add(new Bullet()); } return pool[pool.Count - 1]; } }这段代码的关键是“失效对象优先复用”和“固定步长扩容”。Acquire 先从现有池里找 IsActive 为 false 的对象找到就 Reset 复用找不到再扩容 20 个然后返回最后一个。这样池容量会逐渐逼近峰值需求又不会一次性分配过大。Reset 方法必须把位置、速度、方向、伤害全部重置漏掉任何一项复用的子弹就会带着旧状态出现这类 bug 非常隐蔽。对象池不是所有对象都值得用。飞机小游戏里敌机数量少、生成频率低直接 new 影响不大真正值得池化的是子弹这个高频对象。这套源码整体流畅很大程度归功于子弹做了池而不是在敌机上做复杂的池管理。四十行的对象池代码省掉的是肉眼可见的卡顿。3.3 反向遍历删除循环里改集合的后悔药管理动态对象绕不开删除而“在遍历集合过程中删除元素”是所有新手都会踩的坑。正序遍历调用 RemoveAt(index)被删元素后面的所有元素会前移一位循环索引继续递增就会跳过下一个本应检查的对象。表现是敌机明明中弹了却经常出现“隔了一帧才消失”或者“消失的是后面一架飞机”的诡异现象。// 反向遍历从尾部往头部删元素前移不影响已经检查过的部分 for (int i enemies.Count - 1; i 0; i--) { if (!enemies[i].IsActive) { enemies.RemoveAt(i); } }这个写法不需要额外标记和临时列表逻辑也容易读。另一种工程化的方案是先用 List 收集待删除对象循环结束后统一 RemoveAll。两种都行我更推荐反向遍历直观而且不依赖 RemoveAll 的 Lambda 判断是否触发额外副作用。注意对象池复用时对象只是从活动列表里移除并没有从池里移除——RemoveAt 抹掉的是“活动列表”上的引用池里的实例还在等下一次 Reset。4. GDI 双缓冲与碰撞检测画面不闪、子弹不穿4.1 双缓冲三面旗子一次开齐飞机小游戏里所有元素都在高频移动如果不做缓冲处理每帧重绘时都会被看到“先擦后画”的过程画面闪烁到没法玩。GDI 闪烁的根源是背景擦除系统默认先发送 WM_ERASEBKGND 擦掉旧背景再触发绘制事件这个时间差里窗口暴露的是空背景。双缓冲的原理是在内存里画好整帧再一次 BitBlt 到屏幕用户看到的始终是完整画面。WinForms 里省事的做法是设置窗体的 DoubleBuffered 属性但这套源码用的是另一种更彻底的方式——在构造函数里用 SetStyle 手动打开三个标志。public GameForm() { InitializeComponent(); // 三个标志缺一个都可能出现不同程度的闪烁 this.SetStyle( ControlStyles.AllPaintingInWmPaint | // 阻止系统单独清空背景 ControlStyles.OptimizedDoubleBuffer | // 启用二级缓冲真正的双缓冲 ControlStyles.UserPaint, // 所有绘制交由 OnPaint 处理 true); this.UpdateStyles(); }三个标志各管一块。AllPaintingInWmPaint 告诉系统擦背景的动作并入绘制流程不要单独发 WM_ERASEBKGND 消息OptimizedDoubleBuffer 启用 WinForms 内置的第二缓冲区所有绘制先在缓冲上完成UserPaint 声明窗体完全自己绘制允许前两个标志生效。设置完记得调用 UpdateStyles()否则标志不会立刻应用。这里有个容易被忽略的点双缓冲生效的前提是“所有绘制都在 OnPaint 里完成”。如果在 Tick 事件里直接 CreateGraphics 画了某个元素那个元素绕过了缓冲直接画在窗口表面下一帧缓冲整体刷新时它就会“闪没”再“闪回”。检查方法是在窗体 OnPaint 里画完所有对象构造函数里一行绘制代码都不要写。4.2 碰撞检测矩形相交优先别碰 GetPixel碰撞检测有两种极端做法。像素级检测是拿透明位图逐像素判断两图像 Alpha 通道是否有重叠区域精确但代价太高——每帧对整帧画面做像素遍历在 C# 里用 GetPixel 读位图颜色几十毫秒就没了游戏直接掉到十几帧。矩形碰撞检测用对象的 Bounds 做相交判断几行代码搞定肉眼效果几乎一样因为大多数飞机素材都是方正的。private void CheckCollisions() { foreach (Bullet bullet in bullets) { if (!bullet.IsActive) continue; foreach (Enemy enemy in enemies) { if (!enemy.IsActive) continue; // 矩形相交即视为命中 if (bullet.Bounds.IntersectsWith(enemy.Bounds)) { bullet.IsActive false; enemy.HP - bullet.Damage; if (enemy.HP 0) { enemy.IsActive false; score enemy.ScoreValue; } break; // 一颗子弹只判定一次命中命中后跳出内层循环 } } } }这个双层循环看起来是 O(n*m)但飞机小游戏的对象数量级很小子弹池里活跃子弹通常几十个敌机最多十几个每帧几十次矩形比较对 CPU 来说可以忽略。如果以后把对象数量放大到几百再考虑空间划分比如屏幕按网格分区只检测同区域对象。现在的规模这个简单版本反而最稳。GetPixel 不是完全没用它的合理位置是“碰撞发生后做二次精细判定”——先用矩形相交粗筛再对相交区域里两图像的重叠像素做精确判定。飞机小游戏用不上这层复杂度矩形判定足够肉眼根本分不清差的那几个像素。4.3 高速子弹为什么穿透步长与连续检测子弹速度调到 20px/帧以上会出现一个典型问题子弹从一个位置飞到下一个位置前后两个瞬间的矩形都没碰上敌机矩形但实际它已经“穿过去”了。这是因为矩形检测只检查离散时刻的状态不检查这段时间内路径上发生了什么。敌机宽度只有 40px子弹一帧飞 30px 时很可能直接跳到敌机身后前后两帧的相交检测全部落空。// 连续碰撞检测把一步拆成多个小步逐段做矩形相交 private bool IsHitOnPath(Rectangle bulletBounds, int stepX, int stepY, Rectangle target) { int steps Math.Max(Math.Abs(stepX), Math.Abs(stepY)); steps Math.Max(steps, 1); for (int i 0; i steps; i) { bulletBounds.X Math.Sign(stepX); bulletBounds.Y Math.Sign(stepY); if (bulletBounds.IntersectsWith(target)) { return true; } } return false; }思路是把一步移动拆成若干小步每小步最多 1 像素逐段检查矩形是否相交。steps 取水平、垂直两个方向位移的较大值保证两方向步数一致路径不失真。Math.Sign 返回 -1、0、1保证每次只移动一格。代价是碰撞检测次数变多但对象少完全扛得住。现实游戏里高速子弹通常会配合“降低伤害、增加射速”来平衡体验。作为源码实现把子弹速度控制在 12px/帧以内普通矩形检测就不会出问题要做超高速弹幕才需要启用这段连续检测逻辑。注释里写清楚这个阈值后面接手的人不会踩同一个坑。5. 避坑指南这套源码最容易翻车的五个现场5.1 输入响应按住方向键飞机不走直线现象第一次按键飞机立刻移动持续按住后移动变得一顿一顿像按键失效了松开再按又恢复。原因是 Windows 系统对键盘事件有“按下-延迟-重复”机制。WinForms 的 KeyDown 事件不会持续触发而是受系统键盘重复延迟配置影响默认首次按下后要等约 500ms 才开始重复触发。游戏循环里的移动逻辑如果依赖 KeyDown 累积方向就必然卡在这个系统延迟里。解决方式是改用一个 HashSet 记录所有当前按下的键KeyDown 和 KeyUp 只负责添加、移除按键实际移动在 UpdateLogic 里根据集合内容计算方向。这样移动完全由游戏循环驱动不再依赖系统按键重复节奏。private HashSetKeys pressedKeys new HashSetKeys(); protected override void OnKeyDown(KeyEventArgs e) { pressedKeys.Add(e.KeyCode); base.OnKeyDown(e); } protected override void OnKeyUp(KeyEventArgs e) { pressedKeys.Remove(e.KeyCode); base.OnKeyUp(e); } // UpdateLogic 中调用 // if (pressedKeys.Contains(Keys.Left)) dx - player.MoveSpeed; // if (pressedKeys.Contains(Keys.Right)) dx player.MoveSpeed;5.2 边界生成敌机从屏幕边缘“挤”进来现象敌机从左侧生成时像窗帘一样慢慢拉出来经过很长一段距离才露出完整机身看起来是从空气里长出来的。原因是生成代码把敌机的 X 坐标限制在 0 到 ClientSize.Width 之间但敌机对象的位置通常指矩形左上角。X 为 0 时敌机机身可能有几十像素被挤在屏幕外于是视觉上出现“挤进来”的过程。解决方式是给生成逻辑做边界偏移X 范围应该是 -enemy.Width 到 ClientSize.Width - enemy.Width。敌机从负坐标开始进入屏幕才能做到机身完整滑入的效果。// 生成坐标的偏移量把机身宽度和高度纳入边界计算 int spawnX random.Next(-enemy.Width, this.ClientSize.Width - enemy.Width); int spawnY -enemy.Height; // 从屏幕上方进入5.3 窗体缩放后的黑边与错位现象拖动窗口大小后游戏区域边缘出现黑色或白色条带部分绘制内容停留在旧区域。原因是窗体尺寸变化后双缓冲缓冲区仍沿用旧尺寸绘制区域与窗口新客户区不匹配。更隐蔽的是如果背景是星空图案背景图尺寸没有跟着更新就会出现一条粗细不均匀的色带。解决方式是在 OnResize 里更新缓冲区并在 OnPaint 里根据 ClientSize 重新绘制背景。如果使用 ControlStyles.OptimizedDoubleBufferWinForms 会自动重新分配缓冲但如果是自定义 BufferedGraphics就必须手动重新 Allocate。标准做法是让 Resize 事件强制下一帧完整重绘不要依赖旧的背景位图。protected override void OnResize(EventArgs e) { base.OnResize(e); // 强制下一帧完整重绘避免残留旧区域 Invalidate(); }5.4 暂停恢复后飞机和子弹“跳”现象暂停 5 秒后点继续游戏恢复的同一帧所有对象像瞬移一样跳到另一个位置之后又恢复正常。原因是如果主循环改造成了 Thread Stopwatch 手写循环暂停期间 Stopwatch 仍在累计时间恢复时的 DeltaTime 可能是几百毫秒。移动代码用的是“位移 速度 × DeltaTime”巨大 DeltaTime 把速度放大了几十倍于是瞬移。解决方式是在暂停入口记录时间戳恢复时把 DeltaTime 基准重置到当前时刻。Timer 版本没有 DeltaTime所以不触发这个坑但一旦按第二章的思路改造成手写循环就一定会遇到。这个坑提前写清楚改造时就能少翻一次车。// 暂停时记下基准时刻 private void PauseGame() { isPaused true; lastTick Environment.TickCount; } // 恢复时重置基准避免 DeltaTime 突变 private void ResumeGame() { isPaused false; lastTick Environment.TickCount; } // 帧循环内计算 DeltaTime int currentTick Environment.TickCount; float deltaTime (currentTick - lastTick) / 1000f; lastTick currentTick;5.5 随机数种子每次开始的弹幕像录像回放现象每次点击重新开始前几波敌机和子弹出现的位置规律几乎一模一样像在重复播放同一段录像。原因是 Random 默认以时间作为种子但如果代码里在游戏循环中快速连续创建了多个 Random 实例这些实例的种子在极短时间内相同生成的随机序列完全一致。常见写法是在敌机生成方法里直接new Random()一帧内多次调用拿到的是同一个“随机流”。解决方式是整个项目只保留一个静态 Random 实例所有随机需求都从它取数。这种看似玄学的问题根源其实是构造函数调用时机而不是随机算法本身。// 全局只保留一个 Random 实例避免连续 new 产生相同种子 private static readonly Random rng new Random(); // 用全局实例替代局部 new Random() int spawnX rng.Next(-enemy.Width, this.ClientSize.Width - enemy.Width);6. 把参数抽成配置类给源码留一条换皮捷径这套源码真正值得你带进下一个项目的设计是把散落在事件里的魔数集中到配置类。射击间隔、玩家速度、敌机生成频率、子弹速度、敌机血量全部用常量收编到一个类里。以后想从“普通模式”改成“困难模式”只改配置不动逻辑。public static class GameConfig { // 玩家相关 public const int PlayerMoveSpeed 8; // 像素/帧太快会穿透碰撞 public const int PlayerFireIntervalMs 180; // 毫秒射速与弹幕密度的平衡点 public const int PlayerMaxHp 3; // 子弹相关 public const int BulletSpeed 12; // 超过 20 建议启用连续碰撞检测 public const int BulletPoolSize 100; // 池的初始容量 // 敌机相关 public const int EnemySpawnIntervalMs 1200; public const int EnemyMinSpeed 2; public const int EnemyMaxSpeed 6; public const int EnemyMaxCount 12; // 同屏上限防止生成过多拖慢帧率 // 难度系数每击杀 10 个敌人提升一次生成速率 public const int DifficultyStep 10; public const float SpawnIntervalDecreaseRate 0.85f; }参数的含义直接写在注释里后面的人改起来心里有数。比如 EnemyMinSpeed 和 EnemyMaxSpeed 会决定敌人的压迫感建议差值保持 4 到 6太小整体节奏呆板太大新手反应不过来。PlayerFireIntervalMs 是手感的关键180ms 是折中值调到 120 会变成全屏弹幕调到 300 会觉得武器乏力。改之前先看注释里的平衡点说明比直接试值高效得多。验证配置改动是否合理我习惯把难度参数做成三档预设简单、普通、困难各自对应一组配置常量。玩法逻辑不变只切换配置类就能给一套源码扩出三种体验。这比在代码里堆 if else 判断难度干净得多。从那以后我拿到任何一套游戏或图形界面的源码第一件事都是把魔数列出来问自己这个数能不能在十秒内找到、改了会不会破坏别处逻辑。这个习惯救过我很多次后来做偏界面的项目时轮询间隔、刷新频率同样靠集中配置兜底希望帮到你。本文还有配套的精品资源点击获取
返回列表