简介:这份《C#极品飞机小游戏源码》面向希望借小型项目入门游戏开发的C#初学者和进阶练手者,用一套可运行的飞行射击游戏,填补语法练习与完整工程组织之间的实践空白,适合课程设计参考、个人练手和源码阅读等场景。压缩包共61个文件、约1.25MB,以17个cs源码文件承载核心逻辑,png、jpg、gif用于界面与素材呈现,wav、mp3提供音效,sln、csproj便于工程管理,dll、exe及缓存配置则支撑运行与调试。源码将飞机、子弹、敌机等拆分为独立对象,并围绕对象创建、交互、碰撞检测和游戏循环展开,便于对照理解C#基础语法、面向对象封装以及Windows Forms事件处理的实际用法。已有264人学习,说明其在入门练手场景中具备一定参考度。读者可从中梳理对象属性与行为设计,理解游戏循环、定时器帧率控制、碰撞检测、输入事件响应、音频图像资源加载与内存性能优化等关键环节,也能观察界面绘制、GDI+或DirectX渲染尝试、事件模型以及多线程分离逻辑与渲染的实践思路,逐步建立从需求拆解、模块组织到调试排错的完整游戏开发认知。
1. 一份能直接跑的 C# 游戏源码,适合当“能跑的第二教材”
想找一份能直接跑起来的 C# 游戏源码练手,最怕两件事:工程大到读不完,或者小到只剩个 Hello World。飞机射击小游戏刚好卡在中间——一个玩家、几架敌机、一串子弹、碰撞判定、分数和音效,闭环完整,主干代码通常几百到两千行,一个周末能从入口读到出膛。它基于 WinForms 加 GDI+,不依赖商业引擎,双击就能玩,改一行参数就能在画面上看出差别。
适合三类人:刚学完 C# 语法、想找点东西把委托、事件、集合、计时器串起来的;想搞明白游戏主循环、碰撞检测、对象管理在真实代码里长什么样,又不想一上来就啃大引擎的;以及手上有个小项目需要一份能二次开发的底座,比如课程设计、演示 Demo、给新人练手的模板。下面按“跑起来 → 看懂循环 → 改得动 → 不踩坑”的顺序拆,每一步都落到具体文件和参数上。
2. 把工程跑起来:从解压到第一架飞机起飞
拿到源码包先别急着双击 sln。压缩包里通常混着三类东西:解决方案与工程文件、源码目录、以及别人编译时留下的 bin/obj 产物。第三类最容易坏事——旧 exe 能双击运行,于是你以为自己改的代码没生效,其实一直在跑几周前编译出来的二进制。
2.1 工程结构与目标框架:先看三样东西
解压后按下面的顺序扫一遍,五分钟能判断这个工程能不能直接开。
| 路径 | 作用 | 要不要动 |
|---|---|---|
*.sln | 解决方案入口,双击进 IDE 用 | 不动 |
*.csproj | 目标框架、平台位数、资源清单 | 必须确认 |
Form1.cs/MainForm.cs | 主窗口,游戏循环和绘制一般在这 | 先读不动 |
Properties/Resources.resx | 图片、音效的托管资源 | 路径对就不用动 |
bin/obj/ | 编译产物,可能来自别人的机器 | 建议整个删掉重编 |
csproj里有四个节点值得盯一眼,缺一个就可能编译不过或者运行时报错:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <!-- 关键点1:目标框架。老工程可能是 net472,新模板是 net8.0-windows --> <TargetFramework>net8.0-windows</TargetFramework> <!-- 关键点2:没有这行,WinForms 的设计器和 Application 类都找不到 --> <UseWindowsForms>true</UseWindowsForms> <!-- 关键点3:引用了 32 位原生 dll 时,AnyCPU 会在 64 位进程里崩 --> <PlatformTarget>x64</PlatformTarget> <!-- 关键点4:图标和版本信息,打包时有用,调试阶段可以先不管 --> <ApplicationIcon>app.ico</ApplicationIcon> </PropertyGroup> </Project>TargetFramework决定了你能用哪些语法和 API,net472的工程直接改成net8.0-windows大概率会撞上一堆 API 差异,反过来也一样。稳妥做法是照着本机已安装的运行时改,装的是 .NET Framework 4.8 就写net48,装的是 .NET 8 桌面运行时就用net8.0-windows,别硬凑。
PlatformTarget这一项是血泪经验:AnyCPU 在 64 位系统上默认以 64 位进程跑,工程里但凡引用了一个 32 位的音频库或者老式 DirectX 封装,运行起来就是BadImageFormatException,报错信息还完全不提位数。看到这个异常,先查平台目标,别急着怀疑代码。
资源路径是第二个高频区。Properties/Resources.resx里的图片会被编译进程序集,代码里通过Properties.Resources.xxx访问,跟 exe 放哪儿无关;但如果工程用的是Image.FromFile("images/plane.png")这种相对路径,那就完全依赖工作目录,从 IDE 启动和双击 exe 启动的当前目录不一样,结果就是一个能跑一个黑屏。
2.2 第一次编译:命令行跑一遍比 IDE 更清楚
先用命令行编一次,报错信息比 IDE 的错误列表干净得多:
# 在 sln 所在目录执行,-c Debug 保留调试符号,-p:Platform 指定与 csproj 一致的平台 dotnet build ./YourGame.sln -c Debug -p:Platform=x64 # 老式 .NET Framework 工程如果没装 dotnet CLI 的对应 SDK,改用 MSBuild: # "C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe" YourGame.sln /p:Configuration=Debug /p:Platform=x64-c Debug会保留.pdb,之后异常堆栈能定位到具体行号;-p:Platform=x64要和 csproj 里的PlatformTarget对上,对不上会出现“解决方案配置里没有 x64”这类提示。编译成功后产物在bin/x64/Debug/下,把这个目录整个记下来,后面所有“为什么我改了没反应”的问题,第一件事就是确认你运行的是不是这里的 exe。
如果编译报的是“当前目标框架不支持 Windows Forms”,说明UseWindowsForms没开或者框架写成了net8.0而不是net8.0-windows;如果报的是找不到System.Windows.Forms命名空间,八成是工程类型被误建成了类库。这两类错误跟源码本身没关系,改工程文件就能过。
2.3 改一行代码,确认这个工程真的是“活的”
跑起来之后别急着读代码,先做一次最小验证:改一个能被眼睛立刻捕捉到的参数。改窗口标题只能验证编译链,改速度才能验证主循环:
// MainForm.cs 构造函数末尾,两行都能立刻看到效果 public MainForm() { InitializeComponent(); this.Text = "飞行演示 - 本地调试构建"; // 验证你运行的确实是这次编译的产物 _player.SpeedX = 280f; // 单位:像素/秒,原值一般是 180~220 }第一行的作用不是装饰,是给你一个“我到底在跑哪个 exe”的锚点,标题没变就说明运行的还是旧产物。第二行改的是玩家水平速度,改完重新编译、按方向键,手感应该有明显区别;如果完全没区别,说明这个字段没有被主循环读取,或者移动逻辑写死在别处(常见的是在KeyDown里直接Left += 5,压根没走速度字段)。
这一步的价值在于建立因果链:改代码 → 编译 → 运行 → 画面变化。链路通了,后面调参才有意义;链路不通就先修环境,别在代码里找原因。做完这一步,把bin/和obj/删掉重编一次,确认干净环境下也能过,这个工程才算真正“在你手上”。
3. 游戏主循环与 GDI+ 渲染:把帧率和画面稳定住
飞机小游戏这类 2D 游戏,画面抖、子弹飘、敌机瞬移,九成问题不在逻辑,在主循环和绘制这两层。WinForms 天生不是为游戏设计的,它给你的是一个消息泵加一个画布,剩下的节奏控制得自己搭。看懂这两层,后面改玩法才不会一改就崩。
3.1 Timer 与消息泵:为什么不是 while(true)
新手最容易写的是while(true) { Update(); Draw(); },在控制台里跑得好好的,搬到 WinForms 里窗口直接白屏卡死。原因是 WinForms 的窗口消息(重绘、点击、关闭)全靠主线程的消息泵分发,你一个死循环占住主线程,消息泵就没机会跑,窗口连“未响应”都来不及画。
正确做法是把“帧”挂到消息泵上,用System.Windows.Forms.Timer或者Application.Idle事件驱动。前者简单,后者更贴近真实帧率。源码里常见的是 Timer:
private readonly Stopwatch _clock = Stopwatch.StartNew(); private double _lastMs; private void GameTimer_Tick(object sender, EventArgs e) { double nowMs = _clock.Elapsed.TotalMilliseconds; double dt = (nowMs - _lastMs) / 1000.0; // 换算成秒 _lastMs = nowMs; // 卡顿保护:窗口拖动、断点调试会让 dt 突然变得很大,不夹住就会穿模 if (dt > 0.1) dt = 0.1; if (dt <= 0) return; Update(dt); // 只改数据,不碰界面 Invalidate(); // 标记区域待重绘,交给系统合并后再调 OnPaint }Timer.Interval的单位是毫秒,设 16 大约对应 60 帧,但实际精度受系统时钟限制,通常在 15.6ms 上下浮动,所以不能假设“每次 Tick 就是 16ms”。上面这段用Stopwatch实测两次 Tick 的间隔,把真实耗时传进Update,这才是能跨机器保持一致的做法。
Invalidate()和Refresh()的区别值得记一下:Invalidate只是把区域标记为脏,等消息队列空下来再合并重绘;Refresh是立刻同步重绘。游戏循环里用前者,一帧内多次调用也只会合并成一次绘制;后者在循环里调会直接把帧率打对折。
还有一点:Update里不要碰任何控件属性。改Label.Text、PictureBox.Image都会触发消息和重绘,跟你的绘制抢时间,表现出来就是分数一变化画面就顿一下。分数、血量这类 UI 数据先存字段,在OnPaint里统一画。
3.2 双缓冲与资源复用:把闪烁按下去
没开双缓冲的 WinForms 画布,每帧先刷背景再画图,中间那一瞬会被看到,表现就是整屏闪。开关在窗口的样式位里,必须在句柄创建前后做对:
public MainForm() { InitializeComponent(); // 四个样式位缺一不可: // AllPaintingInWmPaint 屏蔽 WM_ERASEBKGND,避免先刷背景 // UserPaint 自己负责全部绘制 // OptimizedDoubleBuffer 开双缓冲 // ResizeRedraw 窗口尺寸变化时整屏重绘,避免残留 SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true); UpdateStyles(); // 构造函数里设置后强制生效 }四个标志位的作用不一样,少一个效果就打折。只加OptimizedDoubleBuffer而没加AllPaintingInWmPaint,背景擦除照样会闪;加了UserPaint却忘了在OnPaint里铺底色,上一帧的残影会留在画面上。
双缓冲解决的是“整屏闪”,GDI 句柄泄漏解决的是“跑几分钟就崩”。GDI+ 的Pen、Brush、Image、Font都实现了IDisposable,每帧 new 一个不释放,进程的 GDI 对象数会一路涨到一万上限,然后抛A generic error occurred in GDI+——这个异常信息毫无指向性,很多人会去怀疑图片格式,其实只是没释放。
// 在窗体字段里创建一次,整个生命周期复用 private readonly SolidBrush _hudBrush = new(Color.White); private readonly Pen _hitBoxPen = new(Color.Red, 1f); private Bitmap _playerSprite; // 在 Load 事件里加载一次 protected override void OnPaint(PaintEventArgs e) { var g = e.Graphics; // 敌机数超过三四十架时,关掉抗锯齿换帧率;少于十架时开着更好看 g.SmoothingMode = SmoothingMode.AntiAlias; g.DrawImage(_playerSprite, _player.Rect); foreach (var b in _bullets) g.DrawImage(_bulletSprite, b.Rect); g.DrawString($"SCORE {_score}", _hudFont, _hudBrush, 12, 12); } protected override void OnFormClosed(FormClosedEventArgs e) { _hudBrush.Dispose(); _hitBoxPen.Dispose(); _playerSprite?.Dispose(); base.OnFormClosed(e); }DrawImage带缩放重载比不带缩放的慢不少,因为每帧都在做插值。如果精灵图原始尺寸和显示尺寸不一致,常见做法是在加载时用new Bitmap(src, new Size(w, h))预缩放一次,绘制时直接按原尺寸贴,帧率能明显回升。
3.3 用 deltaTime 而不是“每帧走几像素”
把所有移动写成x += 5,在 60 帧的机器上子弹每秒飞 300 像素,在 30 帧的机器上只飞 150 像素,同一份代码两种手感,这就是“在我这儿好好的”的经典来源。换成按时间积分:
// SpeedX / SpeedY 单位统一为 像素/秒,和帧率彻底解耦 public void Update(double dt) { X += SpeedX * (float)dt; Y += SpeedY * (float)dt; // 出界判定放在积分之后,别放在之前,否则高速子弹会漏判一帧 if (Y < -Rect.Height || Y > WorldHeight + Rect.Height) Alive = false; }单位统一之后,调参才有可比性:SpeedY = 600表示子弹每秒飞 600 像素,屏幕高 800 像素的话大约 1.3 秒穿屏。这个数字可以直接拿去和“玩家反应时间 0.25 秒”对照,判断弹道是不是太快。
浮点坐标配RectangleF,别用整数Rectangle。整数坐标每帧截断一次,低速移动(比如每秒 30 像素)会出现“走两帧停一帧”的顿挫,肉眼可见。真到了绘制那一步再取整,而且建议统一用(int)Math.Round(x)而不是强转,方向键左右移动的对称性会好很多。
4. 碰撞检测与对象管理:从矩形相交到子弹与敌机的对象池
逻辑层最容易写出性能问题的地方就两处:每帧对所有子弹和敌机做双重循环碰撞,以及每帧 new 一堆对象让 GC 反复回收。前者在子弹上百发时表现为帧率掉到个位数,后者表现为每十几秒卡一下。这两个坑都有成熟解法,改起来也不复杂。
4.1 先做对再做快:AABB 够用,圆形补位
飞机小游戏里 90% 的碰撞用轴对齐包围盒(AABB)就够了,RectangleF.IntersectsWith一行搞定,开销极小。真正需要讲究的是“判定框比图小一圈”,因为玩家对“擦边就死”的容忍度极低:
// 判定框收缩:在精灵矩形基础上向内缩,scale 取 0.75~0.9 static RectangleF Shrink(RectangleF r, float scale) { float w = r.Width * scale, h = r.Height * scale; return new RectangleF(r.X + (r.Width - w) / 2f, r.Y + (r.Height - h) / 2f, w, h); } // AABB 相交:绝大多数子弹-敌机、敌机-玩家判定 static bool Hit(RectangleF a, RectangleF b) => a.IntersectsWith(b); // 圆形近似:爆炸范围、拾取道具这类用圆形更自然 static bool HitCircle(PointF c1, float r1, PointF c2, float r2) { float dx = c1.X - c2.X, dy = c1.Y - c2.Y, rr = r1 + r2; return dx * dx + dy * dy <= rr * rr; // 不开根号,省一次 sqrt }Shrink的scale是手感参数,0.75 意味着判定框只有视觉大小的四分之三,玩家会觉得“能擦过去”;0.95 就接近像素级严格,新手两分钟就死。这个值不要硬编码在碰撞函数里,提到配置里,后面调难度直接改它。
规模上去之后要加粗筛。子弹 200 发、敌机 50 架,双重循环是一万次判定,每帧一万次IntersectsWith其实还好,真正贵的是里面的浮点运算和后续的命中处理。常见做法是按 Y 轴排序后只比较相邻区段,或者把屏幕切成 4×4 网格,子弹只查所在格子的敌机。别一上来就上四叉树,这个体量用不上。
4.2 对象池:子弹和敌机不要每帧 new
一秒生成 20 发子弹,每发存活 1.5 秒,屏幕上长期有 30 个对象,每分钟 new 1200 次。单看不多,但这些对象都带RectangleF、可能还带贴图引用,全进 Gen0,GC 一触发就是几毫秒的停顿,表现出来就是规律性的微卡。对象池把这部分分配全消掉:
public sealed class BulletPool { private readonly Stack<Bullet> _free = new(); // 空闲对象,后进先出,缓存友好 private readonly List<Bullet> _active = new(); // 活跃对象,每帧遍历 public Bullet Spawn(float x, float y) { var b = _free.Count > 0 ? _free.Pop() : new Bullet(); b.Reset(x, y); // 关键:复用对象必须重置全部状态,别漏字段 b.Alive = true; _active.Add(b); return b; } public void Sweep() // 每帧 Update 末尾调一次 { for (int i = _active.Count - 1; i >= 0; i--) // 倒序,删除不跳项 { if (_active[i].Alive) continue; var dead = _active[i]; _active.RemoveAt(i); _free.Push(dead); // 回收,注意不要再 Dispose 贴图 } } }两个细节容易翻车。Reset必须把所有可变字段归零,包括速度、存活时间、动画帧索引、是否已命中;漏一个就会出现“新子弹一出生就带着上一发的伤害值”。贴图不要跟着对象回收一起Dispose,那是共享资源,池子只回收对象本身。
RemoveAt是 O(n),子弹几十上百时每帧几十次移动,开销可以忽略;如果哪天做到上千,再换成“和末尾元素交换后删除”的写法,List支持按下标赋值。
4.3 难度曲线用配置表驱动,别堆 if-else
源码里常见的难度实现是if (score > 1000) speed = 200; else if (score > 500) speed = 150;,加到第四关就开始互相打架。改成时间轴配置,改难度就是改一行数据:
{ "waves": [ { "startSec": 0, "spawnInterval": 1.20, "enemySpeed": 120, "pattern": "line", "hp": 1 }, { "startSec": 30, "spawnInterval": 0.80, "enemySpeed": 160, "pattern": "sine", "hp": 1 }, { "startSec": 75, "spawnInterval": 0.55, "enemySpeed": 200, "pattern": "chase", "hp": 2 } ] }// 每帧只需要问一次:现在该用哪一段 private WaveConfig CurrentWave(double elapsedSec) { WaveConfig picked = _waves[0]; foreach (var w in _waves) if (elapsedSec >= w.StartSec) picked = w; // 配置按 startSec 升序 return picked; }spawnInterval决定同屏敌机数量的上限,enemySpeed决定首次交锋时间,pattern是走位模式的名字,字符串比枚举更好改也好存档。配置放外部 JSON 而不是编译进代码,改难度不用重新编译,这是调参阶段最省时间的决定——一晚上调二十轮参数,每轮等一次编译,人是会烦的。
读配置要有兜底:文件缺失或者字段类型不对,直接回退到内置的默认难度,别让整个游戏起不来。JsonSerializer反序列化失败会抛异常,包一层 try/catch 并打日志,比白屏强。
5. 常见问题排查:黑屏、闪烁、没声音与打包翻车
下面这些是照着源码跑起来之后最常遇到的情况,基本都集中在“环境差异”和“资源生命周期”两处。每条按现象、原因、解决三段走,对着排一遍能省掉大半时间。
5.1 运行期三个高频翻车点
现象一:程序启动,窗口出来了,但画面全黑或者一片纯色。原因通常是绘制入口没被调用,或者资源加载抛了异常被吞掉。OnPaint里第一行Image.FromFile找不到文件会抛FileNotFoundException,如果外面套了空的 try/catch,画面就停在背景色上,看起来像“黑屏”。 解决:先把OnPaint里的 try/catch 改成打印e.ToString(),让异常冒出来;同时确认 sprite 是用Properties.Resources还是相对路径加载,前者跟工作目录无关,后者必须保证 exe 同级目录下有图片文件夹。命令行启动一次、双击 exe 启动一次,对比结果就能区分。
现象二:画面持续闪烁,尤其是敌机多的时候。原因有两类:没开双缓冲,或者开了双缓冲但OnPaint里每帧 newPen/Brush没释放导致重绘变慢。 解决:先按 3.2 那四个样式位补齐并调UpdateStyles();再去OnPaint里搜new SolidBrush、new Pen、Image.FromFile,全部提到字段里创建。改成复用之后如果还闪,检查是不是在Update里改了Label.Text或PictureBox.Image,这类控件属性会触发额外的重绘,跟你的绘制抢消息队列。
现象三:音效第一次响,第二次开始就静音。原因是播放器对象被复用或者被提前释放。SoundPlayer在播放中再次调用Play()会打断上一段,如果同时还有Dispose()调用,第二次就彻底没声。用System.Media.SoundPlayer的话,常见做法是每个音效持有一个实例、Load()一次,播放前判断是否需要重头开始。 解决:把音效对象放进一个字典按名字缓存,不要每次播放都 new;如果用的是WindowsMediaPlayer这类 COM 组件,注意它需要 STA 线程并且要在窗体关闭时显式close(),否则进程会残留在后台。
现象四:跑两三分钟内存一路涨,最后卡死或者抛 GDI+ 通用错误。原因是子弹、敌机、粒子被移出列表但没有真正结束引用,或者事件订阅没解绑。Image.FromFile还有个坑:它会在加载后一直持有文件句柄,反复加载同一张图会累积句柄。 解决:先看任务管理器的 GDI 对象数,涨到接近一万就是句柄泄漏,问题在绘制资源;内存涨但 GDI 平稳就是对象泄漏,问题在列表和事件。敌机被击毁时除了从活跃列表移除,还要把它的动画、音效回调全部清空;窗体关闭时统一解绑Timer.Tick,别指望 GC 帮你处理事件链。
5.2 打包之后换台机器就跑不起来
现象五:在开发机上好好的,拷到另一台机器双击 exe 报“缺少 xxx.dll”或直接闪退。原因是目标机器没装对应版本的桌面运行时,或者工程依赖了只在开发机存在的第三方 dll。 解决:先确认目标机器装的是 .NET Framework 运行时还是 .NET 桌面运行时,两者不通用;工程如果引用第三方 dll,确认它们的“复制到输出目录”属性是 true,或者在 csproj 里显式写<CopyLocalLockFileAssemblies>。发布时用dotnet publish -c Release -r win-x64 --self-contained false生成一个干净的输出目录,比直接拷bin/Debug靠谱得多。
注意:发布目录里如果带*.pdb,体积会大不少,正式分发可以删掉,但留着它能让崩溃日志带上行号,自己调试阶段别删。
6. 二次开发:把“手感”变成一张能回滚的表
拿到源码之后真正花时间的不是加功能,是调参数。速度、间隔、判定框这些东西改动一像素,手感就换一个人,而且改完第二天就忘了昨天那版为什么更好。我的习惯是先把所有可调项集中到一张表里,每次只动一个变量,跑三局记一次结论,回滚的时候有据可依。
| 参数 | 常见初始值 | 调整幅度 | 观察指标 |
|---|---|---|---|
| 玩家水平速度 | 240 px/s | ±20% | 追弹道是否吃力 |
| 射击间隔 | 120 ms | 80~160 ms | 同屏子弹数量 |
| 子弹速度 | 600 px/s | ±15% | 从开火到命中的延迟感 |
| 敌机基础速度 | 120 px/s | ±15% | 首次交锋时间 |
| 敌机生成间隔 | 1.2 s | 0.6~1.5 s | 同屏敌机上限 |
| 碰撞判定缩放 | 0.85 | 0.70~0.95 | 擦弹是否被判死 |
调的时候一次只动一行,动完连打三局,觉得“能过但紧张”就是合适区间。全表一起改,出了问题根本不知道是哪一项的锅,这是最典型的调参翻车方式。
想做点能验证的改动,加一个最高分存档最合适:逻辑短、能看到结果、还顺便把文件读写走通一遍。
// 放 %AppData%\PlaneGame\score.json,重装游戏不会丢,也不需要管理员权限 static string SavePath => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "PlaneGame", "score.json"); static int LoadBest() { try { if (!File.Exists(SavePath)) return 0; using var doc = JsonDocument.Parse(File.ReadAllText(SavePath)); return doc.RootElement.GetProperty("best").GetInt32(); } catch { return 0; } // 文件损坏就当没有,别让存档拖垮启动 } static void SaveBest(int score) { Directory.CreateDirectory(Path.GetDirectoryName(SavePath)!); File.WriteAllText(SavePath, JsonSerializer.Serialize(new { best = score })); }SpecialFolder.ApplicationData指向当前用户的漫游目录,不需要管理员权限,也不会被安装目录的只读属性挡住。LoadBest外面的 try/catch 不是偷懒,存档文件是外部输入,用户手动改坏、断电写一半都可能发生,解析失败就归零,游戏照常启动。
从那以后我每次拿到一份新源码,都先做三件事:删 bin/obj 重编一次、改一个能看见的参数验证链路、把可调项抄进一张表。这三步走完,这份 C# 游戏源码才算真正落到自己手上,后面加敌机类型、换贴图、接排行榜都是在这个基础上长出来的。希望帮到你。
本文还有配套的精品资源,点击获取