简介:一份C# Winform飞机大战小游戏源码,面向C#窗体应用学习者,是完整可运行的小游戏项目。游戏以W-A-S-D控制飞机移动、K键发射子弹,敌方子弹命中会同时扣除1分与1点生命值,我方子弹击中敌机则增加1分;敌机爆炸时播放音效,界面设置生命值和得分两个矩形进度条,任一归零即游戏结束,交互反馈完整。代码系统展示了键盘控制、对象碰撞检测、GDI绘图、音效播放与分数生命值状态管理等常见技术,注释完整、命名规范,可直接阅读也可二次修改。压缩包共101个文件,以14个C#源文件为核心,搭配44张PNG美术素材、WAV/MP3音效、字体图标、资源文件、解决方案与DLL运行库,以及可直接启动的EXE程序,整体约13.35MB,在Visual Studio中打开sln即可运行调试。已有649人学习下载,适合需要快速搭建Winform小游戏或参考完整项目结构的初学者与开发者。
1. 用Winform写飞机大战,是练C#最划算的一个项目
很多学C#的人卡在一个尴尬的位置:增删改查写了几个月,委托、事件背得滚瓜烂熟,一碰到“画面动起来”就发怵。而Winform飞机大战这个源码级的winform项目案例,恰好把这些知识点全串起来:GDI+绘图、定时器主循环、键盘事件、集合增删、碰撞检测、状态切换,全部浓缩在一两千行代码里。你不需要懂引擎、不用碰Unity,一个空窗体就能把游戏跑起来。这篇笔记给C#入门者和想交课程设计的读者一条能落地的路径:看懂结构、抄对关键代码、避开容易翻车的坑。飞机大战不是炫技项目,但它能让你最快把“编程”变成“程序”。
2. 先把游戏框架搭起来:窗体、双缓冲与主循环
2.1 双缓冲:三行代码干掉画面闪烁
如果你直接往普通的Winform窗体上画图,第一眼看到的一定是闪瞎眼的画面。原因是GDI+默认的绘制顺序是“先用背景色擦掉旧画面,再重绘新画面”,这个擦除动作落在用户眼里就是闪烁。飞机大战里敌机、子弹、背景每帧都在变位置,不处理闪烁根本没法玩。
常见的做法是在窗体构造函数里用SetStyle开启双缓冲。双缓冲的意思是在内存里先把这一帧全部画好,再一次性地BitBlt到屏幕上,用户只看到完整帧,看不到擦除过程。
// GameForm.cs 构造函数 public GameForm() { InitializeComponent(); // 双缓冲三件套:告诉控件自己画、全部画、优化双缓冲 SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true); UpdateStyles(); }AllPaintingInWmPaint让系统不再先擦背景,UserPaint表示绘制逻辑交给你自己的OnPaint,OptimizedDoubleBuffer启用内存缓冲区。参数之间是位或关系,三个必须一起写,只开最后一个不起作用。UpdateStyles()用来让控件立即应用样式,很多教程漏掉它,导致设置不生效。
2.2 游戏主循环:Timer驱动还是Thread驱动
飞机大战这类游戏需要一个“每帧更新”的循环,Winform最稳妥的驱动方式是System.Windows.Forms.Timer。它依赖消息循环,Tick事件排队进入UI线程,所以事件里可以直接读写控件属性,不需要Invoke。我见过不少新手尝试用while(true)配合Thread.Sleep(16)跑游戏循环,结果线程里改Label.Text直接抛异常,或者窗体关闭后循环还在后台跑,非常难收场。
private System.Windows.Forms.Timer mainTimer; public GameForm() { InitializeComponent(); mainTimer = new System.Windows.Forms.Timer(); mainTimer.Interval = 16; // 约60FPS,时间单位是毫秒 mainTimer.Tick += MainTimer_Tick; mainTimer.Start(); } private void MainTimer_Tick(object? sender, EventArgs e) { UpdateLogic(); // 移动飞机、子弹、敌机 Invalidate(); // 请求重绘,触发OnPaint }Interval = 16是经验值,1000/60得到的近似值。但要注意,System.Windows.Forms.Timer并不保证严格每秒60次,如果消息队列被拖住,Tick事件会丢失,游戏表现为掉帧。对小游戏来说这完全可接受,真到了需要精确帧率的时候,再用Stopwatch记录真实时间差来修正移动距离,不要靠固定步长硬算。Invalidate()也不是立即刷新屏幕,而是把WM_PAINT消息丢进队列,这个机制本身就是在配合双缓冲。
2.3 键盘控制与背景滚动:让画面先动起来
键盘控制最容易踩的坑,是在KeyDown事件里直接给飞机坐标做加减法。结果是你按住方向键不放,Windows的按键重复消息会不断触发KeyDown,飞机要么走一步停一下,要么快到离谱。正确的做法是把按键状态记录下来,由主循环统一处理。
// 用布尔位记录四个方向的按键状态 private bool keyLeft, keyRight, keyUp, keyDown; protected override void OnKeyDown(KeyEventArgs e) { switch (e.KeyCode) { case Keys.Left: keyLeft = true; break; case Keys.Right: keyRight = true; break; case Keys.Up: keyUp = true; break; case Keys.Down: keyDown = true; break; } base.OnKeyDown(e); } protected override void OnKeyUp(KeyEventArgs e) { switch (e.KeyCode) { case Keys.Left: keyLeft = false; break; case Keys.Right: keyRight = false; break; case Keys.Up: keyUp = false; break; case Keys.Down: keyDown = false; break; } base.OnKeyUp(e); }然后在UpdateLogic()里读取这些状态,按固定速度移动飞机。按一下键和按住键之间的差别被抹平了,按住只是让状态位一直为true,移动速度不会叠加。
背景滚动是让玩家感知“飞机在飞”的关键。常见做法是用两张相同的星空图交替往上移动:
private float bgY1, bgY2; // 两张背景图的Y坐标 private const float BG_SPEED = 2f; // 背景滚动速度,像素/帧 private void UpdateBackground() { bgY1 += BG_SPEED; bgY2 += BG_SPEED; if (bgY1 >= ClientSize.Height) bgY1 = bgY2 - ClientSize.Height; if (bgY2 >= ClientSize.Height) bgY2 = bgY1 - ClientSize.Height; }bgY1和bgY2初始化时让第二张图紧贴第一张图的底部,滚动超过窗体高度后跳回另一张图的上面,形成无限循环。ClientSize.Height是窗体客户区高度,不要用窗体的Height,那包含标题栏,会让背景图衔接处露出黑边。速度值2f是像素/帧,在60FPS下大约每秒移动120像素,属于比较稳的视觉速度,你可以按自己背景图的尺寸调成1到4之间的值。
3. 飞机大战核心逻辑:碰撞、子弹与敌机管理
3.1 矩形碰撞检测:先用矩形再谈像素
碰撞检测是整个游戏里最容易被高估的技术点。子弹打敌机、敌机撞玩家,绝大多数需求用Rectangle.IntersectsWith就够了,不需要像素级检测。每个游戏对象都维护一个Rectangle,或者由坐标和图片尺寸临时生成,每帧两两判断是否相交。
private bool IsHit(Rectangle bulletRect, Rectangle enemyRect) { // 两个矩形只要存在交集就判定碰撞 return bulletRect.IntersectsWith(enemyRect); }这个方法的判断逻辑是:分别比较两个矩形的左右边界和上下边界,存在重叠区域就返回true。Rectangle结构体本身存储x、y、width、height,所以判定成本极低,几百个对象同时检测也没有压力。
但矩形碰撞的误差要心里有数。飞机的PNG图片往往有透明边缘,矩形框比实际可见的飞机大一圈,玩家会感觉“明明没碰到却被击中了”。我一般会写一个缩小碰撞框的方法,把判定区域往中心收缩10%到20%:
private Rectangle Shrink(Rectangle rect, int percent) { int shrinkW = rect.Width * percent / 100; int shrinkH = rect.Height * percent / 100; return new Rectangle( rect.X + shrinkW / 2, rect.Y + shrinkH / 2, rect.Width - shrinkW, rect.Height - shrinkH); }还有一个隐藏问题:高速子弹可能“穿透”薄目标。比如子弹每帧向上移动10像素,而敌机只有4像素高,碰撞检测只发生在“当前这一帧的两帧快照”之间,子弹上一帧在敌机下方、这一帧已经到了敌机上方,两次检测都没相交,穿透就发生了。解法一是限制子弹速度不超过敌机最小尺寸;二是用线段插值检测,即把这一帧的位移拆成小步逐段检测。小游戏里限制速度最省事,我建议子弹速度控制在8像素/帧以内。
3.2 子弹与敌机的增删:逆序遍历List的坑
游戏里子弹、敌机都是动态增删的,最常见的容器是List<T>。增删时最容易翻车的写法是正序遍历然后RemoveAt:
// 错误示范:正序删除会跳过元素 for (int i = 0; i < bullets.Count; i++) { bullets.RemoveAt(i); // 删除后元素前移,i+1跳过了原i+1位置的元素 }这个坑的原理是:List<T>的RemoveAt会把被删元素之后的所有元素向前移动一位,此时你循环变量i已经加一,实际上跳过了原本位于i+1的那个元素。一帧里如果连续命中两个敌人,第二个往往不会被正确删除。
正确的做法是逆序遍历,从尾部往头部删,删除尾部元素不影响前面元素的下标:
// GameForm.cs private readonly List<Bullet> bullets = new List<Bullet>(); private void UpdateBullets() { for (int i = bullets.Count - 1; i >= 0; i--) { Bullet b = bullets[i]; b.Y -= BULLET_SPEED; // 子弹向上飞,Y减小 if (b.Y < -b.Height) // 超出屏幕上边界 { bullets.RemoveAt(i); continue; } for (int j = enemies.Count - 1; j >= 0; j--) { if (IsHit(b.Rect, Shrink(enemies[j].Rect, 20))) { enemies.RemoveAt(j); bullets.RemoveAt(i); // 同时移除子弹 score += 10; break; // 这颗子弹不能再碰撞第二个敌人 } } } }BULLET_SPEED是常量,方便一处修改全局生效。内层循环也要从尾部开始,因为命中的敌人被删除后,j的遍历方向不会影响前面待检查的敌机。注意内层删除子弹后必须break,否则继续内层循环时,bullets[i]已经被移除,再访问就是越界或错误对象。
这里顺便说一下性能:List<T>删除中间元素涉及后续元素的拷贝,子弹数量到上千时会有明显卡顿。但飞机大战里同时存活的子弹和敌机通常只有几十个,逆序遍历的拷贝成本可以忽略。与其焦虑集合性能,不如先保证逻辑正确,真要优化再上对象池,后面第6章会讲。
3.3 刷怪与难度曲线:随机数和关卡节奏
敌机不能一次性全刷出来,也不能毫无规律地乱刷。常见做法是维护一个刷怪计时器,每过一定时间生成一个敌机,生成位置在屏幕宽度内随机,但要把敌机图片宽度考虑进去,避免半个身子在屏幕外。
private int spawnTimer; // 帧累计变量 private int spawnInterval = 60; // 每60帧刷一个敌机 private readonly Random random = new Random(); private void UpdateEnemySpawn() { spawnTimer++; if (spawnTimer >= spawnInterval) { spawnTimer = 0; int maxX = ClientSize.Width - enemyWidth; int x = random.Next(0, maxX); enemies.Add(new Enemy(x, -enemyHeight)); // 从屏幕顶部外出现 } }random必须做成字段而不是在方法里new Random()。每次new Random()如果连续执行时间间隔极短,系统会用当前时间做种子,多个实例可能产生完全相同的随机序列,刷怪规律会显得很假。这也是很多新手回报“明明Random了,敌机还是排成一条线”的原因。
关卡的难度曲线,我习惯做成“分数驱动”而不是“时间驱动”:玩家达到一定分数后,刷怪间隔缩短、敌机移动速度提高。这样游戏难度跟玩家表现绑定,而不是让玩家盯着时钟被动等变难。如果刷怪间隔小于某个值,游戏会变成无敌弹幕,通常建议下限30帧,也就是每秒最多刷2个敌机。移动速度也可以分级,比如4分、8分、12分各提供一个速度档,不要做成连续曲线,玩家更容易感知到“这个阶段变难了”。
4. 把游戏做成完整产品:分数、生命、音效与界面切换
4.1 分数与生命UI:Label刷新和跨线程绕坑
Winform下UI更新最简单的方式是直接改Label.Text。飞机大战是Timer驱动,所有逻辑都在UI线程里执行,所以不存在跨线程问题,直接赋值即可:
private void UpdateHUD() { lblScore.Text = "得分:" + score; lblLife.Text = "生命:" + life; }有些情况下,比如你用了后台线程管理游戏逻辑,再在后台线程里写lblScore.Text = ...,程序会在运行时抛出“线程间操作无效”的异常。这不是Winform的限制,而是控件不是线程安全设计的。解决办法是Invoke把UI更新封送回UI线程:
private void UpdateScoreUI() { if (lblScore.InvokeRequired) { lblScore.BeginInvoke(new Action(() => lblScore.Text = "得分:" + score)); return; } lblScore.Text = "得分:" + score; }InvokeRequired判断当前线程是否与控件所属线程不同,BeginInvoke异步排队,不阻塞后台线程。但这个方案在飞机大战里属于多余复杂度,Timer驱动下根本走不到InvokeRequired == true的分支。知道有这回事,遇到异常能排查就够了。
每帧刷新Label.Text会触发一次控件重绘,对几毫秒的开销,飞机大战完全扛得住。非要优化就让HUD每10帧刷新一次,视觉上分数跳变也不那么生硬。
4.2 开始-暂停-结束:用状态机切界面
游戏不能一打开窗体重接开打,至少要有“开始”“暂停”“结束”三种状态。很多新手用一堆bool标志位控制,比如isStart、isPause、isOver,组合起来能凑出几十种非法状态,改一个就得回头检查另外三个。我会用一个枚举状态机,把游戏行为按当前状态分派:
private enum GameState { Ready, Playing, Paused, GameOver } private GameState state = GameState.Ready; private void MainTimer_Tick(object? sender, EventArgs e) { switch (state) { case GameState.Ready: break; // 等待玩家按空格开始 case GameState.Playing: UpdateGame(); break; case GameState.Paused: break; // 暂停时不更新逻辑 case GameState.GameOver: break; } Invalidate(); } private void UpdateGame() { UpdateBackground(); UpdatePlayer(); UpdateBullets(); UpdateEnemySpawn(); UpdateEnemies(); CheckPlayerHit(); }状态切换时做对应初始化或清理。比如从Ready切到Playing时重置飞机位置、清空子弹和敌机列表、把分数归零;从Playing切到Paused时什么都不用动,只是主循环不再调用UpdateGame,画面冻结,这本质上是“游戏时间暂停”。
界面呈现也由状态驱动:Ready时画面中央画“按下空格开始”,GameOver时画“游戏结束,按R重开”,这比堆叠多个Panel再来回Visible切换省事得多。输入处理里也要带上状态判断,比如GameOver状态下按空格重新开始,Playing状态下按P暂停。
4.3 音效、背景图与winform界面美化
音效这块Winform没有一个“开箱即用的万能播放器”。System.Media.SoundPlayer是免费且内置的,但它只支持wav格式,而且用起来有坑:如果用new SoundPlayer("文件路径")然后每次播放时创建实例,会有明显的延迟和卡顿。正确做法是提前加载进内存:
private SoundPlayer shootSound; private void LoadAudio() { try { // 预加载音效,避免游戏中首次播放延迟 shootSound = new SoundPlayer(@"Assets\shoot.wav"); shootSound.Load(); } catch (Exception ex) { // 音效缺失不能让游戏崩溃,打印异常继续跑 Console.WriteLine("音效加载失败:" + ex.Message); } } private void PlayShoot() { if (shootSound != null) { shootSound.Play(); // 异步播放,不阻塞主循环 } }Load()同步读文件,放构造函数里最多卡几十毫秒,可以接受。Play()是异步的,不会挡住游戏循环。背景音乐如果用mp3,SoundPlayer就不行了,常见做法是引入Windows Media Player COM组件或NAudio库,这个按项目需求自行决定,不要为了一个背景音乐给游戏引入一整套多媒体框架。
背景图和整体视觉属于winform界面美化的范围。窗体默认的灰色边框和标题栏很出戏,常见做法是设置FormBorderStyle = None、BackgroundImage,或者直接在OnPaint里绘制背景。注意BackgroundImage直接赋值给窗体时,双缓冲的设置依然生效,但如果你同时用Graphics.Clear()清屏,会先刷成背景色再画图,又闪回来了。正确做法是用背景滚动绘制代替Clear(),每一帧用背景图完全覆盖客户区,不需要擦除动作。
5. 飞机大战避坑指南:5个新手必踩的坑
5.1 画面闪到看不清敌机
现象:飞机和子弹移动时有明显闪烁和拖影,能看出屏幕在一张一张擦除重画。
原因:双缓冲没有真正开启。常见的遗漏是只写了SetStyle却没调用UpdateStyles(),或者双缓冲参数里少了ControlStyles.UserPaint;还有一种是OnPaint里先调用了base.OnPaint(e)导致背景被额外擦除一遍。
解决:构造函数里把三个ControlStyles位或后传进去,紧跟UpdateStyles()。自定义控件的父容器如果有背景图,也可能把闪烁传下去,检查父容器的DoubleBuffered属性,直接对窗体设true也是办法。拿不准就先用最简单的一行属性验证:this.DoubleBuffered = true;,有效再换SetStyle。
5.2 按一下方向键飞机飘出半个屏幕
现象:按住左键,飞机先匀速移动一小段,突然变得飞快;或者松开按键后飞机还在滑行。
原因:Windows键盘默认有按下延迟和重复触发,KeyDown事件实际会被触发多次,如果直接在KeyDown里改横坐标,飞机每帧被加多次速度。另一个常见原因是UpdatePlayer里没有做边界约束,飞机直接飞出客户区。
解决:用KeyDown/KeyUp只记录状态位,主循环里统一按状态移动。加边界钳制:
playerRect.X += moveSpeed; playerRect.X = Math.Max(0, Math.Min(playerRect.X, ClientSize.Width - playerRect.Width));Math.Max和Math.Min嵌套把横坐标限制在0到“窗体宽度减飞机宽度”之间,顶到边缘就停住,飞机永远不会出画布。
5.3 子弹越打越多,游戏越来越卡
现象:前两分钟一切正常,五分钟之后帧率明显下降,飞机开始一顿一顿的。
原因:典型的内存和集合失控。每发射一颗子弹就bullets.Add(new Bullet(...)),而子弹飞出屏幕后没有删除;或者删除了但用的是正序遍历RemoveAt,实际漏删了一半。对象一直在涨,绘制和碰撞检测的时间开销跟着涨。
解决:每一帧都做越界删除,用逆序遍历保证不跳项。再给玩家加一个射速下限,比如每次发射之间至少隔8帧,用计数器累加代替无限制发射。代码层面没有“自动清空”功能,越界、命中、撞击这三条路径都必须有RemoveAt或对象池回收。
5.4 关掉窗体,进程栏里还有它
现象:点关闭按钮,窗口消失了,但任务管理器里进程还在,CPU占用居高不下。
原因:Application.Run(new GameForm())的窗体关闭时,如果后台还有一个死循环线程在跑,进程不会退出。很多教程让新手用Thread做游戏循环,但这个循环没有退出机制,窗体虽然关了,线程还在空转。
解决:如果用了System.Windows.Forms.Timer,在FormClosing事件里停掉计时器:
protected override void OnFormClosing(FormClosingEventArgs e) { mainTimer.Stop(); mainTimer.Dispose(); base.OnFormClosing(e); }如果确实用了后台线程,不要用Abort()粗暴终止,那可能破坏资源。更稳妥的是用ManualResetEvent做停止信号,线程循环里检查信号,收到后正常退出,然后再Join()等待线程结束。项目源码里如果看到这两种模式的任何一种,至少说明作者考虑过进程生命周期。
5.5 PNG贴图变成黑块或白块
现象:飞机图片放上去后,透明背景变成了黑色或白色矩形,整个画面像贴了胶带。
原因:PNG图片本身带Alpha通道,但加载或绘制时把格式弄丢了。常见引发点是用了Bitmap.Clone截取图片区域时没有指定PixelFormat,或者用Graphics.Clear()先刷了不透明背景,再画透明图片也会把透明区域显示成背景色。还有一个冷门原因:某些代码从Bitmap.GetPixel做了逐像素处理,读出来的是32位ARGB,写到屏幕上却转成了24位RGB,Alpha直接丢。
解决:加载图片时直接用new Bitmap("path"),不要手动转换格式;绘制用Graphics.DrawImage即可,GDI+默认尊重Alpha。如果局部区域要抠图,Clone(rect, PixelFormat.Format32bppArgb)明确指定位深。透明控件还要配合BackColor = Color.Transparent和SupportsTransparentBackColor样式才能生效。
6. 让代码值得交付:对象池、发布与源码阅读顺序
6.1 用对象池挡住频繁new带来的GC压力
子弹和敌机每帧都在创建和销毁,高频率的new会触发GC分配。想让帧率曲线更平直,可以给子弹做对象池,核心逻辑是复用对象而不是销毁对象:
public class BulletPool { private readonly Queue<Bullet> pool = new Queue<Bullet>(); public Bullet Rent() { return pool.Count > 0 ? pool.Dequeue() : new Bullet(); } public void Return(Bullet bullet) { pool.Enqueue(bullet); } }Rent从队列里取复用对象,队列为空才真正new;Return把用过的子弹放回队列。这需要把以前写RemoveAt的代码改成“标记死亡并回收到池子”。我见过很多人一开始就上对象池,反而把逻辑绕晕,飞机大战这种小型项目完全可以直接new。对象池是优化选项,不是必需品,等真的卡了再加不迟。
6.2 发布配置:让朋友双击就能玩
源码能跑起来只是一半,交付还要过发布这一关。调试模式下运行,VS会拖着一堆调试文件,发给别人大概率跑不起来。在VS里把配置切到Release,目标平台按实际情况选。传统.NET Framework项目目标机器一般自带运行时,直接发exe就行;.NET 6及以上版本可以用自包含发布,把运行时打进exe,缺点是体积变大,好处是目标机器不用装环境。发布前测试一下窗口尺寸在不同分辨率下的表现,飞机大战的窗体固定大小即可,不要用可拉伸窗口,否则客户区变化会让碰撞框和背景图错位。
6.3 源码怎么读最快:入口一路断点下去
拿到任何Winform游戏源码,我的习惯是先找Program.cs看入口,再跳到主窗体的构造函数,重点看两个地方:哪一行启动了Timer,哪一段是OnPaint。理解了这两处,整个游戏的骨架就清楚了。接着按F11断进Tick事件,跟着UpdateGame()把子弹、敌机、碰撞各走一遍,比直接从头到尾读代码快得多。想验证手感,改三个参数:玩家移动速度、刷怪间隔、子弹速度。找每一处const或readonly字段,试一遍改一遍就知道每个参数管什么,比看注释深刻。
做了这么多年C#和Winform项目,我的体会是飞机大战源码最大的价值不是“游戏多好玩”,而是它把你学过的委托、事件、集合、绘制全部逼到实际使用场景里。我当年改这个项目时,最深的教训就是别迷信高级技巧,先把主循环和碰撞写稳,而阅读他人源码时先动手跑起来再断点追。希望这份拆解能帮到你,少走一点我走过的弯路。
本文还有配套的精品资源,点击获取