简介:面向C#初学者与游戏开发入门者的飞行射击小游戏完整源码,围绕飞机、子弹与敌机等对象的创建、交互和碰撞检测展开,可帮助理解游戏循环、状态更新与屏幕绘制的基本流程。压缩包共61个文件、约1.25MB,以17个cs源码为核心,辅以png、jpg、gif图像,wav、mp3音频,dll与exe运行组件,以及sln、csproj、resx等工程配置,便于打开解决方案梳理项目结构。已有264人学习,适合课程设计、自学练手或小型项目参考。代码涉及窗体界面绘制、图形渲染、定时器与帧率控制、资源加载、事件响应及调试优化,能让人把C#语法、面向对象设计和游戏开发关键模块串联起来,在可运行案例中理解对象属性、行为设计与碰撞算法,并完整呈现主循环调度、资源管理与性能优化思路等实现细节。
1. 一个 Form 加一个 Timer:这套飞机小游戏源码能拿来干什么
有个做后端的 A 同学找过我,说想给孩子写个能玩的东西,问有没有现成能直接打开就跑的 C# 游戏源码。我把这套飞机小游戏源码丢给他,从双击 sln 到敌机成群压下来,前后不到十分钟——没有游戏引擎、没有包管理器、没有一串要先配好的运行时,就是 WinForms 加一个 Timer。
这类 C# 游戏源码的价值不在于画面多精致,而在于它把「游戏循环、画面渲染、键盘输入、碰撞检测、对象回收」这五件事用最少的代码摆在你面前。飞机射击是天然的练手题材:输入只有方向键和开火,状态只有分数和血量,逻辑闭环短,改起来立刻能看到效果。适合两类人:想搞明白游戏主循环怎么搭的新手,和需要一个干净底座做课程设计或小工具的开发。
2. 工程骨架与运行环境:从 sln 双击到第一架飞机起飞
拿到一份 C# 游戏源码,第一件事不是看代码,是先把它跑起来。这个项目的结构非常扁平,没有分层架构,也没有依赖注入那套东西,好处是阅读成本极低,坏处是所有逻辑都挤在少数几个文件里,后面要改就得先摸清谁在调谁。
2.1 目录结构与文件清单
常见做法是把工程拆成「资源 / 逻辑 / 主窗体」三块,虽然物理上还在一起,但文件命名会把职责区分开。
PlaneGame/ ├── PlaneGame.sln ├── PlaneGame.csproj ├── Program.cs // 入口,只做 Application.Run ├── MainForm.cs // 窗体:初始化、定时器、按键、绘制入口 ├── MainForm.Designer.cs // 控件与定时器声明 ├── Core/ │ ├── GameLoop.cs // 帧逻辑:更新 + 碰撞 + 回收 │ ├── GameObject.cs // 对象基类 │ └── ObjectPool.cs // 通用对象池 ├── Entities/ │ ├── Player.cs │ ├── Enemy.cs │ ├── Bullet.cs │ └── Explosion.cs ├── Config/ │ └── WaveTable.cs // 刷怪表和难度参数 └── Resources/ ├── player.png ├── enemy_01.png └── bg_star.png看这份清单就能大致判断代码量:核心逻辑集中在 Core 和 Entities 两个目录,MainForm 只是个壳。我一般拿到手会先看GameObject.cs和ObjectPool.cs,这两个文件决定了后面所有实体的写法,读完基本就知道作者的水平在什么档位。
资源图片这一块要留意属性。图片被放进 Resources 目录后,务必在属性面板把「生成操作」设成「嵌入的资源」或者复制到输出目录,否则编译能过、运行会报FileNotFoundException,这是新手最常翻的车之一。
2.2 运行环境与第一次编译运行
环境要求不高:Windows 平台,装 Visual Studio 2019 或 2022,勾选「.NET 桌面开发」工作负载。项目目标框架一般是 .NET Framework 4.6.1 以上,或者 .NET 6/8 的net8.0-windows,两种都能跑 WinForms。
想不开 IDE、纯命令行编译的话,走 MSBuild:
# 用 Developer Command Prompt 打开,进到 sln 所在目录 msbuild PlaneGame.sln /p:Configuration=Release /p:Platform="Any CPU" # 产物在 bin/Release 下,直接双击 exe 即可 start bin/Release/PlaneGame.exe这两条命令里,Configuration=Release决定是否开优化,Platform必须和 sln 里配置的一致,写成x86而工程只声明了AnyCPU会直接报平台不匹配。如果编译报未能找到类型或命名空间名 System.Windows.Forms,十有八九是目标框架选成了 .NET Standard 或者没装桌面开发负载,去工程属性里把目标框架改回net8.0-windows即可。
运行起来之后大概率能看到:黑色背景、星星滚动、一架自机在底部,按方向键能移动,空格能开火。如果画面一片黑但进程没崩,先别急着怀疑代码,检查MainForm的构造函数里定时器有没有Start(),以及Paint事件有没有真正绑上。
2.3 窗体初始化:构造函数里的三行关键设置
MainForm的构造函数是整份源码里最容易被忽略、但设置错就全盘皆输的地方。
public MainForm() { InitializeComponent(); // 1) 三件套一起开:自己绘制、不需要系统擦背景、双缓冲 SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true); UpdateStyles(); // 2) 键盘事件优先给窗体,而不是先给按钮等子控件 KeyPreview = true; // 3) 主循环定时器,先记下起始时间戳,避免首帧 dt 异常 _lastTick = Stopwatch.GetTimestamp(); GameTimer.Interval = 16; GameTimer.Start(); }三个设置各有分工。UserPaint让窗体放弃系统默认绘制,全部交给OnPaint;AllPaintingInWmPaint禁止系统用背景色擦除,这一条是消除闪烁的关键;OptimizedDoubleBuffer则是把绘制先落到内存位图再一次性贴屏。KeyPreview设成 true 之后,方向键不会因为焦点落到某个控件上而失效——我见过不少版本把分数显示做成 Label 之后,自机就再也动不了。
Interval = 16是奔着 60 帧去的粗略值。这里要强调:它只是定时器的唤醒间隔,不等于真实帧率,Windows 定时器精度本来就在 15ms 上下浮动,后面的章节会讲怎么用时间戳把这件事兜住。
3. 主循环拆解:GameTimer_Tick 里一帧到底做了什么
游戏能跑起来之后,真正决定手感的就是这个 Tick。很多 C# 飞机游戏源码写崩了,不是逻辑不会写,而是把「更新」和「绘制」的职责搅在一起,导致后面想加暂停、想加变速全都得推倒重来。
3.1 双缓冲与 SetStyle:闪烁到底是怎么消失的
不开双缓冲的飞机游戏,画面会呈现一种很有辨识度的撕裂感:自机拖影、敌机忽隐忽现、背景星星闪烁。原因是每一帧g.Clear()擦背景之后,绘制还没画完,屏幕就已经按行刷新了一次,用户看到的是「擦了一半 + 画了一半」的中间态。
项目里采用的是标准双层方案:帧逻辑只负责改数据,绘制全部收敛到OnPaint里。
private void RenderCanvas_Paint(object sender, PaintEventArgs e) { Graphics g = e.Graphics; // 像素素材不要开抗锯齿,否则边缘会糊成一片 g.SmoothingMode = SmoothingMode.None; g.InterpolationMode = InterpolationMode.NearestNeighbor; g.PixelOffsetMode = PixelOffsetMode.Half; g.Clear(Color.FromArgb(8, 10, 20)); // 分层绘制:背景 -> 子弹 -> 敌机 -> 自机 -> HUD _starField.Draw(g); DrawAll(g, _bullets); DrawAll(g, _enemies); _player.Draw(g); DrawHud(g); }这里的分层顺序不是随手排的。背景在最底下,HUD 压在最上面,中间按「谁该盖住谁」从远到近排。PixelOffsetMode.Half是给 GDI+ 半像素对齐用的,贴图位置是整数时能让像素图不出现一像素的偏移模糊,属于调一次就再也不用管的参数。
绘制里还有一个隐性成本:new Pen(...)和new SolidBrush(...)。GDI+ 的这些对象底层持有系统句柄,每帧创建几十个、GC 又不一定及时回收,跑上十几分钟句柄数能涨到几千。正确做法是在类里做static readonly缓存,只在颜色变化时才换。
3.2 定时器与步长:Interval=16 不等于稳定 60 帧
直接把X += speed写在 Tick 里,是新手最常见的写法,也是 90% 手感问题的根源。因为定时器根本不保证准时唤醒,窗口一拖动、系统一忙,Tick 就可能连续漏掉好几次,自机瞬移一段距离,观感上就是「一顿一顿」。
项目里用的是时间步长方案:
private void GameTimer_Tick(object sender, EventArgs e) { long now = Stopwatch.GetTimestamp(); float dt = (float)(now - _lastTick) / Stopwatch.Frequency; _lastTick = now; // 断点调试、窗口拖拽、系统休眠之后会来一个巨大的 dt,必须截断 if (dt > MaxStep) dt = MaxStep; // MaxStep 通常取 0.05f,即 50ms UpdateInput(); _player.Update(dt); _bullets.Update(dt); _enemies.Update(dt); _explosions.Update(dt); CheckCollisions(); _waveSpawner.Update(dt); _starField.Update(dt); RenderCanvas.Invalidate(); // 只标脏,真正的绘制交给 OnPaint }Stopwatch.GetTimestamp()返回的是高精度计数器,除以Stopwatch.Frequency转成秒,比DateTime.Now稳定得多,后者精度只有 10~15ms,还会受系统时间校准影响。
MaxStep这个截断看着不起眼,实际是救命的一行。没有它,你在Update里打断点停三秒再继续,回来时dt是三秒,所有敌机会瞬间飞出屏幕,自机被撞一堆,看着像游戏崩了,其实是帧步长炸了。这个坑我当年在别人代码里踩过一次,找了一下午。
还有一点,RenderCanvas.Invalidate()只是给控件打个「该重画了」的标记,真正的绘制由系统在消息循环空闲时统一调度,这样 Tick 和 Paint 就解耦了。如果直接在 Tick 里拿CreateGraphics()画,画面会闪得没法看,而且窗口最小化再恢复之后会被擦掉。
3.3 输入处理:键盘状态表与斜向速度归一化
输入这块,项目用的是「按键状态集合」而不是在KeyDown里直接改坐标。原因很实在:KeyDown有系统重复延迟,按住不放会先停顿再连发,位移完全不连贯。
private readonly HashSet<Keys> _keys = new HashSet<Keys>(); private float _fireCooldown; protected override void OnKeyDown(KeyEventArgs e) { _keys.Add(e.KeyCode); if (e.KeyCode == Keys.Space) _fireHeld = true; e.Handled = true; // 方向键不再去切换控件焦点 base.OnKeyDown(e); } protected override void OnKeyUp(KeyEventArgs e) { _keys.Remove(e.KeyCode); if (e.KeyCode == Keys.Space) _fireHeld = false; base.OnKeyUp(e); } private void UpdateInput() { float dx = 0f, dy = 0f; if (_keys.Contains(Keys.Left) || _keys.Contains(Keys.A)) dx -= 1f; if (_keys.Contains(Keys.Right) || _keys.Contains(Keys.D)) dx += 1f; if (_keys.Contains(Keys.Up) || _keys.Contains(Keys.W)) dy -= 1f; if (_keys.Contains(Keys.Down) || _keys.Contains(Keys.S)) dy += 1f; // 斜向必须归一化,否则斜着走会比直着走快 √2 倍 if (dx != 0f && dy != 0f) { dx *= 0.70710678f; dy *= 0.70710678f; } _player.VX = dx * PlayerSpeed; // PlayerSpeed 单位:像素/秒 _player.VY = dy * PlayerSpeed; }归一化那一行是很多人忽略的细节。不归一化的话,横向速度 360、纵向 360,斜向合成之后是 509,玩家很快就会发现贴着墙斜走能躲开大部分弹幕,游戏难度直接塌掉。乘以1/√2 ≈ 0.7071之后,任何方向的合成速度都等于 360,手感才是一致的。
开火用的是冷却计时而不是按键事件触发:
private void UpdateFire(float dt) { _fireCooldown -= dt; if (_fireHeld && _fireCooldown <= 0f) { SpawnPlayerBullet(); _fireCooldown = FireInterval; // 例如 0.12f 秒一发 } if (_fireCooldown < 0f) _fireCooldown = 0f; }冷却值而不是「每帧判定一次」,是因为帧率本身在浮动。用冷却计时,不管这一秒跑了 58 帧还是 62 帧,射速都是稳定的,这就是前面强调把 dt 传下去的意义。
4. 对象模型与碰撞:Player、Enemy、Bullet 怎么组织才不乱
一份飞机游戏源码能不能二次开发,八成看对象层写得干不干净。写成「每个人物一个 List」也能跑,但加到第五种敌机就开始失控;写成基类加对象池,后面加 Boss、加道具都是照抄。
4.1 GameObject 基类与通用对象池
基类只放所有实体都有的东西:位置、速度、尺寸、存活标记,以及三个虚方法。
public abstract class GameObject { public float X, Y; // 世界坐标,用 float 避免低速时的取整抖动 public float VX, VY; // 像素/秒 public int Width, Height; // 贴图尺寸,也作为碰撞盒基准 public bool Alive = true; // 死亡标记,交给池子回收 public Rectangle Bounds => new Rectangle((int)X, (int)Y, Width, Height); public virtual void Update(float dt) { X += VX * dt; Y += VY * dt; } public virtual void Draw(Graphics g) { } public virtual void Reset() { Alive = true; VX = VY = 0f; } }坐标用float是刻意为之。如果直接上int,低速子弹按每帧 0.8 像素算,会被截断成 0,子弹永远飞不动;就算改成每帧 1 像素,帧率一浮动速度也跟着变。
对象池解决的是另一个问题:飞机游戏一局下来要创建销毁上万个子弹,每次都new Bullet(),GC 会在某个时刻集中回收,表现为玩着玩着突然卡一下,也就是常说的 GC 尖峰。
public class ObjectPool<T> where T : GameObject, new() { private readonly Stack<T> _free = new Stack<T>(); private readonly List<T> _active = new List<T>(); public T Spawn() { T obj = _free.Count > 0 ? _free.Pop() : new T(); obj.Reset(); _active.Add(obj); return obj; } public void Update(float dt) { // 倒序遍历,边遍历边移除才不会跳过元素 for (int i = _active.Count - 1; i >= 0; i--) { T o = _active[i]; o.Update(dt); if (!o.Alive || o.Y < -120f || o.Y > 1200f || o.X < -120f || o.X > 1600f) { _active.RemoveAt(i); _free.Push(o); } } } public IReadOnlyList<T> Active => _active; }倒序遍历是这个池子里最值得说的一行。正序遍历时RemoveAt(i)会让后面的元素整体前移,紧接着i++就跳过了一个元素,表现为「偶尔有个子弹穿过敌机不掉血」,很难复现。倒序删就不会有这个问题。
Reset()在Spawn()里被调用,是所有池化对象的通用约定:把上次的遗骸清干净,包括血量、速度、动画帧号、透明度。忘写这一条,重用出来的敌机会带着上一架的血量,看着像「这飞机怎么打不死」。
4.2 子弹、敌机与矩形碰撞
飞机游戏的碰撞用轴对齐矩形就够,没必要上分离轴或者像素级检测,后者一帧要读上万次像素,帧率直接掉一半。
private void CheckCollisions() { var bullets = _bullets.Active; var enemies = _enemies.Active; for (int i = 0; i < bullets.Count; i++) { Bullet b = bullets[i]; if (!b.Alive) continue; for (int j = 0; j < enemies.Count; j++) { Enemy en = enemies[j]; if (!en.Alive) continue; if (!b.Bounds.IntersectsWith(en.Bounds)) continue; b.Alive = false; // 一颗子弹只命中一个目标 en.Hp -= b.Damage; if (en.Hp <= 0) { en.Alive = false; _score += en.ScoreValue; _explosions.Spawn(en.CenterX, en.CenterY); } break; } } }这里有两个参数说明。b.Damage是子弹伤害,通常普通弹 1、强化弹 3,写在配置里而不是写死在敌机里,改数值的时候只需要动一处。break保证一颗子弹只打一个目标,去掉它会出现一发子弹穿透整列敌机的情况,输出严重超标。
还有一个实战技巧:Bounds直接用贴图尺寸,会让碰撞盒比视觉上大一圈,玩家会抱怨「明明没碰到就死了」。合格的写法是收缩边缘:
public virtual Rectangle HitBox { get { const int pad = 6; // 四周内缩像素,按素材透明边调整 return new Rectangle( (int)X + pad, (int)Y + pad, Math.Max(1, Width - pad * 2), Math.Max(1, Height - pad * 2)); } }自机碰撞盒建议比敌机缩得更多,通常只保留机身中心 60% 左右,玩家对「我的范围」的感知远小于实际贴图,留点余量手感才不憋屈。
敌机的行为差异则挂在子类的Update上。俯冲机每帧加一点 VX 做抛物线,精英机原地做正弦横移,这些都是在Enemy.Update里按类型分支处理,不要在刷怪逻辑里写死坐标。
4.3 刷怪表与关卡节奏
把刷怪写成if (score > 100) spawn()的源码遍地都是,问题是改一关就得动代码。可维护的做法是用一张时间驱动的波次表,逻辑只负责读表。
// (触发时间秒, 敌机类型, 数量, 起始X) private static readonly (float T, int Type, int Count, float X)[] WaveTable = { ( 1.5f, 0, 1, 200f), ( 3.0f, 0, 2, 120f), ( 5.5f, 1, 1, 320f), // 1 = 斜向俯冲 ( 8.0f, 2, 1, 480f), // 2 = 带血精英 (11.0f, 1, 3, 60f), (15.0f, 3, 1, 400f), // 3 = Boss }; public void Update(float dt) { _elapsed += dt; while (_next < WaveTable.Length && _elapsed >= WaveTable[_next].T) { var w = WaveTable[_next]; for (int i = 0; i < w.Count; i++) SpawnEnemy(w.Type, w.X + i * 70f, -60f); _next++; } }表驱动的好处是调试极快:想试某一波的手感,把它的T改成 0.5,重开就到位,不用在十几处if里翻。_next索引保证每一波只触发一次,while而不是if是为了在处理卡顿导致的大 dt 时,把积压的波次一次性补上。
更难一点的需求是循环波次和难度递增。常见做法是给表加一个LoopTo字段,走到末尾后跳回某一波,同时给全局的EnemySpeedScale每次循环加 5%,这样一张二十行的表就能撑起一局几分钟的弹幕节奏。
5. 常见问题排查:编译失败、画面闪烁、内存上涨与手感发飘
这一节全是实打实的坑,每一条都是「现象 → 原因 → 解决」的结构。拿着源码跑不起来的时候,按顺序对照看,多半能直接定位。
5.1 编译与启动期的问题
现象一:编译报「未能找到类型或命名空间名 System.Windows.Forms」。原因是工程目标框架被设成了netstandard2.0或者net8.0(不带-windows后缀)。WinForms 只在 Windows 目标框架下可用。解决是把 csproj 里的<TargetFramework>改成net8.0-windows,并在<PropertyGroup>里补上<UseWindowsForms>true</UseWindowsForms>,重新还原即可。
现象二:能编译通过,双击 exe 直接闪退,什么提示都没有。原因是Program.cs里没包异常处理,资源文件缺失或者构造函数抛异常时进程直接终止。解决是临时把入口改成先捕获再弹窗:
[STAThread] static void Main() { try { Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); Application.Run(new MainForm()); } catch (Exception ex) { MessageBox.Show(ex.ToString(), "启动失败"); } }这样至少能看到堆栈。九成的情况是图片路径没处理,检查Resources目录下文件的「复制到输出目录」属性有没有设成「始终复制」。
现象三:窗口打开了,但一片黑,按键没反应。先看定时器是不是Start()了,再看Paint事件绑定的是不是那个真正显示在界面上的控件。有些版本在 Designer 里新加了个 Panel 专门用来承载绘制,但事件还绑在 Form 上,结果 Panel 盖住了一切,看起来就像没画。
5.2 画面闪烁与卡顿
现象:自机和敌机有明显拖影,背景星星一闪一闪。原因基本逃不出两个:没开OptimizedDoubleBuffer,或者在 Tick 里直接CreateGraphics()绘制。前者去构造函数补SetStyle,后者把绘制逻辑全部搬到OnPaint,Tick 只留Invalidate()。
现象:帧率看着还行,但每隔几秒会明显卡一下。典型的 GC 尖峰。检查两处:子弹是不是每帧new,Pen、Brush是不是每帧创建。前者用对象池,后者做静态缓存。我一般会开任务管理器盯着内存曲线跑五分钟,正常应该是平的,如果呈锯齿状反复涨落,基本就是这两种分配没管住。
现象:窗口一拖动,回来后自机出现在很奇怪的位置。原因是拖动期间消息循环被占住,Tick 积压,回来时第一个 dt 特别大。这就是MaxStep截断要处理的情况,把它设成 0.05 秒,最多只会补一帧 50ms 的位移,视觉上察觉不到。
5.3 内存、GDI 句柄与长时间运行
现象:刚启动时进程占 60MB,挂机半小时涨到 800MB。多半是实体对象没回收。检查ObjectPool.Update里的越界判定条件,如果只判了Y < 0而敌机是从左右两侧飞出去的,那些对象就永远留在_active列表里,越积越多。稳妥的写法是四个方向都判,再加一个LifeTime兜底。
现象:任务管理器里 GDI 对象数一路涨。每条new SolidBrush/new Font都会占一个 GDI 句柄。系统每进程上限默认 10000,撞到之后界面会变成白块甚至崩溃。解决办法是用using包住临时对象,能复用的做成静态字段,并且给Font这类也加上Dispose。VS 的「调试 → 窗口 → GDI 句柄」可以直接看实时数量,我在优化渲染时几乎每次都会开着它。
现象:切到后台再切回来,游戏直接跳了一大段。这是设计上的取舍。如果希望切走时暂停,在OnDeactivate里停定时器、OnActivated里重置_lastTick,别只是停止计时而不重置时间戳,否则回来第一帧的 dt 依然是超大的。
5.4 手感与判定偏差
现象:玩家反馈「明明躲开了还是被打到」。碰撞盒比贴图大。原因是很多素材四周有透明边,直接拿Width × Height当判定范围,实际判定区域远超视觉范围。给HitBox加内缩 padding,自机缩得比敌机更多,通常 6~10 像素就能明显改善。
现象:斜着走比直着走快。忘了做斜向归一化。这个在前面已经写过,系数 0.7071,改一行就解决。武器发射同理,如果斜射子弹速度没归一化,五个方向的射程会不一样,弹幕看起来会歪。
现象:连发按住不放时,射速忽快忽慢。用了按键事件触发而不是冷却计时。KeyDown有系统重复延迟,按住后要先停 500ms 左右才开始连发,而且频率受系统设置影响。改成在UpdateFire(dt)里累减冷却,无论谁按、按多久,射速都一样。
6. 二次开发改造路线:把能玩的 Demo 变成能交付的版本
跑通之后真正开始改,我建议按「先配置化、再抽象、最后加内容」的顺序推,反过来做容易返工。
第一步是把数值从代码里搬出去。原项目把速度、血量、射速散在各个类里,改一个难度要翻五个文件。抽一个GameConfig,用 JSON 序列化,改数值不用重新编译:
public class GameConfig { public int StartLife { get; set; } = 3; public float PlayerSpeed { get; set; } = 360f; // 像素/秒 public float FireInterval { get; set; } = 0.12f; // 秒/发 public float EnemySpeedMul { get; set; } = 1.0f; // 全局敌机速度系数 public int MaxBullets { get; set; } = 240; // 池子上限,防刷屏 public static GameConfig Load(string path) { if (!File.Exists(path)) return new GameConfig(); // 缺失就用默认值 return JsonSerializer.Deserialize<GameConfig>(File.ReadAllText(path)) ?? new GameConfig(); } }MaxBullets这个字段是我后加的,来自一次教训:玩家同时按两个键加连发,子弹把池子撑到两千多个,帧率直接掉到 20。加个上限,到顶就不生成,比事后调优省事得多。
第二步是把「刷怪表」和「游戏状态」解耦。现在的WaveTable是静态数组,把它挪成一个Level对象,每关一个配置,进场时加载,退场时清空。这样加第二关只是新增一个配置文件,逻辑代码一行不用动。
第三步才是加内容。下面这张表是我通常的排期顺序,从低成本高收益开始:
| 改造方向 | 改动位置 | 预估成本 | 收益 |
|---|---|---|---|
| 敌人子类型(直线/俯冲/绕行) | Enemy.Update按 type 分支 | 半天 | 高,立刻有变化 |
| 武器升级与道具掉落 | 新增PowerUp实体 + 池 | 一天 | 高,游戏性核心 |
| 音效与背景音乐 | System.Media.SoundPlayer | 两小时 | 中,氛围提升明显 |
| 分数榜与本地存档 | 序列化到 JSON | 半天 | 中,有留存感 |
| 暂停与重开 | 状态机(Running/Paused/Over) | 半天 | 高,缺了不像成品 |
验证改动有没有翻车,我用三个硬指标:帧率稳定在 55 以上、内存曲线跑五分钟是平的、GDI 句柄数不随游玩时长增长。这三条只要有一条破,先别急着加新功能,回去查分配和回收。
最后补一个易被忽略的细节:把所有new Bitmap(Image.FromFile(...))改成资源内嵌加静态加载。Image.FromFile会一直持有文件句柄,游戏运行时那个 png 就被锁住了,你想换素材得先关游戏,改一次骂一次。从那以后我每次接手 C# 游戏项目,第一件事就是把所有资源加载改成内嵌读取,并强制自己跑一遍五分钟的内存和句柄观察再提交。希望帮到你。
本文还有配套的精品资源,点击获取