多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Godot C#架构实战:依赖注入与状态机提升游戏代码质量

Godot C#架构实战:依赖注入与状态机提升游戏代码质量 1. 项目概述当Godot C#遇上Chickensoft如果你正在用Godot的C#做稍微复杂点的项目尤其是那种需要良好架构、多人协作或者长期维护的游戏大概率会碰到两个头疼的问题一是各个脚本Node之间互相引用依赖关系乱成一团改一处动全身二是游戏逻辑特别是角色或系统的行为状态切换写成了满屏幕的if-else或者switch-case逻辑纠缠不清bug难找。这两个问题几乎是所有从“小Demo”迈向“正经项目”的开发者都会遇到的坎。我自己在做一个中等体量的2D动作游戏时就深有体会。Player脚本里既要处理输入、动画、物理碰撞又要管理攻击、受伤、死亡等状态一个脚本轻松突破上千行加个新技能都战战兢兢。直到我遇到了Chickensoft这套架构方案它并不是一个全新的游戏引擎而是为Godot C#量身打造的一套开发范式与工具集。它的核心就是直击上述两个痛点用依赖注入Dependency Injection, DI来管理对象间的依赖关系用状态机State Machine来清晰地管理复杂的行为逻辑。简单来说Chickensoft提供了一套“脚手架”和“最佳实践”让你能用更工程化、更可测试的方式来组织Godot C#项目。它不是一个黑盒框架不会把你锁死而是提供了一套接口、基类和约定引导你写出更干净的代码。这次我们就来深入聊聊如何基于Chickensoft在Godot C#项目中实战依赖注入与状态机把这两个听起来有点“架构师”味道的概念变成你手中实实在在的开发利器。2. 核心架构思想与Chickensoft生态解析在动手写代码之前有必要先理解Chickensoft倡导的核心思想。它深受现代.NET开发中Clean Architecture、依赖反转原则DIP等理念的影响并将其适配到Godot这个特定的游戏开发环境中。Godot本身的节点Node和场景Scene树结构非常灵活但缺乏对大型项目代码组织的强制约定容易导致“面条代码”。Chickensoft的引入就是为了在保持Godot灵活性的同时增加一些必要的约束和模式。2.1 依赖注入DI在游戏开发中的价值依赖注入不是什么银弹但它解决了一个非常具体的问题对象A需要对象B的功能A如何获取B的实例最原始的做法是在A内部new B()这导致了紧耦合A依赖了B的具体实现难以测试和替换。在Godot中常见的紧耦合包括在脚本里用GetNodeMyComponent(“../SomePath”)硬编码查找节点。直接使用GD.LoadResource(“res://some_resource.tres”)加载资源。在一个脚本里直接实例化另一个场景PackedScene。依赖注入的核心思想是“别找我我会给你”。对象的依赖即它需要的其他对象或服务由外部容器通常称为IoC容器在创建对象时“注入”给它而不是由对象自己创建或查找。在Chickensoft中这个容器就是其AutoInjector和相关设施。这样做带来的好处是显而易见的可测试性你想测试Player的逻辑但Player依赖一个复杂的AudioManager。在测试时你可以注入一个模拟的MockAudioManager而不是启动整个游戏的声音系统。可维护性依赖接口而非实现。如果今天你用A方案实现存档系统明天想换成B方案你只需要在容器配置处替换一下具体的实现类所有依赖存档系统的代码都无需改动。解耦与清晰度每个类的依赖关系在其构造函数或属性上清晰声明一目了然。你不会在类的方法深处发现隐藏的GetNode调用。2.2 状态机复杂行为逻辑的“降维打击”工具游戏逻辑本质上是状态驱动的。一个角色可能处于Idle闲置、Run奔跑、Jump跳跃、Attack攻击等状态。用条件语句处理的经典问题在于状态转移逻辑分散在各个地方例如在_PhysicsProcess里判断按键和碰撞添加或修改一个状态时你需要通读所有相关代码极易出错。有限状态机FSM将这种行为模型化状态State一个明确的、互斥的行为模式如IdleState。转移Transition状态切换的条件如“按下跳跃键时从Idle转移到Jump”。事件Event/Input触发转移的信号如按键、计时器、碰撞消息。Chickensoft的状态机库提供了一套类型安全、与Godot生命周期集成的状态机实现。它强制你将每个状态的行为进入、退出、每帧更新、处理输入封装在独立的类中状态转移逻辑也集中管理。这样JumpState的代码就只关心跳跃相关的事情AttackState只关心攻击它们之间通过明确定义的转移条件进行切换代码的复杂度和认知负担大大降低。2.3 Chickensoft核心组件一览Chickensoft不是一个单一的包而是一个生态主要包含以下几个核心NuGet包Chickensoft.AutoInject提供依赖注入的核心设施如[Dependency]属性、AutoInjector等。Chickensoft.StateCharts这是其状态机实现的核心提供了定义状态、转移、上下文Context的基类和接口。Chickensoft.LogicBlocks一个更高级的、基于状态和事件的逻辑块模式可以看作是状态机的增强版适合管理UI、游戏流程等。Chickensoft.GodotNode提供了一些与Godot节点生命周期更好集成的工具类。在我们的实战中将主要聚焦于AutoInject和StateCharts这两个包。理解它们如何协同工作是掌握Chickensoft架构的关键。3. 环境搭建与项目初始化理论说得再多不如动手搭一个。这里我们从头开始创建一个使用Chickensoft的Godot C#项目。3.1 创建Godot C#项目与引入NuGet包首先用Godot 4.x确保支持.NET 6/8创建一个新项目项目类型选择“.NETC#”。创建完成后你需要编辑项目的.csproj文件来添加Chickensoft的NuGet包引用。注意Godot 4的C#项目默认使用.NET Sdk风格的项目文件你可以直接用Visual Studio 2022、Rider或VSCode打开.csproj文件进行编辑。在你的.csproj文件的ItemGroup部分如果没有就新建一个添加以下包引用ItemGroup PackageReference IncludeChickensoft.AutoInject Version* / PackageReference IncludeChickensoft.StateCharts Version* / PackageReference IncludeChickensoft.GodotNode Version* / /ItemGroup版本号*表示使用最新的稳定版建议在首次搭建时指定一个具体的最新版本号以避免意外更新。添加完成后保存文件你的IDE应该会自动开始还原NuGet包。3.2 配置依赖注入容器AutoInjector依赖注入需要一个“容器”来管理所有服务的注册和解析。在Chickensoft中我们通常在游戏启动的入口点配置这个容器。一个常见的做法是在Main场景的根节点脚本中进行配置。创建Main场景在Godot中创建一个名为Main的Node2D或Node3D场景并为其附加一个C#脚本例如Main.cs。配置Provider在Main.cs中你需要创建一个DependencyProvider的实例并注册你的服务。using Chickensoft.AutoInject; using Godot; using YourGameNamespace.Services; public partial class Main : Node { private DependencyProvider _provider null!; public override void _Ready() { // 1. 创建依赖提供者 _provider new DependencyProvider(); // 2. 注册服务单例模式 _provider.RegisterIAudioService, AudioService(); _provider.RegisterISaveGameService, SaveGameService(); // 可以注册任何你需要全局访问的服务 // _provider.RegisterIPlayerRepository, PlayerRepository(); // 3. 将Provider设置为全局上下文可选但推荐 // 这样在任何能访问到DependencyContext的地方都能解析依赖 DependencyContext.SetProvider(_provider); // 4. 初始化其他游戏系统或切换到第一个游戏场景 InitializeGame(); } private void InitializeGame() { // 例如加载你的游戏世界或标题界面 var worldScene GD.LoadPackedScene(res://scenes/World.tscn); var world worldScene.InstantiateNode(); AddChild(world); } }这里的关键是DependencyProvider它充当了IoC容器。RegisterTInterface, TImplementation()方法告诉容器“当有人请求IAudioService时请给他一个AudioService的实例”。我们通常注册为单例意味着整个应用生命周期内同一个接口只会有一个实现实例。3.3 项目结构规划建议采用Chickensoft后项目的代码结构可以更有条理。我推荐以下目录结构这有助于区分Godot的资源/场景和纯粹的C#逻辑res:// ├── scenes/ # Godot场景文件 (.tscn) ├── scripts/ # 直接附加到节点的脚本 │ ├── nodes/ # 具体的节点脚本 (如 Player.cs, Enemy.cs) │ └── components/ # 可复用的组件脚本 ├── src/ # 核心业务逻辑与Godot节点弱关联 │ ├── States/ # 状态机相关的状态类 │ │ ├── Player/ │ │ │ ├── PlayerState.cs (基类或接口) │ │ │ ├── IdleState.cs │ │ │ ├── RunState.cs │ │ │ └── ... │ │ └── Enemy/ │ ├── Services/ # 各种服务音频、存档、配置等 │ ├── Models/ # 数据模型 │ ├── Repositories/# 数据访问层如果需要 │ └── Utils/ # 工具类 └── autoloads/ # Godot自动加载的单例脚本将状态类、服务类放在src目录下强调它们是独立的逻辑单元不直接依赖于特定的Godot场景树结构。附加到具体节点的脚本则放在scripts下它们作为“适配器”将Godot节点与内部逻辑状态机、服务连接起来。4. 依赖注入DI在Godot中的实战应用现在让我们看看如何在实际的Godot节点中使用依赖注入。4.1 声明与注入依赖假设我们有一个Player节点它需要一个IAudioService来播放音效。传统的做法是在Player脚本里用GetNodeAudioManager(“/root/AudioManager”)。现在我们用依赖注入。首先定义服务接口和实现// src/Services/IAudioService.cs public interface IAudioService { void PlaySfx(string sfxName, Vector2 position); void PlayBgm(string bgmName); } // src/Services/AudioService.cs public partial class AudioService : Node, IAudioService { // 这里实现具体的音频播放逻辑可能内部使用了Godot的AudioStreamPlayer public void PlaySfx(string sfxName, Vector2 position) { /* ... */ } public void PlayBgm(string bgmName) { /* ... */ } }注意AudioService本身也是一个Node这样它可以方便地使用Godot的音频系统。我们在Main.cs中已经将它注册为IAudioService的实现。然后在Player脚本中声明依赖并注入// scripts/nodes/Player.cs using Chickensoft.AutoInject; using Godot; using YourGameNamespace.Services; public partial class Player : CharacterBody2D { // 1. 使用 [Dependency] 属性声明依赖 [Dependency] public IAudioService AudioService { get; set; } null!; public override void _Ready() { // 2. 在_Ready中或之后调用Dependency()扩展方法进行注入 this.Dependency(); // 此时AudioService属性已经被自动注入并可以安全使用了 GD.Print($AudioService injected: {AudioService ! null}); } private void OnHit() { // 3. 像使用普通字段一样使用被注入的服务 AudioService.PlaySfx(player_hit, GlobalPosition); } }this.Dependency()这个扩展方法会查找当前节点及其父节点链上最近的依赖上下文通常是我们之前在Main节点设置的DependencyContext.Provider然后根据[Dependency]属性将对应的服务实例赋值给那些属性。4.2 依赖注入的多种生命周期与技巧除了上面演示的属性注入Chickensoft的AutoInject也支持构造函数注入更适合纯C#类但属性注入与Godot节点的生命周期_Ready结合得更好。生命周期管理单例Singleton通过_provider.RegisterTInterface, TImplementation()注册默认就是单例。整个应用共享一个实例。瞬态Transient每次请求都创建一个新实例。可以通过_provider.RegisterTInterface, TImplementation(isSingleton: false)来注册。场景作用域Scoped有时你希望某个服务在一个场景内是单例切换场景后销毁。这需要你手动管理例如在场景根节点上挂载一个自定义的DependencyProvider场景加载时创建场景退出时释放。解决循环依赖如果A依赖BB也依赖A就会形成循环依赖容器会报错。设计时应避免这种情况。如果不可避免可以考虑将其中一个依赖改为方法注入在需要时通过方法参数传入而非构造函数或属性。引入第三个接口或服务来打破循环。重新审视设计看是否真的需要双向紧密耦合。依赖查找失败排查如果this.Dependency()调用后某个[Dependency]属性仍然是null请检查服务是否已在某个上级节点的上下文中正确注册Register。调用Dependency()的时机是否在_Ready之后Godot节点的依赖注入通常需要在节点进入场景树后进行。属性是否是public的[Dependency]属性需要附加在public的属性上。5. 状态机StateCharts设计与实现详解依赖注入理顺了对象间的关系状态机则用来理顺对象内部的行为逻辑。我们以玩家角色为例实现一个包含闲置、奔跑、跳跃、攻击的状态机。5.1 定义状态上下文Context与状态接口状态机需要一个“上下文”Context对象它持有状态机需要操作的所有数据和外部依赖。对于玩家来说上下文可能包括玩家的物理体CharacterBody2D、动画播放器AnimationPlayer、输入检测器等。// src/States/Player/PlayerContext.cs using Chickensoft.StateCharts; using Godot; using YourGameNamespace.Services; public class PlayerContext : IContext { // 玩家节点自身 public CharacterBody2D PlayerBody { get; } // 动画播放器 public AnimationPlayer AnimPlayer { get; } // 输入检测可以是一个自定义的输入服务 public IInputService Input { get; } // 依赖注入的服务 public IAudioService Audio { get; } public PlayerContext(CharacterBody2D playerBody, AnimationPlayer animPlayer, IInputService input, IAudioService audio) { PlayerBody playerBody; AnimPlayer animPlayer; Input input; Audio audio; } }IContext是一个标记接口。上下文是一个纯数据对象不包含逻辑。接下来定义所有玩家状态的共同接口// src/States/Player/IPlayerState.cs using Chickensoft.StateCharts; public interface IPlayerState : IStatePlayerContext { }IStateTContext是Chickensoft.StateCharts的泛型接口它定义了状态的生命周期方法OnEnter,OnExit,OnUpdate,OnInput等。5.2 实现具体状态类现在实现具体的状态。每个状态都是一个独立的类。// src/States/Player/IdleState.cs using Chickensoft.StateCharts; using Godot; public class IdleState : IPlayerState { public void OnEnter(PlayerContext context) { // 进入闲置状态播放闲置动画 context.AnimPlayer.Play(idle); GD.Print(进入闲置状态); } public void OnExit(PlayerContext context) { GD.Print(退出闲置状态); } public StateChart.Transition? OnUpdate(PlayerContext context) { // 每帧更新逻辑 var input context.Input; // 状态转移判断如果按下了水平方向键转移到奔跑状态 if (Mathf.Abs(input.GetHorizontalAxis()) 0.01f) { return StateChart.Transition.ToRunState(); } // 如果按下了跳跃键转移到跳跃状态 if (input.IsJumpJustPressed()) { return StateChart.Transition.ToJumpState(); } // 如果按下了攻击键转移到攻击状态 if (input.IsAttackJustPressed()) { return StateChart.Transition.ToAttackState(); } // 没有转移返回null return null; } // 可以处理输入事件如果需要 public StateChart.Transition? OnInput(PlayerContext context, InputEvent event) null; }// src/States/Player/RunState.cs using Chickensoft.StateCharts; using Godot; public class RunState : IPlayerState { public void OnEnter(PlayerContext context) { context.AnimPlayer.Play(run); GD.Print(进入奔跑状态); } public void OnExit(PlayerContext context) { } public StateChart.Transition? OnUpdate(PlayerContext context) { var input context.Input; float horizontalAxis input.GetHorizontalAxis(); // 应用水平速度 var velocity context.PlayerBody.Velocity; velocity.X horizontalAxis * context.PlayerBody.RunSpeed; context.PlayerBody.Velocity velocity; // 根据方向翻转精灵 if (horizontalAxis ! 0) { context.PlayerBody.Scale new Vector2(Mathf.Sign(horizontalAxis), 1); } // 转移判断如果松开方向键回到闲置 if (Mathf.Abs(horizontalAxis) 0.01f) { return StateChart.Transition.ToIdleState(); } if (input.IsJumpJustPressed()) { return StateChart.Transition.ToJumpState(); } if (input.IsAttackJustPressed()) { return StateChart.Transition.ToAttackState(); } return null; } }JumpState和AttackState的实现模式类似它们内部会处理跳跃的物理逻辑或攻击的动画、伤害判定逻辑并在适当的时候如跳跃落地、攻击动画结束转移回其他状态。5.3 构建与驱动状态机状态和上下文都定义好了我们需要在Player节点中创建并驱动这个状态机。// scripts/nodes/Player.cs (续) using Chickensoft.StateCharts; using YourGameNamespace.States.Player; public partial class Player : CharacterBody2D { [Dependency] public IAudioService AudioService { get; set; } null!; [Dependency] public IInputService InputService { get; set; } null!; // 假设有输入服务 [Export] public float RunSpeed { get; set; } 300.0f; [Export] public float JumpVelocity { get; set; } -400.0f; private AnimationPlayer _animationPlayer null!; private StateChartPlayerContext _stateChart null!; private PlayerContext _context null!; public override void _Ready() { this.Dependency(); // 注入服务 _animationPlayer GetNodeAnimationPlayer(AnimationPlayer); // 1. 创建上下文 _context new PlayerContext(this, _animationPlayer, InputService, AudioService); // 2. 构建状态图并设置初始状态 _stateChart new StateChartBuilderPlayerContext() .StateIdleState() // 定义所有可能的状态 .StateRunState() .StateJumpState() .StateAttackState() .InitialStateIdleState() // 设置初始状态 .Build(_context); // 构建状态机实例传入上下文 // 3. 启动状态机 _stateChart.Start(); } public override void _Process(double delta) { // 4. 每帧更新状态机 _stateChart.Update((float)delta); } public override void _PhysicsProcess(double delta) { // 物理逻辑可以放在这里或者由各个状态在OnUpdate中处理 // 如果状态需要物理更新可以调用_context.PlayerBody.MoveAndSlide()等 // 更清晰的做法是在状态机的OnUpdate中计算好速度然后在这里统一应用物理 MoveAndSlide(); } public override void _Input(InputEvent event) { // 5. 将输入事件传递给状态机 _stateChart.Input(event); } protected override void Dispose(bool disposing) { if (disposing) { // 6. 记得在节点退出时停止状态机 _stateChart?.Stop(); } base.Dispose(disposing); } }通过StateChartBuilder我们清晰地定义了状态机的结构和初始状态。_stateChart.Update(delta)会调用当前状态的OnUpdate方法并处理状态转移。_stateChart.Input(event)会将输入事件传递给当前状态的OnInput方法。6. DI与状态机的结合更清晰的架构现在我们把依赖注入和状态机结合起来看。在PlayerContext的构造函数中我们注入了IAudioService和IInputService。这意味着状态类如AttackState可以通过context.Audio.PlaySfx(...)播放音效但它完全不知道这个AudioService是从哪里来的它只依赖PlayerContext。Player节点负责通过依赖注入获取这些服务的实例并传递给PlayerContext。测试时我们可以创建一个MockAudioService和MockInputService注入到PlayerContext中然后单独测试AttackState的逻辑无需启动Godot引擎。这种架构实现了清晰的关注点分离状态类只关心特定状态下的行为逻辑和状态转移条件。上下文类持有状态机运行所需的所有数据和外部依赖。节点脚本作为“粘合剂”负责初始化依赖注入、创建上下文和状态机并驱动状态机运行。服务类提供通用的、与具体游戏逻辑解耦的功能如音频、存档、网络。7. 高级技巧与常见问题排查在实际项目中应用这套架构你可能会遇到一些具体问题。这里分享一些经验和解决方案。7.1 状态间数据传递与共享有时一个状态需要向另一个状态传递数据。例如JumpState在落地时可能需要知道落地的速度以便LandState决定播放“轻落地”还是“重落地”动画。Chickensoft的状态机可以通过Transition.Data来传递数据。在发起转移时// 在JumpState的OnUpdate中 if (IsOnFloor()) { var landingVelocity Mathf.Abs(context.PlayerBody.Velocity.Y); // 创建一个包含数据的转移 return StateChart.Transition.ToLandState().WithData(landingVelocity); }在目标状态的OnEnter中接收数据public class LandState : IPlayerState { public void OnEnter(PlayerContext context, object? data) { if (data is float landingVelocity) { if (landingVelocity 500.0f) { context.AnimPlayer.Play(land_hard); context.Audio.PlaySfx(land_heavy, context.PlayerBody.GlobalPosition); } else { context.AnimPlayer.Play(land_soft); } } } }7.2 嵌套状态与并行状态对于更复杂的行为比如“攻击”状态下可能还有“连击1”、“连击2”、“连击3”的子状态或者角色同时处于“移动”和“持枪”两个正交的状态Chickensoft.StateCharts支持嵌套状态和并行状态。嵌套状态使用StateChartBuilder的.State(...)方法内再调用.State(...)来定义子状态。子状态可以继承父状态的进入/退出逻辑通过base.OnEnter调用适合管理有层级关系的状态。并行状态使用.Parallel()来定义一组并行运行的状态机。例如可以有一个并行状态机管理“移动”走/跑/跳另一个管理“装备”空手/持剑/持枪。它们独立更新和响应输入互不干扰。这在管理复杂角色时非常有用。7.3 状态机调试与可视化调试状态机时最怕不知道当前处于哪个状态。有几种方法日志输出在每个状态的OnEnter和OnExit中打印日志这是最基本的方法。上下文状态属性在PlayerContext中添加一个CurrentStateName的字符串属性在每个状态的OnEnter中更新它。然后在Player节点的_Process中将其显示在屏幕上如通过Label节点。使用Chickensoft的调试工具Chickensoft.StateCharts内置了一些调试支持可以通过监听状态机的事件来获取状态变化信息。7.4 性能考量与最佳实践状态对象池频繁创建和销毁状态对象尤其是简单状态可能产生GC垃圾回收压力。对于非常简单的状态可以考虑使用结构体struct来实现IState接口或者实现一个简单的对象池来复用状态实例。但对于大多数游戏状态切换频率不高直接new的开销可以接受。避免在状态Update中做昂贵操作状态机的OnUpdate每帧调用应保持轻量。如果需要加载资源、进行复杂计算应考虑异步操作或在状态进入时预加载。合理划分状态粒度状态不是越细越好。如果两个行为逻辑高度耦合且几乎总是一起切换那么它们可能应该合并成一个状态。状态过多会增加管理复杂度。一个好的经验法则是一个状态应该对应一个明确的、在玩家看来是“不同”的行为模式。7.5 常见问题排查表问题现象可能原因解决方案依赖注入属性为null1. 服务未在上级DependencyProvider中注册。2. 调用this.Dependency()的时机过早如在_EnterTree中依赖上下文还未就绪。3. 属性不是public。1. 检查Main.cs或相关场景根节点的注册代码。2. 确保在_Ready或之后调用Dependency()。3. 将属性设为public。状态机不更新或状态不切换1. 忘记在_Process中调用_stateChart.Update(delta)。2. 状态转移条件永远不满足逻辑错误。3. 在状态的OnUpdate中返回了错误的Transition类型或null。1. 确保每帧调用Update。2. 在状态转移判断处添加调试打印检查输入和条件。3. 确认返回的是StateChart.Transition.ToTState()。状态进入/退出日志混乱状态转移逻辑有误导致短时间内频繁进出状态如条件在满足和不满足间震荡。检查转移条件考虑增加状态切换的“冷却时间”或使用更精确的条件判断。例如从JumpState转移到IdleState需要确保已经落地并且垂直速度接近零。动画与状态不同步动画播放逻辑写在状态OnEnter中但动画可能被其他逻辑如另一个脚本打断。确保动画控制权完全交给状态机。如果其他系统也需要影响动画如受伤无敌闪烁可以考虑通过修改上下文中的某个标志由状态机在OnUpdate中统一响应并播放动画。8. 实战案例构建一个简单的敌人AI状态机为了加深理解我们快速构建一个拥有“巡逻”、“追击”、“攻击”三个状态的简单敌人AI。定义敌人上下文(EnemyContext)包含敌人节点引用、玩家节点引用用于检测距离、导航代理NavigationAgent2D等。实现状态PatrolState在预设点之间移动。如果发现玩家距离小于某个值转移到ChaseState。ChaseState使用导航代理朝玩家移动。如果玩家进入攻击范围转移到AttackState如果玩家跑远丢失回到PatrolState。AttackState播放攻击动画造成伤害。动画结束后根据玩家是否仍在攻击范围内决定回到ChaseState还是PatrolState。在敌人节点中集成创建上下文注入可能需要的IPlayerFinderService等服务构建状态机并在_Process中更新。这个案例清晰地展示了如何用状态机管理AI的决策流程每个状态职责单一PatrolState不需要知道攻击逻辑AttackState也不需要知道巡逻路径点。添加一个新的状态如“逃跑”状态也变得非常容易只需定义新状态类并在相关状态如ChaseState的转移条件中添加判断即可不会影响现有代码。9. 总结与个人体会从最初面对上千行杂乱无章的Player脚本到如今用Chickensoft将依赖和状态梳理得清清楚楚这个过程给我的最大体会是前期多花一点时间在架构上后期会节省数倍于它的调试和维护时间。依赖注入迫使你思考类之间的边界状态机迫使你将行为逻辑模块化。这带来的不仅是代码的整洁更是思维上的清晰。刚开始接触Chickensoft时可能会觉得它有点“重”为简单的功能写这么多类似乎不值得。但我的经验是一旦你的项目规模超过某个阈值比如超过5个有复杂交互的实体或者需要多人协作这种结构化的好处就会急剧显现。新成员接手代码时他能很快找到播放声音的地方在服务里找到玩家跳跃的逻辑在JumpState里而不是在几千行的脚本里大海捞针。最后一个小技巧不要试图一开始就用Chickensoft重写所有东西。可以从一个新功能或者一个最让你头疼的旧脚本开始尝试。比如先为你新做的Boss敌人用状态机实现一套AI感受一下它带来的条理性。当你尝到甜头后自然会知道在什么地方、以什么节奏将这套架构推广到项目的其他部分。架构是工具目的是让开发更顺畅而不是给自己套上枷锁。找到适合你自己项目和团队的平衡点才是最重要的。
返回列表