ARTICLE DETAIL

资讯详情

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

C#坦克大战源码深度解析:WinForms帧循环、碰撞检测与AI实战

C#坦克大战源码深度解析:WinForms帧循环、碰撞检测与AI实战 简介C#控制类游戏坦克大战源码实例是一份面向C#初学者与游戏开发入门者的代码参考完整演示了控制台游戏从地图、坦克、子弹到音效的基础实现思路适合希望从零搭建小游戏逻辑的开发者。源码内置声音效果sound音效文件已拷贝至Debug目录中压缩包为rar格式、整体大小约3.34MB轻量易下载解压后即可结合音效文件运行体验。源码中覆盖了多个游戏编写基础技巧例如碰撞检测用于判定子弹是否命中目标并区分对象类型子弹坐标管理负责弹道更新与越界处理游戏区域控制保证移动范围受限而如何区分碰撞的是墙还是坦克则涉及对象类型判断与响应分支例如子弹击中墙壁时停止更新、击中坦克时触发爆炸与音效。这些能力加上坦克移动、敌我区分、地图边界和音效触发等细节是后续开发更复杂2D游戏绕不开的底层逻辑代码结构清晰细心研读还能领悟游戏主循环和对象状态刷新的常见写法。目前已有167人学习使用适合希望借助实例快速理解C#游戏开发原理的入门者参考。1. 坦克大战源码这潭水有多深C#控制类游戏的入口、价值与第一道坎很多人拿到一个“坦克大战源码-C#控制类游戏源码实例”第一反应是“这有什么稀奇的不就是把红白机游戏抄一遍吗”。真上手之后才会发现标题里“控制类”三个字才是关键——它指的是玩家通过键盘实时控制坦克移动、转向和射击的游戏程序启动后整个主循环每16毫秒刷新一次检查你的按键、更新子弹坐标、判断砖墙是否被击中。C#做这类游戏最常被人唱衰的是“卡顿、闪屏、手感飘”但这些都是有明确根因的工程问题不是玄学。这篇按我自己的实现顺序展开先搭帧循环和坦克移动再做射击与碰撞然后补上敌方AI最后把最容易翻车的几个坑单独列出来。适合正在选课程设计题目、想用C#练项目实战、或者想读懂别人C#控制类游戏源码但看不懂结构的人。2. 从零搭起最小战场WinForms帧循环与坦克移动的三个核心参数2.1 C#做控制类游戏为什么是WinForms GDI而不是Unity做C#游戏读者的第一反应往往是Unity。Unity确实用C#写逻辑但一个“坦克大战源码”如果想让人能一页看懂核心逻辑WinForms反而是更常见的承载方式。原因有三第一窗体自带消息循环键盘事件、重绘事件都是现成的不需要理解Unity那套MonoBehaviour生命周期第二GDI绘制矩形的性能足够应付坦克大战这种小场景地图13×13格、砖块几十个、子弹十几发每帧几十次绘图调用对现代CPU几乎没有压力第三整个项目就是一个解决方案文件点开就能编译运行适合课程设计、面试演示和源码阅读。Unity适合做需要美术资源和场景管理的游戏坦克大战源码这类偏“算法与逻辑演示”的项目用WinForms反而更干净。控制类游戏的核心骨架是一个“帧循环”。C#里最省事的帧循环是System.Windows.Forms.Timer把Interval设成16毫秒Tick事件里做“读输入→更新状态→触发重绘”三步。16毫秒这个数字不是随便拍的一秒1000毫秒除以16约等于62帧人眼对60帧以上就感知不到明显卡顿。有些源码会把Interval设成10甚至5那不是让画面更流畅只是让CPU空转对坦克大战来说16毫秒已经超过红白机原版的刷新率了。至于用Thread里的while(true)死循环加Thread.Sleep(1)的做法我强烈不建议用来学习——它能够工作但跨线程访问UI控件迟早会碰到“其他线程正在使用此控件”的经典异常这是C#上位机和桌面开发里出了名的坑。2.2 坦克的最小模型位置、朝向、速度与键状态表坦克在代码里的本质是一个矩形。用System.Drawing.Rectangle存储BoundsX、Y决定左上角位置Width、Height决定坦克占地尺寸。方向用一个枚举变量Face来记移动的时候根据Face去改Bounds.X或Bounds.Y每次改多少由Speed决定。这三样东西——位置、朝向、速度——就是坦克移动的全部核心参数。下面这个类是最小实现不需要任何游戏引擎依赖直接丢进WinForms工程就能编译// TankEntity.cs —— 坦克的最小可移动模型 public class TankEntity { public Rectangle Bounds; // 坦克占用的矩形位置和尺寸都在这里 public int Speed 2; // 每帧移动像素数越小越精细但越慢 public Direction Face Direction.Up; // 当前朝向上、下、左、右 public bool IsPlayer; // 区分玩家和AI public bool IsAlive true; public void Move(Direction dir) { Face dir; switch (dir) { case Direction.Up: Bounds.Y - Speed; break; case Direction.Down: Bounds.Y Speed; break; case Direction.Left: Bounds.X - Speed; break; case Direction.Right: Bounds.X Speed; break; } } }这段代码背后有两个容易被新手忽略的点。第一Move方法没有做任何“能不能走”的判断它只负责改变坐标边界检查要放在外层统一做否则坦克就会走出窗体外第二真正的位移量是Speed而不是方向枚举本身。把Speed提出来做成可调字段以后调手感、做“吃到星星就加速”的道具效果都只需要改这一个数值。方向枚举建议声明为Up/Down/Left/RightWinForms里没有现成的自己写一个枚举四行搞定。按钮的“控制感”来自按键状态而不是按键事件。初学者最容易写出的错误代码是在KeyDown事件里Move一步。这样按一下W坦克只走一格按住不放只会重复触发KeyDown画面会一顿一顿地“爬”。教科书里的KeyDown事件面向“快捷键、输入框”这类一次性交互控制类游戏要的是“持续按住持续移动”所以正确做法是维护一张按键状态表KeyDown时把按下的键添加进集合KeyUp时移除然后在帧循环里查这张表查到了就一直走。// MainForm.cs —— 帧循环与按键状态表的配合 public partial class MainForm : Form { private readonly Timer _timer; // 帧循环驱动源 private readonly HashSetKeys _pressedKeys; // 当前按住的键 private readonly TankEntity _player; public MainForm() { InitializeComponent(); DoubleBuffered true; // 先开着防闪屏 _pressedKeys new HashSetKeys(); _player new TankEntity { IsPlayer true, Bounds new Rectangle(320, 420, 36, 36) }; _timer new Timer { Interval 16 }; // 约62帧/秒 _timer.Tick GameLoop; _timer.Start(); KeyDown OnKeyDown; KeyUp OnKeyUp; } private void OnKeyDown(object sender, KeyEventArgs e) { _pressedKeys.Add(e.KeyCode); // 只记录不做事 } private void OnKeyUp(object sender, KeyEventArgs e) { _pressedKeys.Remove(e.KeyCode); } private void GameLoop(object sender, EventArgs e) { if (_pressedKeys.Contains(Keys.W)) _player.Move(Direction.Up); if (_pressedKeys.Contains(Keys.S)) _player.Move(Direction.Down); if (_pressedKeys.Contains(Keys.A)) _player.Move(Direction.Left); if (_pressedKeys.Contains(Keys.D)) _player.Move(Direction.Right); Invalidate(); // 只是请求重绘真正的绘制在OnPaint里 } }这里的核心是把“事件”和“状态”分离。KeyDown/KeyUp只负责维护状态表真正影响坦克坐标的逻辑全部放进帧循环由Timer驱动统一执行。为什么不用KeyDown直接移动刚才说过按住不放时事件不会连续触发画面会像打字一样一节一节地动再一个原因是如果以后要加“同时按W和D走右上角”在KeyDown里做组合键判断会非常痛苦在状态表里就只是一次Contains查询。参数方面Interval16是通用值机器极老时建议放宽到20不要低于10低于10只是让CPU空转。接下来要处理“会不会撞到东西”的问题。控制类游戏里没有重力帮你收手所有边界约束都是代码一层层垫上去的。坦克的边界检查分两类窗体边界和地图砖块。窗体边界简单四行if判断Bounds.X、Bounds.Y、Right、Bottom越界就弹回砖块边界则要查地图格子这也是“C#里数组和集合分别是怎么定义的、使用上有什么区别”这个问题的天然应用场景// 地图和动态对象的存储方式数组 vs 集合 private TileType[,] _map new TileType[13, 13]; // 地图格子固定13x13用二维数组 private ListTankEntity _enemies new ListTankEntity(); // 敌方坦克数量会增删用List集合 private ListBullet _bullets new ListBullet(); // 子弹随时发射和消失用List集合数组和集合在C#里的定位完全不同数组长度固定、内存连续、按下标访问是O(1)适合“地图格子里有没有墙”这种静态查询List集合支持Add和Remove长度随需求增长适合“子弹、敌人、掉落物”这类数量和生命周期都不确定的对象。很多源码写着写着就把地图也换成ListList 不是不行而是白白增加复杂度——地图从不增删格子数组就够了。判断坦克能不能走到某个格子就是拿坦克的Bounds换算成地图行列索引再去查_map那个位置是不是砖块。这个换算有个坑坦克尺寸和格子尺寸要匹配坦克36像素、格子也36像素时行列号用整数除法一除就出来尺寸不一致时要先算中心点再除避免坦克贴墙时半个身子钻进去。3. 子弹、砖墙与碰撞把“开火”做成真实手感射击系统是坦克大战源码里第二个大模块也是“控制感”最直接的部分。玩家按一下空格坦克炮口冒出一颗子弹子弹沿朝向直线飞碰到砖墙就炸掉一格砖碰到敌坦克就让它报废。这一段要处理三个问题子弹对象怎么定义、怎么限制开火频率、碰撞判定怎么做才不穿模。3.1 开火一瞬间发生了什么子弹集合、方向与射击冷却子弹同样是矩形。与坦克不同的地方在于子弹出生在炮口前方飞行方向发射时就已经固定不再变速度可以比坦克快一倍以上生命周期用布尔标记代替立即移除——如果用foreach遍历子弹一边遍历一边Remove会直接抛InvalidOperationException所以常见做法是“打到的东西就标Alivefalse每帧末尾统一RemoveAll”。这是很多C#源码里让新手困惑的写法其实背后就是这个集合操作的限制。回到开火本身。一次空格按下要做的事有三件检查冷却时间够不够按当前朝向计算炮口矩形生成一颗新子弹加入集合。射击冷却参数很关键红白机原版大约每0.2秒一发C#源码里常把这个数写成200毫秒如果写0就是按住空格子弹连成一条线游戏会失去节奏。用DateTime.Now做时间差判断是WinForms里最简单的冷却方案不用额外声明计时器缺点是DateTime.Now精度只有毫秒级对200毫秒的冷却来说完全够用。// Bullet.cs —— 子弹的飞行模型 public class Bullet { public Rectangle Bounds; public Direction Direction; // 发射后方向不再改变 public int Speed 4; // 子弹速度坦克步长2子弹步长4 public bool Alive true; public void MoveOnce() { switch (Direction) { case Direction.Up: Bounds.Y - Speed; break; case Direction.Down: Bounds.Y Speed; break; case Direction.Left: Bounds.X - Speed; break; case Direction.Right: Bounds.X Speed; break; } } }// MainForm.cs —— 开火与冷却 private DateTime _lastFire DateTime.MinValue; private const int FireCooldownMs 200; // 200毫秒一发手感接近红白机 private void TryFire(TankEntity tank) { if ((DateTime.Now - _lastFire).TotalMilliseconds FireCooldownMs) return; _lastFire DateTime.Now; var muzzle GetMuzzleRect(tank); // 炮口位置算法在下方说明 var bullet new Bullet { Direction tank.Face, Speed 4, Bounds muzzle }; _bullets.Add(bullet); }炮口矩形的算法是个常见的数学落点问题。坦克的Bounds是36×36炮口就是“坦克朝向上的那一条边”。朝上时炮口在Bounds顶部正中矩形宽6像素、高8像素、X为坦克中心X-3、Y为Bounds.Y-8朝下时在底部正中朝左时在左边缘垂直居中朝右时在右边缘。把这段写成一个GetMuzzleRect方法用switch返回Rectangle比每次在TryFire里手写判断要清晰。这里特别提醒一点子弹出生点不要放在坦克中心否则子弹还没出膛就和坦克自身发生碰撞这是坦克大战源码里出现频率极高的一处低级bug。3.2 矩形碰撞检测的数学IntersectsWith、步长与运动分割GDI的Rectangle自带IntersectsWith方法判断两个矩形是否相交一行代码就能完成。但碰撞检测的困难从来不在“判断相交”这一步而在两帧之间物体是否直接跳过了对方。坦克大战里的典型翻车现场是“子弹穿砖墙”子弹步长4像素砖墙厚度也是4像素如果子弹上一帧在墙左侧、这一帧已经运动到墙右侧两帧之间完全没有和砖墙“重叠”的时刻IntersectsWith就反应不过来子弹像幽灵一样穿过去了。解决穿模的标准做法是运动分割。设子弹速度为4那就把“一次移动4像素”拆成“四次移动1像素”每一次移动都做一次碰撞检测命中就停止。这样做的成本是每帧额外几次IntersectsWith调用子弹总数按20发算也不过80次完全可以承受// MainForm.cs —— 子弹更新与碰撞检测分割步长版 private void UpdateBullets() { foreach (var bullet in _bullets) { if (!bullet.Alive) continue; // 分割步长把速度拆成1像素的小步逐一检测杜绝穿墙 for (int step 0; step bullet.Speed; step) { bullet.MoveOnce(); // 这里只移动1像素 // 1. 撞地图砖块 var hitWall _walls.FirstOrDefault(w w.Bounds.IntersectsWith(bullet.Bounds)); if (hitWall ! null) { bullet.Alive false; hitWall.Hp--; if (hitWall.Hp 0) _walls.Remove(hitWall); break; } // 2. 撞敌方坦克 var hitEnemy _enemies.FirstOrDefault(e e.IsAlive e.Bounds.IntersectsWith(bullet.Bounds)); if (hitEnemy ! null) { bullet.Alive false; hitEnemy.IsAlive false; _enemies.Remove(hitEnemy); break; } } } }这段代码里的Hp字段是砖墙的血量。红白机原版里普通砖墙打一枪就碎铁墙打不穿草丛打不穿但能挡住视线。C#源码里常见的做法是给墙类定义两个字段Hp和IsIndestructible铁墙把Hp设成int.MaxValue普通砖墙Hp1。这个设计比用布尔值IsWall区分要灵活得多以后要加“两枪才能击穿的加固砖”只要把Hp改成2逻辑完全不用动。另外注意砖墙用List存储而不是数组因为砖块会被打掉需要Remove。碰撞检测的顺序也有讲究。子弹先碰地图砖墙再碰敌方坦克顺序如果反过来可能出现“子弹穿过砖墙又打中坦克”的连锁误判break在命中后立刻跳出循环避免同一帧里一颗子弹连打两堵墙。FirstOrDefault返回的是满足条件的第一项对于坦克大战这种同屏对象数量几十个的场景完全够用不需要用空间哈希或四叉树那种级别的优化。还有个容易被忽略的边界条件子弹飞出窗体。坦克有边界检查子弹没有。子弹撞不到任何东西会一直往一个方向飞直到坐标溢出int范围绘制时直接卡死。所以UpdateBullets最后要加一段“越界就回收”// 窗体边界裁剪 if (bullet.Bounds.Top 0 || bullet.Bounds.Bottom ClientRectangle.Height || bullet.Bounds.Left 0 || bullet.Bounds.Right ClientRectangle.Width) { bullet.Alive false; }这段代码看似多余实际上在Debug模式下还能救你一命一个飞到int.MaxValue的Bounds会画出不可预期的图形把GDI绘制直接拖垮。4. 敌方坦克AI与出生规则让控制类游戏真正“活”起来坦克大战的“对手感”与“AI”是玩家最先感知的部分也是C#源码里最值得阅读的模块。敌方坦克不需要复杂的机器学习只需要记住“AI就是有限状态机”这句话绝大部分逻辑都能看懂每过一段时间做一次决策决策的结果要么是转向、要么是移动、要么是开火。4.1 敌方AI的三种常见做法随机、追踪与预判开火经典红白机坦克大战的AI其实并不复杂坦克朝向一个方向移动碰到墙或到了路口就换方向偶尔向玩家方向开炮。这个逻辑放到C#里最朴素的做法就是Random.Next每200毫秒做一次决策有35%的概率换方向有8%的概率开火。为了让AI不显得太呆可以再加一个“直线对齐就开火”的预判玩家坦克和敌方坦克在同一行或同一列且中间没有砖墙时开火概率提高。三种做法的实现成本天差地别。随机AI只需要四行代码是新手最好的起点追踪AI需要拿玩家坐标减去敌人坐标比较X和Y的差值决定优先追哪边预判开火则需要做一次直线扫描。下面这段AI是按“随机为主、追击为辅”的折中方案写的延续了第2章强调的“状态分离”思路——AI只负责在决策周期内更新敌人的朝向和开火意图不直接改UI// EnemyAI.cs —— 敌方坦克的决策逻辑 private readonly Random _rand new Random(); private int _aiTick; // 决策计时器单位毫秒 private const int AiInterval 200; // 每200毫秒做一次决策 private const int TurnChance 35; // 35%概率随机换方向 private const int FireChance 8; // 8%概率开火 public void Update(TankEntity enemy, TankEntity player, int deltaMs) { _aiTick deltaMs; if (_aiTick AiInterval) return; // 追踪模式随机转向 向玩家方向靠 if (_rand.Next(100) TurnChance) { enemy.Face (Direction)_rand.Next(4); } // 直线预判同一行或同一列且没有墙时才提高开火率 bool aligned Math.Abs(enemy.Bounds.Center.X - player.Bounds.Center.X) 18 || Math.Abs(enemy.Bounds.Center.Y - player.Bounds.Center.Y) 18; int fireProbability aligned ? 30 : FireChance; if (_rand.Next(100) fireProbability) { FireBullet(enemy); } enemy.Move(enemy.Face); _aiTick 0; }这段代码的三个参数——AiInterval、TurnChance、FireChance——就是AI的手感旋钮。AiInterval越小AI反应越快设成50毫秒敌人就像开了外挂刚转向就被追击TurnChance太高敌人会原地鬼畜太低又会一条路走到底FireChance则是全游戏的难度核心从8调到30游戏的压迫感完全不是一个量级。需要看清楚的一点是对齐判断不能用坐标完全相等这里我写成差值绝对值小于18像素——坦克宽度36的一半——只要两个中心点横向或纵向差不到半个车身就视为“对齐了”。源码里如果直接写“”AI几乎永远不会开火这种bug新手几乎必踩。调用时在GameLoop里用Stopwatch或Environment.TickCount计算上一帧到这一帧的毫秒数作为deltaMs传入Update。如果嫌麻烦也可以用Timer的Interval值直接填充但要注意Timer触发并不完全精确用Stopwatch更稳。4.2 出生点与重生用List管理敌人用委托派发光标出生逻辑是另一个看着不起眼、实际上充满设计细节的模块。经典街机的出生规则是“场地四个角落轮流刷”且出生后有约1秒无敌闪烁时间防止玩家堵着出生点秒杀。C#源码里常见的实现是用一个List存储剩余的敌人类型例如List remainingEnemies每消灭一个敌人就从列表头部取一个出来生成新实例。列表的好处是天然支持“本关敌人构成表”——第1关全是普通坦克第2关加入速度快的轻型坦克——这种“关卡数据驱动”的设计。用委托处理“敌人被击毁”这个事件是整个源码里最优雅的做法之一也是C#委托的实际应用场景。坦克被子弹命中后不需要去调用UI层更新分数——那是耦合。让MainForm向外暴露一个事件OnEnemyDestroyed凡是关心这个事件的人都自己订阅。比如记分牌、关卡进度、音效播放分别订阅同一个事件各干各的活// MainForm.cs —— 用委托/事件解耦“消灭敌人”的后续处理 public event Actionint OnEnemyDestroyed; // 参数传剩余敌人数 private void OnBulletHitEnemy(TankEntity enemy) { enemy.IsAlive false; _enemies.Remove(enemy); _score 100; OnEnemyDestroyed?.Invoke(_enemies.Count); // 触发事件UI自己去刷新 if (_enemies.Count 0) { StartNextWave(); // 复活敌人并提高难度 } }第一次看这行OnEnemyDestroyed?.Invoke的读者往往会卡住这里解释一下?.是null条件运算符意思是“如果这个事件没人订阅就跳过”Invoke就是逐个调用订阅者注册的方法。委托的实质是把方法当成参数传给调用方你用订阅事件本质上是在委托的调用列表里挂了一个方法。为什么这里比直接调用UpdateScore()好因为消灭敌人之后要干的事会越来越多——加分、播放音效、掉道具、检查过关——每加一个功能就去改OnBulletHitEnemy函数会越来越胖用事件之后新功能只需要在初始化时订阅一次MainForm的核心逻辑完全不用动。这就是为什么大量C#源码里到处都是event和Action不是炫技是真实场景里解耦的需求。重生逻辑同样使用集合。原版每关固定有20个敌人被打掉后按一定间隔补充。常见实现是设一个EnemySpawnInterval参数敌人数不足上限时每3000毫秒自动在某个角落生成一个新敌人。这个间隔要讲究太短了玩家没喘息时间太长了关卡拖着不过一般设2到3秒。生成时给敌人一个InvincibleTime1000的闪烁期用DateTime记录出生时刻1秒内碰撞不生效。这个小操作直接决定了一个“能不能玩”的细节——如果出生点附近有子弹敌人一刷新就死玩家会认为游戏有bug而不是觉得AI有问题。5. 坦克大战源码避坑C#开发中最常见的四个翻车现场写C#控制类游戏能跑起来跟能稳定玩是两码事。下面四条是从实际调试里反复走出来的翻车现场几乎每个“坦克大战源码”里都能见到一条条说清楚现象、原因和怎么救。5.1 按键只能按一次KeyDown的触发机制与键状态表现象按住W坦克不动松开再按或者按一下动一下。原因把移动逻辑直接写在KeyDown事件里。Windows的键盘消息机制下按住键不放只会触发一次KeyDown之后要等系统键盘重复延迟约500ms才开始连续触发而且连续触发是打字用的字符重复不是游戏要的“按住持续移动”。这段逻辑是控制类游戏与普通界面程序最根本的思维差异前者面向“状态”后者面向“事件”。解决KeyDown里只记录键到HashSet移动逻辑交给帧循环查表就是上面第2章那段代码。判断一个源码是不是老手写的看这一处就够了如果KeyDown里直接写了_tank.Move()大概率是新手写法如果出现了_pressedKeys.Add(e.KeyCode)说明作者理解控制类游戏的输入模型。5.2 松键后坦克还在跑KeyUp没写对焦点又被抢现象按W坦克走松开W还在走鼠标点一下窗体才停下。原因KeyDown时往状态表里Add了键但KeyUp对应的Remove没写或者KeyUp订阅在了别的控件上。还有一个WinForms特有的隐性坑窗体上如果放了Button控件点击Button后焦点会落到Button上窗体的KeyDown事件不再触发。这种情况在Unity里不会遇到但C#源码只要用了WinForms就躲不开。解决KeyUp里必须Remove窗体上尽量不放会抢焦点的控件或者把KeyPreview设成True让窗体能在控件之前先收到键盘事件。KeyPreview是Form上的一个布尔属性设置为True后键盘消息会先经过窗体的KeyDown/KeyUp再传递给焦点控件。很多新手不知道这个属性导致游戏逻辑和界面控件抢焦点。提示KeyPreviewtrue 只影响窗体收键盘消息的顺序不影响按键状态表的逻辑两件事配合使用。5.3 屏幕疯狂闪DoubleBuffered才是后悔药现象运行后坦克拖着残影砖墙闪成雪花画面一直在抖。原因每帧Tick都调用InvalidateOnPaint里直接用Graphics绘制同一块区域被反复擦除和重绘产生了闪烁。这是GDI绘制的经典问题和绘制的内容多少无关纯粹是刷新方式的问题。解决MainForm构造函数第一行设置DoubleBuffered true让系统先往内存位图绘图再一次性刷新到屏幕。如果这行还不够就在OnPaint里改用BufferedGraphicsContext做手动双缓冲。要注意DoubleBuffered是Form和Control上的受保护属性需要在窗体内部赋值写在外部代码里会报访问权限错误。这个属性就是WinForms的后悔药——一行代码解决八成闪屏问题代价是占用一点额外的内存位图对坦克大战这种小场景可以忽略。5.4 子弹穿砖移动步长大于墙体尺寸的穿模现象子弹隔一堵墙把墙后面的坦克打死了或者子弹从砖缝里斜着飞过去砖块没反应。原因帧间隔内物体位移量大于碰撞体尺寸两步之间矩形没有重叠IntersectsWith检测不到。这是所有2D游戏碰撞系统都会遇到的边界问题和C#本身无关但在控制类游戏里容易被放大因为玩家操作频率高子弹速度又往往比坦克快。解决把位移拆分成小步用循环粒度1像素或者计算运动扫过的矩形区域用“扫掠检测”。一个更省事的替代方案是限制最大速度坦克和子弹的步长都不得超过最小碰撞物厚度的一半。比如砖墙厚36像素那子弹步长最多18像素实际取4完全安全如果砖墙缩到20像素子弹速度还保持4穿模是迟早的事。这条约束会在下一章的验证清单里再次出现是所有碰撞系统的公共边界条件。6. 最后一块拼图存档读档、关卡扩展与控制类游戏的验证清单一个坦克大战能跑、能打、能通关距离“能交付给别人玩”还差两步进度保存和系统验证。进度保存的一个可靠做法是JSON序列化把需要保存的数据抽取成一个SaveData类。坦克坐标、当前关卡、总分、剩余敌人数四样足够几行代码就能搞定// 存档与读档的最小实现 public class SaveData { public int Level { get; set; } public int Score { get; set; } public int PlayerX { get; set; } public int PlayerY { get; set; } } // 保存 File.WriteAllText(save.json, JsonSerializer.Serialize(save)); // 读取 var save JsonSerializer.DeserializeSaveData(File.ReadAllText(save.json));这里有个细节要留意不要试图序列化TankEntity或者Bullet这类包含游戏逻辑的对象很多初学者序列化报错就是因为存了Rectangle、Graphics这类复杂对象。正确的做法是建一个纯数据的DTO类再从DTO“翻译”回实体。做控制类游戏时这个“数据与行为分离”的教训吃一次就长记性了。最后一发是验证清单。拿到一个坦克大战源码检查它像不像能交付的游戏下面这张表一格一格对照就行检查项手感标准参数参考移动响应按下到移动小于50ms帧循环Interval16方向连续性按住不抖、松键即停键状态表 KeyPreview碰撞可信度子弹不穿墙、不打自己人步长小于碰撞物厚度一半屏幕刷新无闪屏、无残影DoubleBufferedtrue射击节奏连发不溢出、不打断冷却时间大于等于150ms敌人动态有决策间隔、有出生保护AiInterval约200ms、出生无敌1秒其中“步长小于碰撞物厚度一半”这条可以直接当公式用砖墙36像素步长取2到4都安全如果砖墙缩到20像素坦克速度还保持4碰撞穿透几乎必然发生。这是我在实际项目里反复试出来的经验改参数时与其靠肉眼猜不如盯着屏幕上的调试信息来得直观。写到最后有个小习惯也分享一下我会在每辆坦克和每颗子弹上压一个Debug版的“碰撞盒显示开关”按F9就能把全部矩形的边界画出来。看起来是脏脏的辅助线但排查穿模和反弹异常的时候它就是我的后悔药。交付时开关默认是关的按F9看不见任何辅助线不影响正式观感。希望帮到你。本文还有配套的精品资源点击获取
返回列表