免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

MonoGame实战:一整天开发2D射击游戏的架构与实现

MonoGame实战:一整天开发2D射击游戏的架构与实现 从标题就能闻到一股“再战考研”的狠劲用 MonoGame 花一整天搓出一款射击游戏视频只做实机演示代码暂时不开源。观众一边看一边问“这游戏是人能玩的”——这个评价放在独立游戏开发里其实已经算一种肯定至少说明玩法有难度、有挑战性不是随便点点就通关的 Demo。这篇文章就把这个项目的技术路线拆开讲清楚MonoGame 到底是什么、一整天能做出什么程度的射击游戏、核心玩法是怎么实现的、实机演示要重点看哪些指标、为什么现阶段不开源以及后续如果要开源或继续迭代应该先补哪些东西。如果你也在用 C# 写游戏或者正准备从 Unity 转到更底层的框架这篇内容可以给你一份完整的参考。1. MonoGame 项目核心能力速览项目项说明项目类型2D 俯视角/横版射击游戏具体视角以实机演示为准开发框架MonoGame 3.8.x基于 .NET/C#开发周期一整天集中开发达到可实机演示状态核心玩法玩家操控角色移动、瞄准、射击敌人刷新与追击得分/生存判定技术重点游戏循环、精灵绘制、碰撞检测、输入处理、对象池、游戏状态管理开源状态暂时不开源考研结束后计划整理发布适用读者C# 开发者、MonoGame 学习者、独立游戏开发者、Unity 转底层框架的开发者运行环境Windows / Linux / macOS需要 .NET SDK 或桌面运行时从材料看这个项目的核心价值不在于画面多精致、系统多复杂而在于一个人用 MonoGame 在极短时间内完成了一款“能玩、有反馈、有挑战”的完整游戏闭环。观众说“不是人能玩的”侧面说明游戏难度曲线和敌人设计是有压迫感的。2. 适用场景与使用边界2.1 这个项目适合谁第一类是 MonoGame 入门者。这个项目用一天完成意味着它踩的坑和总结的经验非常适合作为学习路线参考怎么组织 Game1.cs、怎么拆分更新和绘制、怎么处理子弹和敌人的生命周期。第二类是 C# 开发者。Unity 用多了很多人对引擎的依赖会掩盖对底层渲染循环、资源加载、生命周期管理的理解。MonoGame 项目能把“游戏循环”这件事拉回最基础的状态每一帧做什么都由你控制这对理解游戏引擎原理很有帮助。第三类是独立游戏开发者。特别是那种想验证“一个玩法点子是否好玩”的人。MonoGame 启动快、结构简单、打包小适合做玩法原型验证。2.2 不适合什么场景如果你需要快速出 UI、需要可视化场景编辑、需要现成的物理引擎MonoGame 不是最优选择。它没有 Unity 那样的 Inspector 编辑器没有 Godot 的场景树也没有 Cocos 的跨平台可视化编辑器。它在 UI、动画、物理、音效、特效方面提供的都是基础能力大部分需要手写。另外如果目标是做商业手游且需要广告 SDK、内购 SDK、推送等MonoGame 的集成成本明显高于 Unity 和 Cocos Creator因为这些平台的生态和插件体系更成熟。2.3 版权、隐私与合规边界虽然这个项目暂未开源但后续如果发布源码需要注意游戏中使用的美术素材、音效素材、字体是否拥有合法授权。如果是从免费素材站下载的要保留授权信息C端商用要对照许可证要求。如果后续加入排行榜、云存档、多人联机需要做账号体系和用户数据保护不能明文存储用户信息。如果游戏玩法或角色设计与某个已上线产品高度相似要注意知识产权风险。即使开源也要明确许可证MIT 允许商用和修改GPL 要求后续代码也必须开源CC-BY-NC 则限制商业使用。建议选择适合自己后续计划的许可证。3. MonoGame 本地开发环境准备3.1 操作系统与运行时MonoGame 是基于 .NET 的游戏框架支持 Windows、Linux、macOS。最稳妥的开发组合是Windows 10/11 Visual Studio 2022 或 JetBrains Rider.NET 6.0 SDK 或更高版本MonoGame 3.8.1 或更新版本也可以使用 VS Code但需要手动创建项目结构体验不如 Visual Studio 的 MonoGame 模板方便。3.2 安装 MonoGame 模板MonoGame 提供官方项目模板安装之后可以直接在 Visual Studio 中创建项目dotnet new install MonoGame.Templates.CSharp安装完成后可以使用命令行创建项目dotnet new mgdesktopgl -o MyShootingGame cd MyShootingGame dotnet restore dotnet run其中mgdesktopgl是桌面版 OpenGL 项目模板。如果你要做跨平台还可以使用mgandroid、mgios等模板但桌面原型验证用mgdesktopgl足够。3.3 MonoGame 项目结构创建完成后项目结构如下MyShootingGame/ ├── Content/ │ ├── Content.mgcb │ └── bin/ ├── Game1.cs ├── Program.cs ├── MyShootingGame.csproj └── Icon.icoContent.mgcb是 MonoGame Content Pipeline 的配置文件用于管理纹理、字体、音频、着色器等资源。.csproj文件里需要包含内容管道的引用。3.4 开发机器的硬件要求MonoGame 2D 游戏性能开销很小不需要独立显卡。CPU 4 核以上内存 8GB 以上集成显卡即可流畅跑 2D 精灵和基本粒子效果。如果你要做 3D则需要独立显卡但这篇项目是从 2D 射击游戏出发所以硬件门槛很低。4. 游戏架构与核心代码实现一整天开发一款射击游戏架构设计必须足够简单高效。项目如果拆得太细时间根本不够用。这里给出一个最实用的分层思路4.1 Game1.cs 主入口设计MonoGame 项目的主类继承Game类核心方法是Initialize、LoadContent、Update、Draw。一天版射击游戏的 Game1.cs 大概是这个骨架public class Game1 : Game { private GraphicsDeviceManager _graphics; private SpriteBatch _spriteBatch; private Player _player; private EnemyManager _enemyManager; private BulletManager _bulletManager; private GameState _gameState; public Game1() { _graphics new GraphicsDeviceManager(this); Content.RootDirectory Content; IsMouseVisible true; } protected override void Initialize() { _player new Player(); _enemyManager new EnemyManager(); _bulletManager new BulletManager(); _gameState GameState.Playing; base.Initialize(); } protected override void LoadContent() { _spriteBatch new SpriteBatch(GraphicsDevice); _player.LoadContent(Content); _enemyManager.LoadContent(Content); _bulletManager.LoadContent(Content); } protected override void Update(GameTime gameTime) { if (_gameState GameState.Playing) { _player.Update(gameTime); _bulletManager.Update(gameTime); _enemyManager.Update(gameTime, _player.Position); CheckCollisions(); } base.Update(gameTime); } protected override void Draw(GameTime gameTime) { GraphicsDevice.Clear(Color.CornflowerBlue); _spriteBatch.Begin(); _enemyManager.Draw(_spriteBatch); _bulletManager.Draw(_spriteBatch); _player.Draw(_spriteBatch); _spriteBatch.End(); base.Draw(gameTime); } }这里最核心的思路是把玩家、敌人、子弹都拆成独立管理类Game1 只负责调度和碰撞检测。这样的好处是一整天开发过程中你可以并行处理多个模块而不用在同一个类里反复横跳。4.2 玩家控制与移动射击游戏最基本的操作是移动和射击。玩家类的 Update 方法可以这样处理键盘输入public class Player { public Vector2 Position; public float Speed 300f; private Texture2D _texture; private KeyboardState _currentKeyboardState; private KeyboardState _previousKeyboardState; public void LoadContent(ContentManager content) { _texture content.LoadTexture2D(player); } public void Update(GameTime gameTime) { _previousKeyboardState _currentKeyboardState; _currentKeyboardState Keyboard.GetState(); float deltaTime (float)gameTime.ElapsedGameTime.TotalSeconds; Vector2 direction Vector2.Zero; if (_currentKeyboardState.IsKeyDown(Keys.W)) direction.Y - 1; if (_currentKeyboardState.IsKeyDown(Keys.S)) direction.Y 1; if (_currentKeyboardState.IsKeyDown(Keys.A)) direction.X - 1; if (_currentKeyboardState.IsKeyDown(Keys.D)) direction.X 1; if (direction.Length() 0) direction.Normalize(); Position direction * Speed * deltaTime; Position Vector2.Clamp(Position, Vector2.Zero, new Vector2(1920, 1080)); } public bool IsShooting() { return _currentKeyboardState.IsKeyDown(Keys.Space) _previousKeyboardState.IsKeyUp(Keys.Space); } }重点说明几个细节deltaTime必须乘上否则游戏速度会和帧率挂钩高帧率玩家移动更快。方向向量要Normalize()否则斜向移动会比直向移动快约 1.414 倍。Vector2.Clamp控制玩家不出界。4.3 子弹系统与对象池射击游戏的子弹如果每次发射都新建对象一整天做完的游戏在后期弹幕密集时会出现明显的 GC 卡顿。最简单的优化方式是对象池。public class BulletManager { private ListBullet _activeBullets new ListBullet(); private QueueBullet _bulletPool new QueueBullet(); private Texture2D _bulletTexture; private float _bulletSpeed 600f; public void LoadContent(ContentManager content) { _bulletTexture content.LoadTexture2D(bullet); } public void SpawnBullet(Vector2 position, Vector2 direction) { Bullet bullet; if (_bulletPool.Count 0) { bullet _bulletPool.Dequeue(); } else { bullet new Bullet(); } bullet.Initialize(_bulletTexture, position, direction * _bulletSpeed); _activeBullets.Add(bullet); } public void Update(GameTime gameTime) { float deltaTime (float)gameTime.ElapsedGameTime.TotalSeconds; for (int i _activeBullets.Count - 1; i 0; i--) { _activeBullets[i].Update(deltaTime); if (_activeBullets[i].IsOutOfBounds(new Rectangle(0, 0, 1920, 1080))) { _bulletPool.Enqueue(_activeBullets[i]); _activeBullets.RemoveAt(i); } } } public void Draw(SpriteBatch spriteBatch) { foreach (var bullet in _activeBullets) { bullet.Draw(spriteBatch); } } }这个代码的关键点是子弹池用QueueBullet缓存已经飞出屏幕的子弹对象。更新循环从列表尾部向前遍历避免 RemoveAt 对前方元素索引的影响。超出屏幕边界的子弹回收进池子不销毁不触发 GC。观众吐槽“不是人能玩的”很可能是子弹密度太高或者敌人刷新位置和速度设计得比较激进。但这恰好证明对象池在弹幕密集场景下表现稳定。4.4 敌人 AI 与刷新逻辑一整天版本不需要复杂的 AI。最简单的敌人行为可以分成三种直线追击、固定方向移动、蛇形移动。public class Enemy { public Vector2 Position; public float Speed; public int HP; public EnemyType Type; private Texture2D _texture; private float _time; private Vector2 _baseDirection; public void LoadContent(ContentManager content) { _texture content.LoadTexture2D(enemy); } public void Update(GameTime gameTime, Vector2 playerPosition) { float deltaTime (float)gameTime.ElapsedGameTime.TotalSeconds; _time deltaTime; switch (Type) { case EnemyType.Chaser: Vector2 direction playerPosition - Position; if (direction.Length() 0) direction.Normalize(); Position direction * Speed * deltaTime; break; case EnemyType.Horizontal: Position _baseDirection * Speed * deltaTime; break; case EnemyType.Sinusoidal: float sineOffset MathF.Sin(_time * 4f) * 20f; Position new Vector2(_baseDirection.X * Speed * deltaTime, sineOffset * deltaTime); break; } } }敌人刷新需要控制节奏最简单的方式是时间触发private float _spawnTimer; private float _spawnInterval 1.5f; public void Update(GameTime gameTime, Vector2 playerPosition) { _spawnTimer (float)gameTime.ElapsedGameTime.TotalSeconds; if (_spawnTimer _spawnInterval) { _spawnTimer 0f; SpawnEnemy(); } foreach (var enemy in _activeEnemies) { enemy.Update(gameTime, playerPosition); } }“不是人玩的”可能就是因为敌人数量随时间线性增长但玩家武器没有相应成长。这个问题后面可以靠武器升级系统或道具掉落来缓解。4.5 碰撞检测2D 射击游戏的碰撞检测不需要复杂的物理引擎最简单的 AABB轴对齐包围盒就够了private void CheckCollisions() { Rectangle playerBounds _player.GetBounds(); // 玩家子弹 vs 敌人 foreach (var bullet in _bulletManager.GetActiveBullets()) { Rectangle bulletBounds bullet.GetBounds(); Enemy hitEnemy _enemyManager.GetEnemies() .FirstOrDefault(e e.GetBounds().Intersects(bulletBounds)); if (hitEnemy ! null) { hitEnemy.HP - bullet.Damage; bullet.Deactivate(); if (hitEnemy.HP 0) { _score hitEnemy.ScoreValue; _enemyManager.RemoveEnemy(hitEnemy); } } } // 敌人 vs 玩家 foreach (var enemy in _enemyManager.GetEnemies()) { if (enemy.GetBounds().Intersects(playerBounds)) { _gameState GameState.GameOver; } } }这里要注意的是如果子弹飞行速度特别快比如每帧移动 20 像素以上当子弹和敌人都是薄片时上一帧没碰到、下一帧穿透就会出现子弹穿过敌人的现象。解决方式有两种使用光线投射计算上一帧位置和当前位置之间的线段是否和敌人的包围盒相交。限制最大速度不要让子弹的速度超过敌人尺寸的一半。一整天版本可以先不处理高速穿透但如果观众反馈“子弹打不到人”就要优先排查这个问题。5. 实机演示效果分析5.1 帧率与流畅度2D 射击游戏在 DesktopGL 模板下使用 OpenGL 渲染6000 个精灵以内基本可以保持 60 FPS。如果弹幕密集后出现掉帧优先排查以下问题是否有大量new对象导致 GC 频繁。是否对所有子弹做了不必要的Sort或OrderBy。纹理是否使用SpriteBatch.Begin和End之间的重复状态切换。是否在 Update 中读取了磁盘资源。从这台演示机的预期表现来说只要对象池逻辑正确密集弹幕下的帧率衰减应该不明显。5.2 资源占用MonoGame 桌面版的资源占用非常低。2D 游戏在原型阶段的 CPU 占用一般在单核 20% 到 40%内存占用通常在 300MB 到 800MB 之间具体取决于纹理数量、音频大小和对象数量。这里不标注准确数值因为需要以实机测试为准但可以确定的是MonoGame 原型的资源开销远低于同规模 Unity 项目。5.3 操作手感与难度曲线观众说“不是人能玩的”其实涉及一个核心设计问题操作手感的值是否匹配敌人的攻击密度。常见问题有三个玩家移动速度太低无法躲避敌人。子弹射速太慢清怪效率跟不上刷新速度。敌人碰撞体积偏大即使玩家视觉上没碰到判定也触发了。这三类问题都需要通过大量实机试玩来调整一天开发时间很难做到精细调参所以观众的反馈反而是继续优化的宝贵数据。6. 为什么暂时不开源标题里写得很清楚再战考研暂时不开源。这个决定本身是合理的。对个人开发者来说开源不只是把代码传上去那么简单还涉及以下成本需要整理项目结构删除硬编码的本地路径。需要编写 README说明运行环境、操作方式和已知问题。需要确认素材授权避免上传后产生版权纠纷。需要处理 Issue尤其是初学者的问题可能会占大量时间。考研复习阶段这些成本都很难负担。所以“暂时不开源”不是坏事反而是对项目负责的表现。从另一个角度看这个项目具备开源价值的点在于代码量小结构清晰适合初学者阅读。覆盖了对象池、碰撞检测、输入处理、游戏状态管理等经典主题。展示了 MonoGame 中一个完整游戏项目的最小闭环。如果后续要开源建议按以下顺序准备移除所有资产来源不明的素材。写一份清晰的 README包含运行截图、操作说明、环境要求。选择许可证MIT 最省心GPL 适合想要回馈社区但限制商用的情况。将本地路径改为相对路径和配置项。确认.gitignore排除bin和obj目录。7. MonoGame 开发常见问题与排查方法问题现象可能原因排查方式解决方案启动后黑屏纹理未加载或 Content.mgcb 未构建检查输出窗口的异常信息重新生成 Content 项目Content.Load 抛异常文件路径或 .mgcb 中文件名不一致检查 Content.mgcb 中文档路径是否区分大小写保持文件名大小写一致游戏运行很卡每帧创建了大量对象在 Update 中使用 Visual Studio 性能探查器使用对象池缓解 GC子弹偶尔穿透敌人子弹速度过快检查子弹每帧移动距离限制最大速度或使用射线检测移动时斜向更快方向向量未归一化检查方向向量长度使用 Normalize()窗口缩放后画面拉伸变形未处理分辨率缩放检查 GraphicsDevice.PreferredBackBufferWidth 设置使用固定分辨率或实现视口缩放字体无法显示中文字体资源未包含中文子集在 Content Pipeline 中检查字体设置使用支持中文的字体并配置字符范围音频延迟音频文件格式或加载时机不对检查是否在初始化时预加载提前加载避免运行时加载按键响应不灵敏未处理上一帧和当前帧状态检查键盘状态判断方式使用 previous/current 状态比较8. 技术未来优化方向与再战8.1 优化方向如果考研结束后继续迭代这个项目我建议按以下优先级排序第一优先是操作手感包括玩家加速度、惯性、镜头跟随、受击反馈。第一天版本往往把移动做成了匀速直线玩家操作会感觉很“飘”。加入加速度和摩擦后手感会有明显提升。第二优先是难度曲线敌人的刷新密度应该随时间梯度增加同时玩家的火力也要有成长。最直接的做法是加入武器升级掉落物击杀敌人后掉落不同颜色的道具吃道具可以增加子弹数量或射速。第三优先是视听反馈玩家被击中时屏幕闪红、敌人被击中时闪白或产生粒子效果、高分时播放特定音效。这些细节决定游戏有没有“打击感”。第四优先是存档系统把最高分、已通关卡、武器等级保存到本地 JSON 文件用System.Text.Json可以轻松实现。第五优先是加入 Boss 战一天版本可能没有 Boss后续可以加入一个多阶段 Boss每个阶段切换攻击模式。这是射击游戏最有记忆点的内容。8.2 考研之后的计划从项目标题来看作者处于考研冲刺期那么最合理的节奏是考研结束前只收集问题不做大改动。考研结束后先花一周整理项目写 README补齐遗漏的素材授权。第二周修复明显问题发布 0.2 版本。第三周开源并写一篇完整的技术拆解记录 MonoGame 开发中的设计决策。9. 给 MonoGame 初学者的建议如果你看完之后也想用 MonoGame 做一个射击游戏这里给你一套可执行的步骤9.1 先做一个最小可玩版本第一天不要想画面和特效先把以下内容跑通// 玩家可以移动 // 按下空格发射子弹 // 敌人每 2 秒从屏幕上方出现并向玩家移动 // 子弹碰到敌人时敌人消失 // 敌人碰到玩家时游戏结束这个最小闭环是一切优化的基础。9.2 在最小版本上迭代玩家移动手感 OK 后再加对象池。 对象池稳定后再加敌人类型。 敌人类型丰富后再加分数和 UI。 然后加音效、粒子、Boss。每次只加一个功能测试一个功能。一天开发完一款游戏的前提不是写得多而是没有在某个功能上反复返工。9.3 控制开发时间一天开发时间是非常紧的所以要把“做不完”的焦虑放到一边。先把核心玩法做完特效和音效宁可留到第二天再补也不要一开始就陷在细节里面。10. 总结这个 MonoGame 射击游戏项目用一天时间从零做出一款可实机演示的完整游戏技术上覆盖了游戏循环、输入处理、对象池、碰撞检测、敌人 AI 等关键模块。暂时不开源的决策合理考研结束后如果整理发布对 MonoGame 社区和 C# 游戏开发者都会是一份很好的参考。如果你也是用 C# 写游戏或者正在 MonoGame 和 Unity 之间纠结这个项目已经证明了一件事不需要大而全的引擎也能在一天内做出一个完整的、有挑战性的射击游戏原型。现在可以把这篇收藏起来等作者开源后照着代码跑一遍然后用你自己的玩法设定去扩展它。
返回列表