“枪火地牢”这个项目标题,我第一次看到是在一次 .NET 9 新特性交流的讨论串里。地牢射击、随机房间、满屏弹幕,再加上“基于 .NET 9 开发”这个标签,确实很容易勾起好奇心。很多人的第一反应是:用 C# 写游戏不是不行,但能撑起弹幕型地牢射击这种高密度、高频反馈的类型吗?
答案是可以,而且 .NET 9 版本下做起来比想象中顺手。这篇文章不打算只停留在“推荐一款游戏”的层面,我会把它当成一个真实的 .NET 9 游戏工程来拆解。你会看到地牢地图生成逻辑、子弹池设计、碰撞检测思路,还有哪些 .NET 9 特性在这个场景里真正能落地,哪些是宣传大于实际。无论你是想找一款能参考的项目,还是正准备用 .NET 9 做自己的 Roguelike 射击游戏,这篇内容都能给你一些可以直接拿去用的方案。
1. 地牢射击这个品类,为什么值得被单独拿出来说
1.1 玩法构成并不复杂,难的是“多重系统叠加后的稳定性”
先说地牢射击是什么。它通常不是传统意义上的 FPS,而是俯视角 + 房间制探索 + 随机地牢 + 高密度弹幕战斗。玩家的操作集中在移动、射击、翻滚闪避、切换武器这几个动作上,但每一层地牢都由大量房间拼接而成,敌人会按波次出现,弹幕轨迹随机且密集。这个品类代表性作品有《Enter the Gungeon》、《Nuclear Throne》这类游戏,玩法循环很清晰:进地牢 → 打房间 → 拿武器 → 去下一层 → 死亡或通关。
从技术角度拆开看,它实际由几个不同系统的叠加组成:
- 程序化地牢生成,每局地图布局、房间类型、宝箱位置都不能完全一样。
- 大量动态实体,子弹、敌人、掉落物、粒子效果可能同时存在几十到几百个。
- 规则紧凑的碰撞检测,玩家要能在密集弹幕中判断“哪一颗子弹会打到自己”。
- 武器系统天然适合做随机词条、元素属性、射击模式等抽象。
这种组合对性能的要求不在单颗子弹的渲染,而在“大量实体同时更新时的稳定性”。很多刚接触这个品类的开发者在原型阶段都会觉得不难,因为单房间打几个敌人,帧率毫无压力。一旦把弹幕密度提上来,再把房间换新、切层、加载资源串到一起,GC 卡顿和逻辑复杂度就会集中爆发。
1.2 .NET 9 过去那点争议,放到今天还成立吗
以前聊用 C# 写游戏,总绕不开几个老问题:GC 停顿、打包体积、启动速度、生态是不是只能绑死在 Unity 上。这些争论有一部分是历史遗留。.NET 9 在底层性能上做了不少实际改进,尤其是和游戏开发关系较紧的几个方面。
一个值得关注的是 JIT 和代码质量。.NET 9 对 Arm64 和 x64 的向量化支持更完善,像 Vector2、Vector3、Matrix 这类矩阵运算能比较高效地被编译成 SIMD 指令。地牢射击要频繁做向量运算,比如计算子弹方向、判断玩家与敌人的相对位置、做朝向插值,这些操作在 .NET 9 上能获得接近本地的性能,而不是被抽象成一个个函数调用。
另一个是 Random 相关 API 的补全。.NET 9 把原来分散的随机数生成逻辑做了整理,Random.Shared 的线程安全语义更清晰,还引入了 System.Random 的若干扩展,对程序化生成非常友好。地牢布局、武器掉落、敌人词条全都要靠随机数驱动,一个稳定且高效的随机源能省掉很多心智负担。
还有 NativeAOT 的成熟。过去 .NET 游戏要发给玩家,经常被吐槽“要装运行时”“启动慢”。NativeAOT 可以把应用编译成原生二进制,不需要目标机器预装 .NET 运行时。.NET 9 对这个模式的支持已经达到生产可用,虽然游戏开发里的反射使用场景需要额外适配,但至少发行路径不再是硬伤。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 拿 .NET 9 搭地牢射击工程,框架和目录结构怎么选
2.1 MonoGame 在这种项目里比 Unity 更贴合需求
做 .NET 9 的地牢射击,最常见的框架选项是 MonoGame、Godot(支持 C#),或者直接用 Unity 但把后端切到 .NET 9。
个人观点,如果你想要“对底层有足够掌控力”,MonoGame 是更合适的选择。原因很实在。Unity 自带组件系统和场景管理器,确实能极大提高单机关卡开发效率,但它的数据驱动方式和 .NET 9 新特性的结合是隔着引擎层的。你很难在 Unity 里精准控制对象池、内存布局,甚至编译器版本都受限于编辑器的 Mono 配置。
MonoGame 更像是一个被封装好的底层库,它提供窗口、图形设备、资源导入这些基础设施,但游戏主循环是完整暴露给你的。这意味着你可以直接用 .NET 9 写整个战斗逻辑,自己想要多少控制权就有多少控制权。子弹池能自己写,房间生成算法能自己写,更新顺序能自己调整,而不是被迫迁就引擎的默认行为。
Godot 的 C# 支持现在已经比较完善,但它的场景树和节点系统仍然会引导你往“节点组合”的思路上靠。让一个地牢射击项目获得类似的自由,往往需要做不少绕行。
2.2 推荐的项目分层和 csproj 起步配置
真实项目里我不喜欢把所有脚本压在一起,因为地牢射击至少包含“地图生成、实体战斗、物品掉落、表现层”四个独立领域。分享一个我用来搭 .NET 9 游戏时的推荐目录结构,这种拆分在开发中期会帮你省掉大量查找时间:
code复制GunfireDungeon/
├── GunfireDungeon.sln
├── src/
│ ├── GunfireDungeon.Core/ // 纯 C# 核心逻辑,不依赖显示层
│ │ ├── Dungeon/
│ │ ├── Combat/
│ │ ├── Items/
│ │ └── Random/
│ ├── GunfireDungeon.MonoGame/ // 平台层,MonoGame 游戏入口
│ │ ├── Content/
│ │ ├── Renderers/
│ │ └── Game1.cs
│ └── GunfireDungeon.Tests/ // xUnit 测试,主要测地图生成和随机算法
└── tools/
GunfireDungeon.Core 不引用 MonoGame,只写地图生成、武器数值、敌人 AI 状态这些不关心渲染的逻辑。平时开发可以用单元测试直接跑核心逻辑,不必每次启动游戏,这样做收益极大。地牢生成这种适合做数据回归测试的逻辑尤其受益。
一个可用的 .csproj 起步配置大致长这样:
xml复制<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<RollForward>Major</RollForward>
<PublishReadyToRun>false</PublishReadyToRun>
<TieredCompilation>false</TieredCompilation>
<AssemblyName>GunfireDungeon</AssemblyName>
<RootNamespace>GunfireDungeon</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MonoGame.Framework.DesktopGL" Version="3.8.*" />
<PackageReference Include="MonoGame.Content.Builder.Task" Version="3.8.*" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\GunfireDungeon.Core\GunfireDungeon.Core.csproj" />
</ItemGroup>
</Project>
注意 PublishReadyToRun 和 TieredCompilation 我默认关掉。在游戏开发阶段开着分层编译会影响冷启动后的前几帧表现,对调试主机不太友好。到发布阶段再根据目标平台做权衡,而不是一开始就背上这些包袱。
MonoGame 的 Content.Builder.Task 会在编译时把贴图、音效、字体转成引擎可读格式,这属于传统但稳定的方式,所有素材会集中打成一个 Content.mgcb 项目,管理起来很清楚。
3. 地牢随机地图到底怎么生成,直接给一套能跑的 BSP 思路
3.1 种子随机与方块分割:为什么不是“每局完全不一样”就完事
地牢生成最怕两个极端:太随机导致地图没有结构感,太平铺直叙导致无聊。成熟做法是先确定“全局种子”,然后基于种子让房间生成、敌人分布、武器掉落共用一条确定性随机链。这样同一局游戏可以回放,出 bug 也能用种子复现。
BSP(二叉空间分割)是比较适合地牢射击的算法。思路是把整张大图不断划分成两个子区域,每一轮随机选一个方向切一刀,切到最小房间尺寸为止。每个叶子节点会成为房间候选,然后在相邻区域之间开出门洞并生成走廊。
下面是完整可运行的 C# 骨架:
csharp复制public sealed class BspDungeonGenerator
{
private readonly Random _random;
public BspDungeonGenerator(int seed)
{
_random = new Random(seed);
}
public DungeonLayout Generate(int width, int height, int minRoomSize)
{
var root = new BspNode(new Rectangle(0, 0, width, height));
Split(root, minRoomSize, maxDepth: 6);
var layout = new DungeonLayout(width, height);
AssignRooms(root, layout);
ConnectRooms(root, layout);
return layout;
}
private void Split(BspNode node, int minSize, int maxDepth)
{
if (maxDepth == 0) return;
if (node.Rect.Width < minSize * 2 || node.Rect.Height < minSize * 2) return;
bool splitVertical = node.Rect.Width >= node.Rect.Height
? _random.NextDouble() < 0.6
: _random.NextDouble() < 0.4;
Rectangle firstRect, secondRect;
if (splitVertical)
{
int splitX = _random.Next(node.Rect.Left + minSize, node.Rect.Right - minSize);
firstRect = new Rectangle(node.Rect.Left, node.Rect.Top, splitX - node.Rect.Left, node.Rect.Height);
secondRect = new Rectangle(splitX, node.Rect.Top, node.Rect.Right - splitX, node.Rect.Height);
}
else
{
int splitY = _random.Next(node.Rect.Top + minSize, node.Rect.Bottom - minSize);
firstRect = new Rectangle(node.Rect.Left, node.Rect.Top, node.Rect.Width, splitY - node.Rect.Top);
secondRect = new Rectangle(node.Rect.Left, splitY, node.Rect.Width, node.Rect.Bottom - splitY);
}
node.Left = new BspNode(firstRect);
node.Right = new BspNode(secondRect);
Split(node.Left, minSize, maxDepth - 1);
Split(node.Right, minSize, maxDepth - 1);
}
}
这里没有用到任何 .NET 9 新增的特殊 API,但你会在后边的代码里发现一个真正有用的新成员:Random.Shared 很容易被误用,如果是在异步加载线程里绕开了主线程,理解随机状态分离非常重要。项目里我更倾向为“地牢生成”单独创建 SeedRandom 实例,而不是到处用 Random.Shared,这样每局种子才能完整重现。
3.2 房间连接与走廊的坑:你实际需要处理的是“门”而不是“墙”
分割完叶子节点后,通常做法是把每个叶子区域的房间缩一圈,在内部生成一扇门,然后递归连接左右子树。最容易踩坑的点是:人眼看上去,“走廊应该穿过墙体”,但实际地牢射击里,走廊宽度至少要容许两个角色并行闪避,否则近战敌人堵在门口时玩家毫无操作空间。
在设计连通时我建议做两个额外动作。
第一,记录连通层级。连接时不要把根节点左右子区域简单用一条直线串起来,而是先自底向上连接兄弟区域,再回到上层,否则地图会出现大量死胡同。第二,把所有门洞位置单独做成一个 List<DoorInfo>。玩家进入新房间前有一道短暂的门动画,敌人不会提前穿墙索敌,这些都依赖门信息而不是靠房间边界去猜测。
房间内的敌人生成也依赖房间元数据。假设每个 BspNode 都存储了 RoomType(普通战斗房、宝库房、精英房),之后布置敌人波次就会容易很多:
csharp复制public enum RoomType
{
Combat,
Treasure,
Elite,
Boss,
Shop
}
房间生成完成后,把类型和敌人模板表通过权重随机匹配。这个阶段只设置“生成规则”,不真正生成敌人实体,等玩家进入房间前的一瞬间再实例化。这样能避免整层地牢所有敌人同时活跃,性能压力小很多。
4. 开枪与闪避的手感,靠的是对象池和紧凑的碰撞判定
4.1 子弹池不是优化技巧,而是这类游戏的基本框架
地牢射击里的弹幕密度随层数快速上涨,一屏同时存在 200 到 500 颗子弹是常态。如果每颗子弹都是 new Bullet(),每一帧都要创建销毁,再好的 GC 也扛不住持续性的内存压力。
正确做法是预分配一个对象池。子弹被“发射”时从池里取,命中或被清除时还回池里。这个思路几乎所有游戏都用,但很多项目是先把功能跑通再补池,这会在后续层数变多时造成灾难性回潮。
下面给一个紧凑的子弹池范例:
csharp复制public sealed class BulletPool
{
private readonly Stack<Bullet> _pool = new(1024);
public Bullet Rent(Vector2 position, Vector2 velocity)
{
Bullet bullet = _pool.Count > 0 ? _pool.Pop() : new Bullet();
bullet.Initialize(position, velocity);
bullet.IsActive = true;
return bullet;
}
public void Return(Bullet bullet)
{
bullet.IsActive = false;
bullet.Velocity = Vector2.Zero;
_pool.Push(bullet);
}
}
真实项目里可能会把池子泛型化,但只做子弹时手写一个类反而更直观。你还需要一个 BulletManager 在每帧更新所有活跃子弹,并且统一回收“已脱离房间边界或生命周期结束”的实例。
有一个容易被忽略的细节:子弹对象本身是否包含渲染资源。子弹贴图通常是同一张,所以不要在 Bullet 实例里保存 Texture2D,而是让渲染层查静态表 TextureBank.BulletTextures[弹种]。这样多个子弹实例共享一份贴图引用,内存占用和 GC 压力都会小很多。
4.2 圆形碰撞、向量点乘和“看起来打中了”的判定顺序
地牢射击对碰撞判定的精度要求其实不高,但要快。因为子弹密集时,玩家可能同时要和几十颗子弹做碰撞检测。
Bullet 和 Player 都优先用圆形碰撞盒。判断两个圆是否相交只需要比较距离平方和半径平方之和,避开 Math.Sqrt,节省大量浮点运算:
csharp复制public static bool CircleHit(Vector2 aPos, float aRadius, Vector2 bPos, float bRadius)
{
float dx = aPos.X - bPos.X;
float dy = aPos.Y - bPos.Y;
float r = aRadius + bRadius;
return dx * dx + dy * dy <= r * r;
}
子弹命中玩家要给两层碰撞逻辑。第一层是“实体碰撞”,判定玩家本体是否被子弹击中,用于扣血和触发伤害。第二层是“近战反弹判定”,某些武器或翻滚技能会弹开子弹,这时需要把子弹的存活状态改成敌人可伤害或者直接消除。
一个实战中经常犯的错误是把“玩家受击判定”放在子弹更新里逐个检测。更好的做法是每帧只让活跃子弹和少数字段检测一次,并且优先检测“距离玩家最近的子弹”。你可以先做粗筛,比如只检测玩家周围半径两个屏幕内的子弹,再做精确的圆形相交。很多性能问题不是出在数学运算上,而是因为在过大的范围里做了太多次无意义循环。
为了让玩家觉得“有被击中”,游戏通常还需要提供受击闪烁、屏幕震动、短暂的子弹时间。这些不涉及真实伤害逻辑,却直接影响手感。
5. .NET 9 的性能红利和地牢射击的实际边界
5.1 用 List 还是数组,以及那些会让你掉帧的隐藏分配
地牢射击场景里,玩家的武器、敌人、掉落物列表都处于高频增删状态。List<T> 是动态数组,在需求超过内部容量时会触发扩容,产生旧数组垃圾。子弹数量可能几百上千,扩容次数一多,GC 压力就上来了。
建议直接采用混合策略。所有“帧内活跃实体”使用自定义循环数组,不支持中间删除,删除时把最后一个元素挪到待删位:
csharp复制public sealed class EntityArray<T> where T : struct
{
private T[] _items = new T[256];
private int _count;
public ref T this[int index] => ref _items[index];
public void Add(in T item)
{
if (_count >= _items.Length)
{
Array.Resize(ref _items, _items.Length * 2);
}
_items[_count++] = item;
}
public void RemoveAtSwap(int index)
{
_items[index] = _items[_count - 1];
_count--;
}
}
因为地牢射击的更新顺序并不依赖实体排列顺序,所以“交换删除”完全可行。像 Bullet、Enemy、Pickup 这类对象用结构体数组存储,配合 Span<T> 遍历时可以获得很好的缓存友好性和确定的内存占用。
这里有个 .NET 9 值得一提的优化:CollectionsMarshal.SetCount 和 GetUnsafeList 可以在不触发清空的情况下预先填充列表。不过个人建议别在核心战斗逻辑里用太多“不安全”手段,它容易把代码可维护性拖垮。用上面那种简单结构体数组,已经能覆盖绝大多数子弹和敌人规模。
5.2 GC 停顿很难彻底消除,但你可以让它发生在“看不见的时候”
讨论 .NET 游戏,逃不开一个话题:GC 卡顿。坦白讲,就算用了对象池,游戏里仍有零星字符串拼接、LINQ 枚举和临时产生的小对象。GC 停顿不可能归零,关键是要让停顿发生在不被注意的时机。
地牢射击和开放世界游戏不同,它有明确的房间切换节点。玩家迈进门洞的一刻、下一波敌人加载前,都是自然的 GC 窗口。你可以主动调用 GC.Collect() 强制回收吗?在 Web 服务端是被劝退的行为,但在这种“关卡切换的确定性时点”,主动执行一次反而能显著减少战斗过程中的抖动。配合 GC.TryStartNoGCRegion 只适合短暂临界区,不适合扩展成大范围做法。
实战中我更喜欢用另一个思路:把“每局遗留下来的临时数据”主动丢弃。比如一次地牢循环结束后,把地图对象、房间列表、掉落物表引用全部置空,等待下一次进入时重新分配。这样既不会产生高频垃圾,也不需要依赖 GC 的自动节奏。
5.3 真要追求极限,NativeAOT 和 ReadyToRun 怎么选
发布阶段,你面对的不只是核心代码,还有 Windows、Linux、macOS 多个平台的玩家。.NET 9 的 NativeAOT 可以生成无需运行时即可执行的原生文件,但 MonoGame 的 Content Pipeline 涉及不少反射和动态程序集加载逻辑,盲上 AOT 大概率会挂。
诚实的建议是:先不要默认上 NativeAOT。优先使用 ReadyToRun(PublishReadyToRun=true)加单文件发布。R2R 能减少 JIT 预热时间,同时保持大部分反射逻辑的可用性。毕竟地牢射击更在意的是稳定帧率,而不是首启那几百毫秒。
如果确实想试 NativeAOT,必须准备好为 MonoGame 的反射调用写 Rd.xml 运行时指令文件,把纹理类型、程序集清单提前声明。这属于吃力但有一定折腾价值的优化,想清楚收益再决定。
6. 做出一款能推荐出去的 .NET 9 地牢射击,完成度比堆功能更重要
6.1 从“地图能跑”到“游戏好玩”,中间差的是体验闭环
如果只是技术上跑通地图生成和子弹碰撞,那游戏还缺一个“让人愿意继续玩”的动力系统。地牢射击的核心驱动力是:玩家每一局都能获得不同武器,武器之间能形成 build,build 会实质改变游戏策略。
把这套做成数据驱动模型会很自然。为每把武器定义一个记录类型:
csharp复制public sealed record WeaponDefinition
{
public string Id { get; init; }
public WeaponType Type { get; init; }
public float FireRate { get; init; }
public int Damage { get; init; }
public int BulletCount { get; init; }
public ElementType Element { get; init; }
public float SpreadAngle { get; init; }
}
.NET 9 的 record 类型在游戏数值对象上很合适。它的值语义让调试更容易,with 表达式可以优雅地生成随机词条派生武器:
csharp复制var legendary = baseWeapon with
{
BulletCount = baseWeapon.BulletCount + 2,
Damage = (int)(baseWeapon.Damage * 1.6f),
Element = ElementType.Fire
};
不需要写一堆手动的 setter,也不需要为了改两个字段就 new 一个对象。这比早期 C# 时代爽了不止一个档次。
建议在游戏流程中设计三层复杂度:
- 玩家在每一层有“主武器 + 副武器”两个槽位。
- 武器词条影响子弹数量、伤害、元素、弹道。
- 每层地牢结算时提供一次武器升级选择,把“当前 build 强不强”的反馈做得足够清晰。
6.2 想做给玩家玩,必须提前处理的启动与兼容细节
游戏写得再好,也要能顺利在别人电脑上跑起来。分享几个 .NET 游戏发布时常见的坑。
第一是“图标和文件版本”。很多开发者在调试阶段不设置应用图标、版本号、产品名称,发布后玩家看到的是一片空白或“GunfireDungeon.exe”这种没有辨识性的名字。建议在构建配置里加:
xml复制<ApplicationIcon>Assets\icon.ico</ApplicationIcon>
<Version>0.1.0</Version>
<Company>YourStudio</Company>
<Product>Gunfire Dungeon</Product>
第二是“贴图加载路径”。MonoGame 的 Content Pipeline 生成的 .xnb 文件和开发机上能找到,但在发布目录里如果 Content 文件夹没放对,玩家启动时会直接黑屏闪退。发布前一定要在干净环境跑一次 from-scratch 启动测试。
第三是保存数据路径。地牢射击一定会记录解锁进度、玩家设置、最高层数。不要把这些写到程序安装目录,建议用 Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) 拼一层自己的应用目录。否则 Windows 上默认安装在 Program Files 时,写入权限直接红牌。
最后是“配置文件的损坏恢复”。如果玩家中途退出或系统崩溃,存档文件处于半写状态,下次启动就可能读不出来。给存档写 .tmp 文件再改名覆盖,是目前性价比最高的方案。.NET 9 的 File.Move(source, dest, overwrite: true) 在同一个卷上基本是原子操作,能有效避免存档损坏。
7. 用 .NET 9 开发地牢射击,一些同路人不会写在文档里的判断
最后说点可能比较“个人向”的经验。如果你问我要不要用 .NET 9 去复刻一个《枪火地牢》这类项目,我的态度是鼓励,但建议你给自己界定一个小的范围。地牢射击玩法看起来简单,真正做起来牵扯地图生成、武器平衡、敌人 AI、视觉表现、音效反馈,领域跨度比想象中大很多。
对于一个想从 .NET 9 入门游戏开发的人,我会建议这样分级:
- 第一版只做 5 个房间、3 种武器、1 个 Boss。
- 第二版加入种子随机和词条派生,确保每局体验不同。
- 第三版再考虑发布流程、存档和成就系统。
这个项目如果认认真真做好前两个版本,你已经能把 .NET 9 的代码组织、MonoGame 的渲染管线和性能优化都摸一遍了。我就是这么一路调试过来的,最开始的版本照样在弹幕密集时掉帧,地图生成也会出现卡住玩家的死胡同。但正因为这些问题都出在可控的代码层里,每次定位、复现、修复,都会让下一个版本往前推一大步。
地牢射击这片领域在 .NET 生态里做的人不算多,但框架的成熟度、现代 C# 的表达力、还有像 record、对象池、BSP 算法这些能被精确使用的工具,其实都已经铺好了路。剩下的是你能不能把每颗子弹都变成值得玩家去躲的体验。
