> 技术文档 > ARPG开发流程第一章——方法合集

ARPG开发流程第一章——方法合集


配置表格 

1、给Excel插件脚本配置:(都放置在Editor文件夹中)

  1. Excel2CS.cs:这是你之前提到的用于将Excel数据转换为C#脚本的脚本文件。

  2. ExcelTools.cs:这是另一个工具脚本,可能包含了一些辅助方法或菜单项,用于在Unity编辑器中操作Excel数据。

  3. ExcelDataReader.DataSet:这是一个与ExcelDataReader相关的数据集文件,可能用于存储和管理从Excel文件中读取的数据。

  4. ExcelDataReader:这是一个DLL文件或脚本文件,提供了读取Excel文件的核心功能。有关该程序文件的下载:下载及使用方法

2、 将配置的表格导入成脚本:

       

 Tools脚本编辑器与Excel2CS脚本之间的功能联动

ExcelTools 编辑器脚本的功能

ExcelTools 是一个编辑器脚本,主要功能是为用户提供更友好的操作界面和流程管理,以便在 Unity 编辑器中方便地启动和管理 Excel 数据的转换过程。它的功能包括:

  1. 提供菜单项

    • 在 Unity 编辑器的菜单栏中添加菜单项(如Tools -> Excel工具 -> 生成游戏配置脚本),方便用户触发转换操作。

    • 这些菜单项封装了对Excel2CS脚本的调用逻辑,使用户无需直接操作脚本代码即可进行转换。

  2. 流程控制和状态检查

    • 在执行转换操作前,进行一系列的状态检查,比如检查 Unity 是否处于运行状态、是否有编译正在进行等。

    • 如果检查不通过,则提示用户相应的错误信息,避免在不合适的时机执行转换操作可能导致的问题。

  3. 路径配置和初始化

    • 提供对 Excel 文件输入路径、C# 脚本输出路径和 JSON 文件输出路径的配置。

    • 通过Init()方法初始化这些路径,确保Excel2CS脚本能够正确找到输入文件和输出位置。

  4. 外部进程管理

    • 杀死可能占用 Excel 文件的外部进程(如 WPS 和 Excel),以防止文件被占用导致转换失败。

    • 这一步骤对于确保转换过程顺利进行非常重要,因为如果文件被其他程序占用,可能会导致读取或写入失败。

  5. 编译和刷新操作

    • 在转换完成后,请求 Unity 编译新的脚本,并在编译完成后刷新资产数据库,使新的配置类能够立即生效。

    • 这有助于用户快速查看转换结果并继续后续的开发工作。
       

Excel2CS 脚本的功能

Excel2CS 是核心的转换逻辑实现脚本,主要功能是处理 Excel 文件的数据转换工作。具体包括:

  1. Excel 文件读取

    • 使用合适的库(如ExcelDataReader)读取 Excel 文件的内容。

    • 将表格中的数据加载到内存中,以便进行后续的处理。

  2. 数据解析和转换

    • 解析读取到的 Excel 数据,将其转换为适合游戏开发的结构化数据。

    • 这通常包括将每一行数据映射为一个对象或数据结构,定义字段类型等。

  3. 生成 C# 配置类

    • 根据转换后的数据生成对应的 C# 类文件。

    • 这些类文件定义了游戏中的配置数据结构,方便在游戏代码中引用和使用这些数据。

  4. 生成 JSON 文件(如果需要):

    • 除了生成 C# 类文件,还可以将数据导出为 JSON 格式,用于其他需要的地方。

    • JSON 文件可以方便地进行数据交换和配置管理。

  5. 错误处理和日志记录

    • 在转换过程中处理可能出现的错误,并记录日志以便排查问题。

    • 为用户提供了一定的调试信息,帮助他们了解转换过程中的问题所在。
       

两者的协同工作关系

  1. 触发和流程管理

    • 用户通过ExcelTools编辑器脚本提供的菜单项触发转换操作。

    • ExcelTools负责检查环境状态并准备好转换所需的路径和配置,然后调用Excel2CS脚本的核心逻辑。

  2. 核心转换逻辑执行

    • Excel2CS脚本接收到ExcelTools传递的参数(如路径配置等),开始执行 Excel 文件的读取、解析和转换工作。

    • 它生成所需的 C# 配置类和 JSON 文件,并将它们输出到指定的位置。

  3. 后续处理

    • 转换完成后,ExcelTools编辑器脚本继续执行后续操作,如请求 Unity 编译新生成的脚本,并刷新资产数据库。

    • 这使得转换后的文件能够立即在 Unity 项目中生效,用户可以继续开发工作

Excel2CS脚本作用(Tools文件夹中与Excel表并排)

   曾出现了路径中未能找到Excel表格的报错:原因是因为TOOLs文件夹没能放在Unity practice文件夹中。(需要避免混淆的是这里主要找到Excel表的位置,实际发挥作用的是Editor中的脚本)

        如果出现了Tool没有Excel工具的情况,重新导入Editor文件即可

正确放置位置:

截取部分Excel2CS脚本

static string path = AppDomain.CurrentDomain.BaseDirectory + \"/../../../../../Tools/Excel/\";static string writePath = ... // 输出CS文件的路径static string jsonPath = ... // 输出JSON文件的路径 static void Start() { //https://github.com/ExcelDataReader/ExcelDataReader#important-note-on-net-core System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance); List fileLst = new List(); GetAllFiles(path,ref fileLst); //获取要转化的配置表 string[] files = fileLst.ToArray(); //Directory.GetFiles(path); for (int i = 0; i < files.Length; i++) { Console.WriteLine(files[i]); if (!files[i].Contains(\"~$\") && files[i].EndsWith(\".xlsx\")&&files[i].Contains(\"_\"))//xlsx {  excelList.Add(files[i]);  //Console.WriteLine(files[i]); } }
  • 路径构建逻辑

    • AppDomain.CurrentDomain.BaseDirectory:获取程序执行的基目录(通常是bin/Debugbin/Release

    • 通过/../../../../../向上跳转5级目录(假设项目结构为:项目根/Tools/Excel/

    • 最终指向:[项目根目录]/Tools/Excel/

    • 潜在问题:依赖固定目录层级结构,项目结构调整会导致路径失效

 

System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance);
  • 解决编码问题

    • 必需调用:使ExcelDataReader支持旧版Excel编码(如GB2312)

    • 仅需执行一次(静态方法中注册全局有效)
       

GetAllFiles(path, ref fileLst);
  • 递归获取文件(假设自定义方法):

    • 实现深度遍历所有子目录

    • 等效于:Directory.GetFiles(path, \"*\", SearchOption.AllDirectories)
       

csharpif (!files[i].Contains(\"~$\") && files[i].EndsWith(\".xlsx\") && files[i].Contains(\"_\"))
  • 筛选条件

    1. 排除Office临时文件(~$开头的隐藏文件)

    2. 仅处理.xlsx格式

    3. 文件名必须包含下划线_(自定义规则)

FSM运行逻辑 

        同为PlayerState类型变量;stateData成员与currentPlayerstate成员作用完全不同。 

初始化阶段(Awake) 

using System;using System.Collections;using System.Collections.Generic;using Game.Config;using UnityEngine;public class FSM : MonoBehaviour{ public int id; private PlayerState currentPlayerstate; Dictionary stateData = new Dictionary();//存储各个角色状态信息的目录 public UnitEntity unitEntity;//单位基础表(在Game.Config命名空间中) [HideInInspector] public Transform _transform; [HideInInspector] public GameObject _gameObject; public Animator _animator; public CharacterController characterController; private void Awake() { _transform = this.transform; _gameObject = this.gameObject; _animator =_transform.GetChild(0).GetComponent(); characterController =GetComponent(); unitEntity = UnitData.Get(id);// 通过ID加载角色配置数据,这里在Inspctor中已经填写1001 ServiceInit(); // 创建核心服务系统 StateInit(); // 加载所有状态配置 ToNext(1001); // 进入ID为1001的初始状态(这里需要注意的是currentPlayerState已在ToNext(1001);赋值了) }

        这里id已经赋值为1001

unitEntity = UnitData.Get(id);
public static UnitEntity Get(int id){ // 1. 检查缓存字典是否已初始化且包含目标ID if (entityDic != null && entityDic.TryGetValue(id, out var entity)) { // 2. 如果找到则直接返回缓存对象 return entity; } // 3. 找不到则返回null return null;}
  • entityDic != null: 检查字典是否已初始化

  • &&: 逻辑与运算符(两个条件都必须满足)

  • entityDic.TryGetValue(id, out var entity): 字典的安全查找方法

    • TryGetValue(): Dictionary类的方法,尝试获取指定键的值

    • id: 要查找的键

    • out var entity: 输出参数,如果找到则赋值给entity变量(请查看UnitData的拆解)

      • 使用 out var entity 可以同时获取值
      • 不用 out 的话,即使存在 key,你也不知道对应的值是什么
  • unitEntity获取的entity如下:

UnitData 类

using System.Collections.Generic;using UnityEngine;namespace Game.Config{ public class UnitData { static UnitData() { entityDic = new Dictionary(4); UnitEntity e0 = new UnitEntity(1001,@\"玄影剑姬\",0,0,1,10011,10012,10013,10014,10015,10016,10017,10018,80,60,30,50,30); entityDic.Add(e0.id, e0); UnitEntity e1 = new UnitEntity(1002,@\"红焰邪姬\",1,1,2,20011,20012,20013,20014,20015,20016,20017,20018,80,60,30,50,20); entityDic.Add(e1.id, e1); UnitEntity e2 = new UnitEntity(1003,@\"独目锤影\",3,1,2,30011,30012,30013,30014,30015,30016,30017,30018,80,60,30,50,20); entityDic.Add(e2.id, e2); UnitEntity e3 = new UnitEntity(1004,@\"小兵C\",3,1,2,20011,20012,20013,20014,20015,20016,20017,20018,80,60,30,50,20); entityDic.Add(e3.id, e3); } public static Dictionary all { get { return entityDic; } }static Dictionary entityDic;public static UnitEntity Get(int id){ if (entityDic!=null&&entityDic.TryGetValue(id,out var entity)){return entity;} return null;} } public class UnitEntity { //TemplateMemberpublic int id;//单位IDpublic string info;//说明public int type;//类型public int camp;//阵营public int att_id;//属性表IDpublic int ntk1;//技能ID_普攻1public int ntk2;//技能ID_普攻2public int ntk3;//技能ID_普攻3public int ntk4;//技能ID_普攻4public int skill1;//技能ID_技能1public int skill2;//技能ID_技能2public int skill3;//技能ID_技能3public int skill4;//技能ID_技能4public int block_probability;//格挡概率public int dodge_probability;//躲闪概率public int atk_probability;//对拼概率public int active_attack_probability;//主动发起攻击概率public int pacing_probability;//踱步概率 public UnitEntity(int id,string info,int type,int camp,int att_id,int ntk1,int ntk2,int ntk3,int ntk4,int skill1,int skill2,int skill3,int skill4,int block_probability,int dodge_probability,int atk_probability,int active_attack_probability,int pacing_probability){this.id = id;  this.info = info;  this.type = type;  this.camp = camp;  this.att_id = att_id;  this.ntk1 = ntk1;  this.ntk2 = ntk2;  this.ntk3 = ntk3;  this.ntk4 = ntk4;  this.skill1 = skill1;  this.skill2 = skill2;  this.skill3 = skill3;  this.skill4 = skill4;  this.block_probability = block_probability;  this.dodge_probability = dodge_probability;  this.atk_probability = atk_probability;  this.active_attack_probability = active_attack_probability;  this.pacing_probability = pacing_probability; } }}
using System.Collections.Generic;using UnityEngine;namespace Game.Config{ public class UnitData { static UnitData() { entityDic = new Dictionary(4); UnitEntity e0 = new UnitEntity(1001,@\"玄影剑姬\",0,0,1,10011,10012,10013,10014,10015,10016,10017,10018,80,60,30,50,30); entityDic.Add(e0.id, e0); UnitEntity e1 = new UnitEntity(1002,@\"红焰邪姬\",1,1,2,20011,20012,20013,20014,20015,20016,20017,20018,80,60,30,50,20);………………………………//还有两个类似格式的UnitEntity e3-e4 } 
public class UnitEntity { //TemplateMemberpublic int id;//单位IDpublic string info;//说明public int type;//类型public int camp;//阵营public int att_id;//属性表IDpublic int ntk1;//技能ID_普攻1……public int pacing_probability;//踱步概率 public UnitEntity(int id,string info,int type,int camp,……,int pacing_probability){this.id = id;  this.info = info;  this.type = type;  this.camp = camp;  this.att_id = att_id;  this.ntk1 = ntk1;  this.ntk2 = ntk2; ……  this.pacing_probability = pacing_probability; }

UnitEntity在此属于构造方法(Constructor)

  1. 作用
    用于在创建类的实例(对象)时初始化对象的属性。当调用 new UnitEntity(...) 时,此方法会被执行。

  2. 特点

    • 方法名与类名相同(此处为 UnitEntity

    • 无返回值类型(连 void 都没有)

    • 通常用 public 修饰(表示可公开访问)

  3. e0的内容如下:

 ServiceInit方法(服务初始化)

 public void ServiceInit() { animationService=AddService(); physicsService=AddService(); service_count = fSMService.Count; }

1、通过 AddService()将实例化后的服务体,返回com的值就是AddService();并将其赋值到animationService。

2、记录当前 FSM 中注册的服务数量,便于后续统一调用这些服务的生命周期方法:

就是把当前已注册的服务数量保存下来,供后续使用,比如:

  • 在 ServiceOnBegin()ServiceOnUpdate() 等方法中循环调用每个服务的生命周期方法;
  • 控制服务更新顺序或进行性能统计;
  • fSMService.Count会在每次调用 AddService() 方法时增加。

AddService方法(添加服务层) 

public T AddService() where T : FSMServiceBase, new(){ T com = new T(); fSMService.Add(com); com.Init(this);//传入当前的 FSM 实例作为参数 return com;}
  • T 是一个泛型参数,表示你要添加的服务类型。Unity游戏开发——对于泛型的理解 - 知乎https://zhuanlan.zhihu.com/p/73374032
  • where T : FSMServiceBase:表示 T 必须继承自 FSMServiceBase 类(即它是某种状态机服务)。
  • new():表示 T 必须有一个无参构造函数

 创建了一个 T 类型的新实例。例如,如果调用的是 AddService(),这里就会创建一个 AnimationService 实例。

  


fSMService.Add(com);

      把新创建的服务对象加入到 fSMService 容器中(它是 List 或类似的集合,而此时因为animationService=AddService

  所以fSMService.Add(com) 的 com ​​既是  类型,也是 FSMServiceBase 类型​​。


com.Init(this);return com;
  • 调用服务对象的 Init 方法,并将当前对象作为参数传入,用于初始化服务,返回刚刚创建并初始化好的服务对象。

Q1:

com.Init(this);//传入当前的 FSM 实例作为参数

        这段代码作用怎么理解?

A1:

public class AnimationService : FSMServiceBase{ public float normalizedTime;//当前动作播放进度 public string now_play_id; public override void Init(FSM fsm) { base.Init(fsm); }

调用 com.Init(this),将当前的 FSM 实例作为参数传递给 AnimationService 的 Init 方法,这意味着:

  • 每个服务都可以持有对主控类(这里是 FSM)的引用;
  • 这样它们就可以访问管理器中的公共资源、状态、方法等

Q2:FSM和AnimationService具体关系说明
A2:

1. 从属关系:AnimationService 是 FSM 的子服务

  • FSM 是核心控制器;
  • AnimationService 是其中一个功能模块;
  • FSM 通过 AddService() 来创建它,并保存引用供后续使用。
animationService = AddService();

2. 协作关系:共同完成状态切换与动画播放

  • 当前状态改变时:

    • FSM.ToNext(id) 切换状态;
    • FSM.ServiceOnBegin() 触发所有服务的 OnBegin()
    • AnimationService.OnBegin() 调用 Play(state) 开始播放动画;
  • 动画播放结束后:

    • AnimationService.OnUpdate() 监测到动画播放完毕;
    • 调用 player.AnimationOnPlayEnd()
    • FSM.AnimationOnPlayEnd() 处理状态切换逻辑(如循环或跳转)

StateInit方法(状态初始化)

 public void StateInit() { anmConfig = Resources.Load($\"StateConfig/{id}\"); Dictionary state_config = new Dictionary(); foreach (var item in anmConfig.states) { state_config[item.id] = item; } var clips = _animator.runtimeAnimatorController.animationClips; Dictionary clipLength = new Dictionary(); foreach (var clip in clips) { clipLength[clip.name] = clip.length; } foreach (var item in PlayerStateData.all) {  PlayerState P = new PlayerState(); P.id = item.Key; P.excel_config = item.Value; P.stateEntity = state_config[P.id]; if (clipLength.TryGetValue(item.Value.anm_name, out var length_clip)) {  P.clipLength = length_clip; } stateData[item.Key] = P; } //事件技能赋值  stateData[1005].skill = SkillData.Get(unitEntity.ntk1); stateData[1006].skill = SkillData.Get(unitEntity.ntk2); stateData[1007].skill = SkillData.Get(unitEntity.ntk3); stateData[1008].skill = SkillData.Get(unitEntity.ntk4); stateData[1009].skill = SkillData.Get(unitEntity.skill1); stateData[1010].skill = SkillData.Get(unitEntity.skill2); stateData[1011].skill = SkillData.Get(unitEntity.skill3); stateData[1012].skill = SkillData.Get(unitEntity.skill4); //添加事件监听器 foreach (var item in stateData ) { if(item.Value.excel_config.on_move != null) { AddListener(item.Key, StateEventType.update, OnMove); } if (item.Value.excel_config.do_move == 1) { AddListener(item.Key, StateEventType.update, PlayerMove); } if (item.Value.excel_config.on_stop != 0) { AddListener(item.Key, StateEventType.update, OnStop); } if (item.Value.excel_config.on_jump != null) { for (int i = 0; i  0) { AddListener(item.Key, StateEventType.update, AddForwardMove); } if (item.Value.excel_config.on_atk != null) { AddListener(item.Key, StateEventType.update, OnAtk); } if (item.Value.excel_config.on_skill1 != null) { AddListener(item.Key, StateEventType.update, OnSkill1); } if (item.Value.excel_config.on_skill2 != null) { AddListener(item.Key, StateEventType.update, OnSkill2); } if (item.Value.excel_config.on_skill3 != null) { AddListener(item.Key, StateEventType.update, OnSkill3); } if (item.Value.excel_config.on_skill4 != null) { AddListener(item.Key, StateEventType.update, OnSkill4); } if (item.Value.excel_config.on_defense != null) { AddListener(item.Key, StateEventType.update, OnDefense); } if (item.Value.excel_config.on_defense_quit != 0) { AddListener(item.Key, StateEventType.update, OnDefenseQuit); } if (item.Value.excel_config.on_sprint != null) { AddListener(item.Key, StateEventType.update, OnSprint); } if (item.Value.excel_config.on_pow_atk != null) { AddListener(item.Key, StateEventType.update, OnPowAtk); } if (item.Value.excel_config.do_rotate != 0) { AddListener(item.Key, StateEventType.update, DORotate); } if (item.Value.stateEntity.ignor_collision == true) { AddListener(item.Key, StateEventType.begin, DisableCollider); AddListener(item.Key, StateEventType.end, EnableCollider); } } }

加载状态配置资源

StateScriptableObject anmConfig;anmConfig = Resources.Load($\"StateConfig/{id}\");//这里的id是初始值1001,在可视化面板编辑的,也是路径stateConfig的子物体名称Dictionary state_config = new Dictionary();foreach (var item in anmConfig.states){ state_config[item.id] = item;}
  • 作用:从Unity资源系统加载ScriptableObject格式的状态配置

  • 细节

    • Resources.Load:从\"Resources/StateConfig\"目录加载指定ID的配置资源

    • 创建 state_config 字典:将配置中的状态数据按ID索引存储

    • 配置内容:包含状态ID、动画名称、过渡条件等状态机参数

获取动画剪辑长度

var clips = _animator.runtimeAnimatorController.animationClips;Dictionary clipLength = new Dictionary();foreach (var clip in clips){ clipLength[clip.name] = clip.length;}
  • 细节

    • _animator.runtimeAnimatorController.animationClips:获取角色Animator上的所有动画片段

    • 创建 clipLength 字典:以动画名称为键,存储对应动画的长度(秒)

    • 目的:为后续状态配置提供精确的动画时长数据

       

_animator.runtimeAnimatorController.animationClips
部分 类型 说明 _animator 对象引用 类字段/属性(通常是 Unity 的 Animator 组件实例) .runtimeAnimatorController 属性访问 Animator 组件的属性,使用此表示可在运行时期间更改 Animator Controller。 .animationClips 属性访问 RuntimeAnimatorController 的属性,返回包含的动画剪辑数组
foreach (var item in PlayerStateData.all) { PlayerState P = new PlayerState(); P.id = item.Key; P.excel_config = item.Value; stateData[item.Key] = P; // 存储到字典}

        将PlayerStateData中记录的Excel配置表(Excel表格已经转存为Unity数据了) ,通过遍历将相应数据ID和数据信息存储至新的字典stateData中。

题外话:        

  • 当外部访问 All 属性时,直接返回 entityDic 字典本身(而非副本)

  • 第一个参数 1001 就是这个对象的 id,即e0.id;这个id作为PlayerStateEntity类型e0数据信息的一部分。

static PlayerStateData() { entityDic = new Dictionary(44); PlayerStateEntity e0 = new PlayerStateEntity(1001,@\"待机\",@\"idle\",0,new float[]{1f,1f,1002f},0,new float[]{1f,1f,1019f},new float[]{1f,1f,1005f},new float[]{1f,1f,1014f},new float[]{1f,1f,1013f},0,new float[]{1f,1f,1020f},0,new float[]{1f,1f,1009f},new float[]{1f,1f,1010f},new float[]{1f,1f,1011f},new float[]{1f,1f,1012f},new int[]{1015,1016},0,new int[]{1017,1018},new int[]{1028,1029},0,null,0f,1,1,5f,1,0,0f,0f); entityDic.Add(e0.id, e0); ……//还有40组这类结构的寄存 }public static Dictionary all{ get { return entityDic; } }

      返回的实体为玩家状态实体:

PlayerStateEntity e0 = new PlayerStateEntity( id: 1001, info: @\"待机\",  // 状态说明:待机 anm_name: @\"idle\", // 动画名称:idle on_anm_end: 0,  // 动画结束时无操作 on_move: new float[]{1f,1f,1002f},// 移动时切换到ID 1002(跑) on_stop: 0, // 停止移动时无操作 on_pow_atk: new float[]{1f,1f,1019f}, // 蓄力攻击时切换到ID 1019 on_atk: new float[]{1f,1f,1005f}, // 普攻时切换到ID 1005(普攻1) on_sprint: new float[]{1f,1f,1014f}, // 冲刺时切换到ID 1014(突进) on_defense: new float[]{1f,1f,1013f}, // 格挡时切换到ID 1013(格挡起手) on_defense_quit: 0,  // 取消格挡时无操作 on_jump: new float[]{1f,1f,1020f}, // 跳跃时切换到ID 1020(跳跃) on_jump_end: 0,  // 跳跃结束时无操作 on_skill1: new float[]{1f,1f,1009f}, // 技能1时切换到ID 1009 on_skill2: new float[]{1f,1f,1010f}, // 技能2时切换到ID 1010 on_skill3: new float[]{1f,1f,1011f}, // 技能3时切换到ID 1011 on_skill4: new float[]{1f,1f,1012f}, // 技能4时切换到ID 1012 on_hit: new int[]{1015,1016}, // 受击时切换到ID 1015(前受击)或1016(后受击) tag: 0, // 标签:0(无特殊标签) on_bash: new int[]{1017,1018}, // 重击时切换到ID 1017(前击飞)或1018(后击飞) on_death: new int[]{1028,1029}, // 死亡时切换到ID 1028(前死亡)或1029(后死亡) on_block_succes: 0,  // 成功格挡时无操作 be_block: null,  // 被格挡时无操作 trigger_atk: 0f,  // 攻击决策调度概率:0(不调度) trigger_dodge: 1,  // 触发躲闪:1(允许) first_strike: 1,  // 触发抢攻:1(允许) active_attack: 5f, // 随机发起攻击概率:5% trigger_pacing: 1, // 进入踱步状态:1(允许) do_move: 0, // 执行移动:0(不移动) do_rotate: 0f,  // 朝向控制:0(不旋转) add_f_move: 0f  // 叠加正向位移:0(无位移));

        StateData[]数组即是存储不同状态下的对应全部状态表。如以上就是id=1001的待机状态表与待机状态下全部的状态表

 //PlayerState的类public class PlayerState{ public int id; //配置表 public PlayerStateEntity excel_config; internal float clipLength; public SkillEntity skill; //动画通知事件 public float begin_time;}//Dictionary stateData = new Dictionary();stateData[1005].skill = SkillData.Get(unitEntity.ntk1);... 类似处理 1006-1008 (ntk2-ntk4) 和 1009-1012 (skill1-skill4)

        UnitEntity中的存储数据如下:

        SkillData取出储存到UnitEntity字典中的ntk1的int,赋值到stateData的skill板块中,让技能状态效果表连接到stateData的skill去:

 foreach (var item in stateData ) { if(item.Value.excel_config.on_move != null) { Addlinstenr(item.Key, StateEventType.update, OnMove); } if (item.Value.excel_config.do_move == 1) { Addlinstenr(item.Key, StateEventType.update, PlayerMove); } if (item.Value.excel_config.on_stop != 0) { Addlinstenr(item.Key, StateEventType.update, OnStop); } } }

 1. 遍历 stateData

foreach (var item in stateData)
  • stateData 是一个 Dictionary 类型的数据结构,其中:
    • Key 是状态 ID(int)
    • Value 是某种包含 Excel 配置的对象(比如 StateConfig

2. 检查 on_move 是否不为 null,若不为null则开始移动

if(item.Value.excel_config.on_move != null){ Addlinstenr(item.Key, StateEventType.update, OnMove);}
  • 如果当前状态的 excel_config.on_move 不为 null,则添加一个更新事件监听器 OnMove
  • 使用 Addlinstenr 方法将 OnMove 注册到对应的状态 ID 和事件类型上

 3. 检查 do_move == 1

if (item.Value.excel_config.do_move == 1){ Addlinstenr(item.Key, StateEventType.update, PlayerMove);}
  • 如果 do_move 的值为 1,则添加另一个更新事件监听器 PlayerMove

4. 检查 on_stop != 0

if (item.Value.excel_config.on_stop != 0){ Addlinstenr(item.Key, StateEventType.update, OnStop);}
  • 如果 on_stop 不等于 0,则添加一个更新事件监听器 OnStop

AddListener方法(监听状态)

 public Dictionary<int, Dictionary<StateEventType, List>> actions = new Dictionary<int, Dictionary<StateEventType, List>>(); public void Addlinstenr(int id, StateEventType t, Action action) { // 1. 确保外层字典有id对应的条目 if (!actions.TryGetValue(id, out var innerDict)) { innerDict = new Dictionary<StateEventType, List>(); actions[id] = innerDict; } // 2. 确保内层字典有t对应的列表 if (!innerDict.TryGetValue(t, out var actionList)) { actionList = new List(); innerDict[t] = actionList; } // 3. 添加action到列表 actionList.Add(action); }
public Dictionary<int, Dictionary<StateEventType, List>> actions = new Dictionary<int, Dictionary<StateEventType, List>>();
  1. 数据结构

    Dictionary<int, Dictionary<StateEventType, List>> actions
    • 外层字典:键为 int 类型的 id,表示唯一标识(如对象ID)。

    • 内层字典:键为 StateEventType(事件类型枚举),值为 List

    • List:存储多个无参数、无返回值的委托(方法),表示需要执行的操作。

      • ActionC# 中的一个预定义委托(delegate)类型Action 是一个没有返回值(void)、没有参数的委托类型。

  2. 方法 Addlinstenr(应为 AddListener

    public void Addlinstenr(int id, StateEventType t, Action action)
    • 功能:为指定 id ,为其事件类型 t 添加一个处理函数 action

    • 且在内外层的索引对应条目能找到的话,对内外层的索引内容进行赋值。

   

示例说明

我们有如下几个函数,它们都是 Action 类型(无参数、无返回值):

void OnMove(){ Debug.Log(\"OnMove\");}void PlayerMove(){ Debug.Log(\"PlayerMove\");}void OnStop(){ Debug.Log(\"OnStop\");}

还有一个枚举:

public enum StateEventType{ update, start, stop}

🧪 场景模拟:

我们调用 Addlinstenr 多次,模拟添加多个事件监听器。

📌 第一次调用:

Addlinstenr(100, StateEventType.update, OnMove);

执行流程:

  1. actions 中没有 id = 100
    • TryGetValue 返回 false
    • 创建一个新的 innerDictnew Dictionary<StateEventType, List>();
    • 把 innerDict 添加到 actions[100]
  2. innerDict 中没有 StateEventType.update
    • 创建一个新的 actionList = new List()
    • 把 actionList 添加到 innerDict[StateEventType.update]
  3. 把 OnMove 加入这个列表

此时:

actions[100][update] = [OnMove]

📌 第二次调用:

Addlinstenr(100, StateEventType.update, PlayerMove);

执行流程:

  1. actions[100] 已存在:
    • innerDict 被取出
  2. innerDict[update] 存在:
    • actionList 被取出
  3. 把 PlayerMove 加入这个列表
actions[100][update] = [OnMove, PlayerMove]

📌 第三次调用:

Addlinstenr(100, StateEventType.stop, OnStop);

执行流程:

  1. actions[100] 存在 → 取出 innerDict
  2. innerDict[stop] 不存在:
    • 创建新的 List
    • 存入 innerDict[stop]
  3. 把 OnStop 加入列表

现在:

actions[100][update] = [OnMove, PlayerMove]actions[100][stop] = [OnStop]

📌 第四次调用:

Addlinstenr(200, StateEventType.update, OnMove);

执行流程:

  1. actions[200] 不存在 → 创建新 innerDict
  2. innerDict[update] 不存在 → 创建新 List
  3. 把 OnMove 加入列表
actions[200][update] = [OnMove]

✅ 最终结构示意图

actions = { 100: { update: [OnMove, PlayerMove], stop: [OnStop] }, 200: { update: [OnMove] }}

SkillData 类

using System.Collections.Generic;using UnityEngine;namespace Game.Config{ public class SkillData { static SkillData() { entityDic = new Dictionary(25); SkillEntity e0 = new SkillEntity(10011,0,0f,5,20f,0f,0.3f,0,3f); …… SkillEntity e24 = new SkillEntity(30019,0,7.8f,5,113f,0f,5f,0,3f); entityDic.Add(e24.id, e24); } public static Dictionary all { get { return entityDic; } }static Dictionary entityDic;public static SkillEntity Get(int id){ if (entityDic!=null&&entityDic.TryGetValue(id,out var entity)){return entity;} return null;} } public class SkillEntity { //TemplateMemberpublic int id;//技能ID……public float atk_distance;//施法距离 public SkillEntity(){} public SkillEntity(int id,int tag,float cd,int hit_max,float phy_damage,float magic_damage,float add_fly,int ignor_collision,float atk_distance){  this.id = id; …… this.atk_distance = atk_distance; } }}

        与UnitData同理:根据技能ID找寻到该技能的全部效果作为实例传递给entityDic后,等待被调用SkillData.Get方法后赋值:

        得到的实例如下:

    运动状态的控制和切换

     具体详细流程请看

     To Next方法

     public bool ToNext(int Next) { if (stateData.ContainsKey(Next))//如果导入进来的状态已经是当前状态 { if (currentPlayerstate != null) { Debug.Log($\"{this.gameObject.name}:切换状态:{stateData[Next].Info()} 当前状态:{currentPlayerstate.Info()} \"); } else { Debug.Log($\"{this.gameObject.name}: 切换状态:{stateData[Next].Info()} \"); } if (currentPlayerstate != null) { DOStateEvent(currentPlayerstate.id, StateEventType.end);//状态绑定的退出事件 ServicesOnEnd(); } currentPlayerstate = stateData[Next]; currentPlayerstate.SetBeginTime(); //执行当前状态的开始(进入)事件 DOStateEvent(currentPlayerstate.id, StateEventType.begin); ServiceOnBegin(); return true; } return false; }
    1. 状态存在性检查

      csharpif (stateData.ContainsKey(Next)) // Next = 1001
      • 检查字典中是否存在ID为1001的状态配置

    2. 日志输出

      csharpif (currentPlayerstate != null) { Debug.Log($\"{this.gameObject.name}:切换状态:{stateData[Next].Info()}...\");}else { Debug.Log($\"{this.gameObject.name}: 切换状态:{stateData[Next].Info()} \");}
      • 由于是首次初始化,currentPlayerstatenull,执行else分支

      • 输出类似:\"PlayerObject: 切换状态:1001_待机状态\"

    3. 结束旧状态处理

      csharpif (currentPlayerstate != null){ DOStateEvent(currentPlayerstate.id, StateEventType.end); ServicesOnEnd();}
      • 当前无旧状态,跳过此段代码

    4. 设置新状态

      csharpcurrentPlayerstate = stateData[Next]; // 获取ID=1001的状态对象currentPlayerstate.SetBeginTime(); // 记录状态开始时间
    5. 触发新状态开始事件

      csharpDOStateEvent(currentPlayerstate.id, StateEventType.begin);
      • 执行所有注册到状态1001的begin类型事件

      • 通过Addlinstenr添加的事件处理函数会被触发

    6. 服务系统初始化

      csharpServiceOnBegin();
      • 调用所有服务的OnBegin方法(如AnimationService)

      • 服务系统开始为当前状态工作

    7. 返回结果

      csharpreturn true;
      • 表示状态切换成功

     SetBeginTime方法

    public void SetBeginTime() { begin_time = Time.time;// 记录开始时刻 }

     DOStateEvent方法(执行事件的方法)

            这里需要知道的是:通过状态初始化,action记录着已注册的行为状态表 

    public void DOStateEvent(int id, StateEventType t) { if (actions.TryGetValue(id, out var v)) { if (v.TryGetValue(t, out var lst)) { for (int i = 0; i < lst.Count; i++) {  lst[i].Invoke(); } } } }
    1. 检查状态是否存在:(action数据请查看AddListener方法)

      csharpif (actions.TryGetValue(id, out var v))//查找当前状态1001的所有事件类型如Begin/Update
      • 在全局事件字典 actions 中查找指定状态ID

      • 如果存在,将事件字典赋值给变量 v

    2. 检查事件类型是否存在

      csharpif (v.TryGetValue(t, out var lst))//查找当前状态1001当前事件类型(Begin)的对应方法(待机回血/播放待机语音)
      • 在状态的事件字典中查找特定事件类型(begin/update等)

      • 如果存在,将事件列表赋值给 lst

    3. 执行所有注册的方法

      csharpfor (int i = 0; i < lst.Count; i++){ lst[i].Invoke();}
      • 遍历该事件类型下的所有注册方法

      • 逐个调用(Invoke)这些方法

    ServiceOnBegin方法

            通过ServiceInit方法设置初始的服务状态,并且将初始服务状态都存储到数组Fsmservice中:

    public void ServiceOnBegin() { for (int i = 0; i < service_count; i++) { fSMService[i].OnBegin(currentPlayerstate); } }

    1、当id=1001时,先执行Awake方法中的ServiceInit方法,添加初始状态的服务体,

    2、AddService() 创建动画服务对象(也可以添加其他服务对象)

    3、再执行Awake方法中的ToNext(1001)方法,执行ServiceOnBegin,对当前已有服务对象进行激活。

    Update方法+服务体更新ServiceOnUpdate 

     void Update() { if (currentPlayerstate != null) {if (ServiceOnUpdate() == true) { DOStateEvent(currentPlayerstate.id, StateEventType.update);//状态每帧执行的事件 }  } } 

    Update() 方法的作用

    • 状态更新协调

      • 在 Update() 方法中每帧都会调用 ServiceOnUpdate() 方法,查看是否需要切换。

      • 事件触发条件​​:仅当 ServiceOnUpdate() 返回 true(即所有服务更新完成且状态未改变)时,触发 DOStateEvent 事件,执行与当前状态相关的每帧逻辑(如动画播放、物理效果更新)。

        • 若返回 true(状态未变,即id=1001未发生改变),执行当前状态的每帧逻辑(DOStateEvent)。

        • 若返回 false(状态已变,即id=1001改变成id=1002),跳过当前帧的状态更新(避免旧状态逻辑干扰新状态)。

    public bool ServiceOnUpdate() { int crn_state_id = currentPlayerstate.id; // 保存当前玩家状态的 ID for (int i = 0; i < service_count; i++) { fSMService[i].OnUpdate(animationService.normalizedTime, currentPlayerstate); // 调用每个服务的 OnUpdate 方法 if (currentPlayerstate.id != crn_state_id) // 检查玩家状态是否改变 { return false; // 如果状态改变,返回 false } } return true; // 如果状态没有改变,返回 true }

    ServiceOnUpdate() 方法的作用

    • 记录当前状态ID:保存进入时的状态ID(crn_state_id)。

    • 遍历所有服务:调用每个服务的 OnUpdate() 方法(可能包含条件检测,如“血量低于30%触发受伤状态”)

    • 实时检测状态变化

      • 若某个服务触发了状态切换(currentPlayerstate.id 改变),立即中断循环并返回 false

      • 若所有服务执行后状态未变,返回 true

    重载virtue方法 

    public bool ServiceOnUpdate() { int crn_state_id = currentState.id; // 保存当前玩家状态的 ID for (int i = 0; i < service_count; i++) { fSMService[i].OnUpdate(animationService.normalizedTime, currentState); // 调用每个服务的 OnUpdate 方法 if (currentState.id != crn_state_id) // 检查玩家状态是否改变 { return false; // 如果状态改变,返回 false } } return true; // 如果状态没有改变,返回 true }
    public class FSMServiceBase{ public FSM player; //每一帧更新 public virtual void OnUpdate(float normaizedTime,PlayerState state) { }}//AnimationService是FSMServiceBase的基类public class AnimationService : FSMServiceBase{ public float normalizedTime;//当前动作播放进度 public string now_play_id; public override void OnUpdate(float normaizedTime, PlayerState state) { base.OnUpdate(normaizedTime, state); if (!string.IsNullOrEmpty(now_play_id)) { ……………… } //判定播放动作是否与配置一致? } }

    在代码中,fSMService[i].OnUpdate(animationService.normalizedTime, currentState) 调用的具体实现取决于 fSMService[i] 的实际类型:

    1. 多态机制
      由于 FSMServiceBase 中的 OnUpdate 是 virtual 方法,且 AnimationService 通过 override 重写了该方法,实际调用的是对象的运行时类型(实际类型)的 OnUpdate 方法。

    2. 具体调用逻辑

      • 如果 fSMService[i] 是 AnimationService 实例 → 调用 AnimationService.OnUpdate()
        (如代码中通过 now_play_id 检查动画状态并更新 normalizedTime

      • 如果 fSMService[i] 是 FSMServiceBase 其他子类的实例 (如—_ObjSerivce)→ 调用子类重写的 OnUpdate()

      • 如果未重写(如直接使用 FSMServiceBase)→ 调用基类默认的空方法(base.OnUpdate

    OnMove方法

     public void OnMove() { //如果垂直或者水平方向输入不为0,说明发生了移动 if (UInput.GetAxis_Horizontal() != 0 || UInput.GetAxis_Vertical() != 0) { if (CheckConfig(currentPlayerstate.excel_config.on_move)) { ToNext((int)currentPlayerstate.excel_config.on_move[2]); } } }
    • excel_config.on_move​:假设配置数组为 [02,08,1005](@ref)
      • config[0](@ref)= 0.2:动画前20%时间可触发。
      • config[1](@ref)= 0.8:动画后20%时间可触发。
      • config[2](@ref)= 1005:目标状态ID(移动状态)。
    • ​触发条件​​:
      • 当动画播放到 ​​0-20%​​ 或 ​​80-100%​​ 时,检测到移动输入则切换到状态 1005

    CheckConfig

     public bool CheckConfig(float[] config) { if (config == null) { return false; } else { if ((animationService.normalizedTime >= 0 && animationService.normalizedTime = config[1] && animationService.normalizedTime <= 1)) { return true; } return false; } }

    动画播放进度 → CheckConfig() 检查├─ 时间在 [0, config[0]] → 返回 true├─ 时间在 [config[1], 1] → 返回 true└─ 其他情况 → 返回 false
    • 该段代码的作用是根据动画的播放进度(normalizedTime)和传入的配置参数(config 数组)判断当前是否处于有效的操作时间窗口。     
      • 第一个时间窗口:动画开始阶段 [0, config[0]]

      • 第二个时间窗口:动画结束阶段 [config[1], 1]

    • 如果当前动画进度落在任一窗口内,返回 true;否则返回 false
       

    • 具体含义

      • 当动画进度在 0% → config[0]% 范围内时,系统认为处于\"动画开始阶段\"

      • 例如:config[0] = 0.2 表示动画前 20% 的时间段

    • 设计目的

      • 允许在动画刚开始播放时触发特定操作

      • 常见用例:

        • 攻击动画:前10%时间允许取消技能

        • 跳跃动画:前5%时间允许中断起跳

        • 受击动画:前15%时间播放受击特效

    PlayOnMove 方法

     private void PlayerMove() { var x = UInput.GetAxis_Horizontal(); var z = UInput.GetAxis_Vertical(); if (x != 0 || z != 0) { Vector3 inputDirection = new Vector3(x, 0f, z).normalized; //Mathf.Atan2 正切函数 求弧度 * Mathf.Rad2Deg(弧度转度数) >> 度数 //第一:先求出输入的角度 //第二:加上当前相机的Y轴旋转的量 //第三:得到目标朝向的角度 _targetRotation = Mathf.Atan2(inputDirection.x, inputDirection.z) * Mathf.Rad2Deg + GameDefine._Camera.transform.eulerAngles.y; //做一个插值运动 float rotation = Mathf.SmoothDampAngle(_transform.eulerAngles.y, _targetRotation, ref _rotationVelocity, RotationSmoothTime); //角色先旋转到目标角度去 // rotate to face input direction relative to camera position _transform.rotation = Quaternion.Euler(0.0f, rotation, 0.0f); //计算目标方向 通过这个角度 Vector3 targetDirection = Quaternion.Euler(0.0f, _targetRotation, 0.0f) * Vector3.forward; Move(targetDirection.normalized * (_speed * GameTime.deltaTime), false, false, false, true); } }

    1. 输入方向归一化

    csharpVector3 inputDirection = new Vector3(x, 0f, z).normalized;
    • 数学逻辑:将原始输入值 (x, z) 转换为单位向量(长度为1)。

    • 目的:消除不同输入强度(如轻推摇杆 vs 全推摇杆)对移动速度的影响,确保移动方向准确。


    2. 计算目标旋转角度

    csharp_targetRotation = Mathf.Atan2(inputDirection.x, inputDirection.z) * Mathf.Rad2Deg  + GameDefine._Camera.transform.eulerAngles.y;
    • 数学逻辑

      • Mathf.Atan2(x, z):计算输入方向相对于 Z轴正方向(世界前方) 的弧度角。

        • 例如:输入 (0,1) → 角度 (正前),输入 (1,0) → 角度 90°(正右)。

      • * Mathf.Rad2Deg:将弧度转换为角度(0~360°)。

      • + Camera.eulerAngles.y:叠加相机的Y轴旋转角度。

    • 目的:将输入方向局部坐标系(相对于相机)转换为世界坐标系(相对于地图)。

      • 示例:相机旋转 90° 时,玩家按“前”键 → 输入方向 (0,1) → 实际世界方向 (1,0)


    3. 平滑旋转插值

    float rotation = Mathf.SmoothDampAngle( _transform.eulerAngles.y, // 当前角度 _targetRotation, // 目标角度 ref _rotationVelocity, // 当前角速度(引用传递) RotationSmoothTime  // 平滑时间);_transform.rotation = Quaternion.Euler(0.0f, rotation, 0.0f);


    4. 计算世界空间移动方向

    Vector3 targetDirection = Quaternion.Euler(0.0f, _targetRotation, 0.0f) * Vector3.forward;
    1. ​四元数构造​​:
      Quaternion.Euler(0, _targetRotation, 0) 创建一个绕Y轴旋转 _targetRotation 度的四元数。例如:

      • 若 _targetRotation = 90°,则生成绕Y轴顺时针旋转90度的四元数。
    2. ​向量旋转​​:
      * Vector3.forward 表示将默认的​​世界坐标系前方向量​​(即 (0,0,1))应用该旋转。

      • 例如:当Y轴旋转90度时,Vector3.forward 会被旋转到世界坐标系的X轴正方向((1,0,0))。
    3. ​坐标系转换​​:
      该运算等效于将角色当前的​​本地前方向量​​(即角色面朝方向)转换为世界坐标系下的目标方向。(这里涉及到四元数与向量的乘法计算,这里就抽象记忆只要Y轴旋转的局部坐标按照该模板输入都能顺利转为适宜的全局坐标。)

      • 若角色未旋转(_targetRotation=0),结果为 (0,0,1)
      • 若角色向右旋转90度(_targetRotation=90),结果为 (1,0,0)

    5、移动方法

     Move(targetDirection.normalized * (_speed * GameTime.deltaTime), false, false, false, true);

    • targetDirection.normalized:
      • 将任意长度的方向向量转换为单位向量(长度为1)

      • 确保速度值精确(避免对角线移动时速度变快)

      • 示例:输入(1,0,1) → 归一化为(0.707,0,0.707)

    • _speed * GameTime.deltaTime:

      • 物理意义:速度 × 时间 = 距离

      • 游戏实现:将每秒移动距离转换为每帧移动距离

      • 示例:速度2m/s,帧时间0.02s → 每帧移动0.04米

    • 方向单位向量 × 距离标量 = 三维位移向量

      • 方向:(0.707, 0, 0.707)

      • 距离:0.04m

      • 结果:(0.0283, 0, 0.0283)

           

    Move方法

     bool ground_check = false; public void Move(Vector3 d, bool transformDirection, bool frame = true, bool _Add_Gravity = true, bool _do_ground_check = true) { if (transformDirection) { d = this._transform.TransformDirection(d); } Vector3 d2; if (_Add_Gravity) { d2 = (d+GameDefine._Gravity ) * (frame ? GameTime.deltaTime : 1); } else { d2 = (d) * (frame ? GameTime.deltaTime : 1); } characterController.Move(d2); //UDebug.LogError(\"xxxxxxxxxxxx:\" + d2); if (_do_ground_check) { ground_check = true; } }}

    public void Move(Vector3 d, bool transformDirection, bool frame = true,  bool _Add_Gravity = true, bool _do_ground_check = true)
    1. d:基础移动向量

    2. transformDirection:是否将向量从局部空间转换到世界空间

    3. frame:是否考虑时间增量(默认true)

    4. _Add_Gravity:是否添加重力(默认true)

    5. _do_ground_check:是否执行地面检测(默认true)


    1. 坐标系转换(可选)

    csharpif (transformDirection) { d = this._transform.TransformDirection(d);}

            若transformDirection=true,将输入方向d从世界空间转换到角色局部空间(例如:按\"W\"键时角色向前移动,而非固定世界坐标的Z轴)。

    • 作用:当transformDirection=true时,将输入向量 d 被解释为相对于当前游戏对象(this._transform)的局部坐标系,这个方法的使用就将角色局部空间转换为世界空间

    • 示例:如果角色面朝右(X+),输入(0,0,1)会转换为(1,0,0)


    2. 重力处理

    csharpVector3 d2;if (_Add_Gravity) { d2 = (d + GameDefine._Gravity) * (frame ? GameTime.deltaTime : 1);} else { d2 = d * (frame ? GameTime.deltaTime : 1);}
    • 重力添加

      • 如果设置重力_Add_Gravity=true:将重力向量加到移动向量上(通常为负Y方向)

        • ​物理意义​​:
          这是将当前方向向量 d 与重力向量 GameDefine._Gravity 进行叠加,得到总的影响向量。
          比如向前跳跃时,既有水平向前的速度,也有垂直下落的重力速度

      • GameDefine._Gravity 为 new Vector3(0, -9.8f, 0)

    • 时间处理

      • (frame ? GameTime.deltaTime : 1)是一个三元运算符,根据 frame 的值选择时间缩放因子:
        • frame=true:乘以GameTime.deltaTime使移动与帧率无关

          • frame 为假​​:直接使用 1(无时间缩放)。
          • frame 为真​​:使用 GameTime.deltaTime(上一帧到当前帧的时间间隔)。
        • frame=false:直接使用原始向量(可能用于特殊动画或瞬移)

          • GameTime.deltaTime​:表示上一帧到当前帧的实际时间间隔(单位:秒),例如:
            • 30 FPS 时,deltaTime ≈ 0.0333 秒(每帧约 33.3 毫秒)
            • 60 FPS 时,deltaTime ≈ 0.0167 秒(即每帧约 16.7 毫秒)。
          • 帧率无关化的理解:
            假设物体速度为 d,则:确保每秒移动距离为 d * 速度系数,与帧率无关。例如:假设 d = (5, 0, 0)(向右移动),_Add_Gravity = false,则// 高帧率(60 FPS)d2 = (5, 0, 0) * 0.0167 ≈ (0.0835, 0, 0) 每帧每秒总位移 = 0.0835 * 60 ≈ 5 单位// 低帧率(30 FPS)d2 = (5, 0, 0) * 0.0333 ≈ (0.1665, 0, 0) 每帧每秒总位移 = 0.1665 * 30 ≈ 5 单位

    3. 执行移动

    csharpcharacterController.Move(d2);
    • 调用Unity的CharacterController组件执行实际移动

    • 自动处理碰撞检测和物理响应


    4. 地面检测标记

    csharpif (_do_ground_check) { ground_check = true;}
    • 设置标记通知系统需要更新地面状态

    • 实际检测可能在FixedUpdate或其他位置执行

    运动状态的结束判定 

    动画结束回调方法AnimationOnPlayEnd

            这段代码是一个动画播放结束时的回调方法,主要处理动画结束后的状态逻辑。 

     public void AnimationOnPlayEnd() { var _id = currentState.id; DOStateEvent(currentState.id, StateEventType.onAnmEnd); ServicesOnAnimationEnd(); if (currentState.id != _id) { return; } switch (currentState.excel_config.on_anm_end) { case 1: break; case 0: ServicesOnReStart(); return; default: ToNext(currentState.excel_config.on_anm_end); break; } }

    1. 保存当前状态ID

    csharpvar _id = currentState.id;
    • 记录当前状态的唯一标识 id,用于后续检查状态是否被外部修改。


    2. 触发状态事件

    csharpDOStateEvent(currentState.id, StateEventType.onAnmEnd);
    • 发送动画结束事件 (StateEventType.onAnmEnd),其他模块可能监听此事件并修改状态(如强制中断、跳转等)。


    3. 执行服务层逻辑

    csharpServicesOnAnimationEnd();
    • 调用与动画结束相关的服务方法(如资源清理、数据上报等)。


    4. 关键状态校验

    csharpif (currentState.id != _id){ return; // 状态已变更,直接退出}
    • 防干扰设计:检查当前状态ID是否与最初保存的 _id 一致。

    • 若不一致,说明在 DOStateEvent 或 ServicesOnAnimationEnd 中触发了状态切换(如跳转到新状态),此时直接退出,避免执行无效操作。


    5. 根据配置执行动画结束策略

    csharpswitch (currentState.excel_config.on_anm_end){ case -1: // 保持当前状态(无操作) break; case 0: // 重启当前状态 ServicesOnReStart(); // 执行重启逻辑 return;  // 直接退出(不再执行后续代码) default: // 跳转到指定状态 ToNext(currentState.excel_config.on_anm_end); // 跳转到配置ID对应的状态 break;}
    • 配置策略说明

      • on_anm_end = -1:动画结束后停留在当前状态(break 后方法自然结束)。

      • on_anm_end = 0:重启当前状态(调用 ServicesOnReStart() 后退出)。

      • 其他值(如2/3/100):将配置值作为目标状态ID,调用 ToNext 跳转。

     碰撞启用EnableCollider与禁用DisableCollide

     private void EnableCollider() { characterController.excludeLayers = 0;// 设置为0表示不排除任何层 }
    • 作用启用与所有层的碰撞检测

    • 行为:将 excludeLayers 设为 0(二进制全0),表示角色控制器不再忽略任何碰撞层,可以与场景中所有物体发生碰撞。

    • 使用场景:通常用于需要恢复完整碰撞时(如角色结束无敌状态、恢复正常交互时)。

     private void DisableCollider() { characterController.excludeLayers = GameDefine.Enemy_LayerMask; // 设置为敌人层的掩码 }
    • 作用禁用与指定层的碰撞检测(此处针对敌人层)。

    • 行为

      • GameDefine.Enemy_LayerMask 是一个预定义的层掩码(如 1 << 8),代表\"敌人\"所在的层级。

      • 设置后,角色控制器会忽略与敌人层物体的碰撞(角色可穿过敌人)。

    • 使用场景:通常用于技能无敌状态、过场动画等需要临时避免与敌人碰撞的情况

    特效物体控制 GetHangPoint

            核心作用:通过字典缓存机制,​​快速获取场景中指定名称的GameObject​,避免重复调用Unity的Find方法(该方法性能较低)。适用于需要频繁访问特定游戏对象的场景(如UI管理、动态加载对象等)。  

            这里有两处引用,在HitService中抓取起点位置空物体,在ObjService中抓取特效物体

    //将物体特效做成字典存储起来Dictionary hangPoint = new Dictionary();internal GameObject GetHangPoint(string o_id){ if (hangPoint.TryGetValue(o_id, out var x)) // 尝试从字典获取 { return x; // 缓存命中,直接返回 } var go = _transform.Find(o_id); // 未命中,调用Unity的Find方法 if (go != null) { hangPoint[o_id] = go.gameObject; // 缓存找到的对象 return go.gameObject; } else { hangPoint[o_id] = null; // 缓存未找到的结果 return null; }}
    Dictionary hangPoint = new Dictionary();

    这里注意类型定义​​:键为string(对象名称),值为GameObject(游戏对象)

     攻击和受击相关接口

    Attack_Hitlag方法

     internal void Attack_Hitlag(PlayerState state) { hitlagService.DOHitlag_OnAttack(animationService.normalizedTime, state); }

            用来作为事件触发 

    单例类Main

    1. 全局初始化的中心节点

    • 作用:作为游戏启动时的核心初始化入口(在Awake中调用SystemInit())。

    • 必要性
      Unity需要场景中的激活GameObject挂载脚本才能执行Awake/Start。空物体作为轻量级载体,确保初始化代码在场景加载时自动运行。

    • 优势
      避免将初始化逻辑分散到多个物体上,集中管理游戏启动流程(如配置加载、事件绑定)。

    2. 关键系统依赖的宿主

    • 时间缩放控制(Hitlag)
      DOHitlag方法通过协程修改Time.timeScale,需挂载在激活物体上(协程依赖MonoBehaviour)。        

      • 协程的载体要求
        Unity 的协程系统 (IEnumerator + yield必须通过 MonoBehaviour.StartCoroutine() 启动。而 MonoBehaviour 只能存在于挂载在场景 GameObject 上的脚本中。

      • 帧等待的引擎依赖
        yield return new WaitForEndOfFrame() 需要 Unity 的帧循环系统驱动,只有场景中激活的 GameObject 上的脚本才能接入此循环。

      • 时间缩放的作用域
        Time.timeScale 是全局状态,修改它会影响整个游戏。需要一个持久存在且权威的控制器,避免多物体竞争修改导致状态混乱。

    • 事件系统桥梁
      GameEvent.DOHitlag = DOHitlag 将事件绑定到实际方法,需物体持续存在以确保事件触发有效。

      • 委托绑定的生命周期问题
        当 GameEvent.DOHitlag 委托被赋值指向 Main.DOHitlag 方法时,它实际绑定的是当前 Main 实例。如果该实例被销毁(如场景切换),委托将指向无效内存,触发 NullReferenceException

      • 事件触发可靠性
        游戏中的攻击判定可能在任何时间发生(如角色技能、子弹碰撞)。需要确保当事件触发时:

        1. 委托目标(Main 实例)必须存在

        2. 物体必须处于激活状态(否则协程不会执行)

    3. 全局单例的稳定访问

    • 示例CombatConfig.Instance.Init()
      空物体保证初始化代码在场景中最早执行,避免其他脚本访问未初始化的单例。

    4. 相机等关键引用托管

    • 代码GameDefine._Camera = GameObject.Find(\"Camera\").transform
      通过空物体集中获取并存储场景中的关键对象(如主摄像机),供全局访问。

    5. 时间管理的统一入口

    • 代码GameTime.Update()Update中调用
      空物体作为持久存在的\"时间管理器\",确保每帧更新游戏时间逻辑。

    using System.Collections;using System.Collections.Generic;using UnityEngine;public class Main : MonoBehaviour{ public void Awake() { SystemInit(); } private void SystemInit() { CombatConfig.Instance.Init(); GameDefine._Camera = GameObject.Find(\"Camera\").transform;//当 GameEvent.DOHitlag 委托被赋值指向 Main.DOHitlag 方法时,它实际绑定的是当前 Main 实例。 GameEvent.DOHitlag = DOHitlag; GameDefine.Init(); } void Start() { } // Update is called once per frame void Update() { GameTime.Update(); } Coroutine coroutine_hitlag; public void DOHitlag(int frame, bool lerp) { if (frame > 0 && Time.timeScale == 1) { if (coroutine_hitlag != null) { StopCoroutine(coroutine_hitlag); } coroutine_hitlag = StartCoroutine(Hitlag(frame, lerp)); } } IEnumerator Hitlag(int frame, bool lerp) { for (int i = 0; i < frame; i++) { Time.timeScale = lerp ? Mathf.Lerp(1, 0, (float)i / frame) : 0; yield return new WaitForEndOfFrame(); } Time.timeScale = 1; coroutine_hitlag = null; }}
    1. 系统初始化 (SystemInit)

      • 初始化战斗配置:CombatConfig.Instance.Init()

      • 获取主摄像机引用:GameDefine._Camera = GameObject.Find(\"Camera\").transform

      • 注册全局事件:GameEvent.DOHitlag = DOHitlag(将击中停滞方法绑定到事件系统)

      • 执行其他全局初始化:GameDefine.Init()(此刻控制着跳跃检测中的接地动作的地面层级赋值

    协程类:Hitlag

    Coroutine coroutine_hitlag; IEnumerator Hitlag(int frame, bool lerp)//停顿多少帧,是否插值输入 { for (int i = 0; i < frame; i++)// 循环指定帧数 { // 插值模式:从1到0平滑减速 // 非插值模式:直接暂停时间 // 关键计算:根据 lerp 模式设置时间流速 Time.timeScale = lerp ? Mathf.Lerp(1, 0, (float)i / frame) : 0; yield return new WaitForEndOfFrame(); } Time.timeScale = 1; // 强制恢复100%时间流速 coroutine_hitlag = null; // 清除协程引用 }

    1. ​​停顿时间的控制​

    • ​参数 frame​:指定停顿的总帧数。例如 frame=60 表示停顿 1 秒(假设帧率为 60 FPS)。
    • ​参数 lerp​:控制时间缩放的过渡方式:
      • lerp=true:通过线性插值(Mathf.Lerp)从 1 平滑过渡到 0,产生渐变的停顿效果。
      • lerp=false:立即将时间缩放设为 0,所有帧直接设置 Time.timeScale = 0(完全暂停)

    2. ​​时间缩放(Time.timeScale)的作用​

    • ​游戏时间流速​​:Time.timeScale=1 表示正常速度,0 表示暂停,0.5 表示慢动作。
    • ​影响范围​​:所有依赖时间的功能(如物理、动画、Time.deltaTime)均受影响。
       

    3. ​​协程的逐帧控制​

    yield return new WaitForEndOfFrame();
    • WaitForEndOfFrame​:协程每帧执行一次,确保时间缩放的修改在每帧结束时生效。
      • yield 关键字:表示\"在此处暂停协程,稍后从此处继续\"

      • return 关键字:向 Unity 协程调度系统返回控制权

      • new WaitForEndOfFrame():Unity 内置的\"等待指令\"对象

    DOHitlag类 

    public void DOHitlag(int frame, bool lerp) { // 条件检查:确保只在游戏正常运行且需要停滞时触发 if (frame > 0 && Time.timeScale == 1) { // 检查是否已有运行的停滞效果 if (coroutine_hitlag != null) { // 停止当前运行的停滞协程 StopCoroutine(coroutine_hitlag); } // 启动新的停滞效果并保存协程引用 coroutine_hitlag = StartCoroutine(Hitlag(frame, lerp)); }}
    1. 入口点

      • 提供外部调用接口,用于触发击中停滞效果

      • 被绑定到全局事件 GameEvent.DOHitlag(在 SystemInit 中设置)

    2. 智能管理

      • 确保同一时间只有一个停滞效果运行

      • 新效果会中断旧效果(防止效果叠加)

    3. 条件过滤

      • 只在游戏正常运行时触发(Time.timeScale == 1

      • 只接受有效的停滞帧数(frame > 0

    实例类:UCameracontroller

    该U Cameracontroller组件应挂载在Camera物体上

    using System.Collections;using System.Collections.Generic;using UnityEngine;public class UCameracontroller : MonoBehaviour{ // Start is called before the first frame update //滑动鼠标,相机旋转 //鼠标滚轮,相机离角色远近 public Transform target; //滑动鼠标 相机旋转 //鼠标滚轮 相机离角色远近 CharacterController controller; Vector3 hight_offset; void Start() { if (target != null) { Cursor.lockState = CursorLockMode.Locked; ; Cursor.visible = false;//隐藏鼠标 不可见 controller = target.GetComponent(); hight_offset = controller.center * 1.75f; } } float xMouse; float yMouse; float distanceFromTarget; public float mouse_scrollwheel_scale = 10;//鼠标滚轮速度的调整(缩放) public float speed = 5;//跟随速度 private void LateUpdate() { if (target != null) { //鼠标滑动 输入的值  xMouse += UInput.GetAxis_Mouse_X(); yMouse -= UInput.GetAxis_Mouse_Y(); yMouse = Mathf.Clamp(yMouse, -30f, 80f); //鼠标滚轮的输入 往前滑动正数 往后滑动输负数的 //离角色越近(往前滑动) distanceFromTarget -= UInput.GetAxis_Mouse_ScrollWheell() * mouse_scrollwheel_scale; //拉近或者拉远 人物镜头 distanceFromTarget = Mathf.Clamp(distanceFromTarget, 2, 15); Quaternion targetRotation = Quaternion.Euler(yMouse, xMouse, 0); Vector3 targetPosition = target.position + targetRotation * new Vector3(0, 0, -distanceFromTarget) + hight_offset; speed = controller.velocity.magnitude > 0.1f ? Mathf.Lerp(speed, 7.5f, 5f * GameTime.deltaTime) : Mathf.Lerp(speed, 25f, 5f * GameTime.deltaTime); transform.position = Vector3.Lerp(transform.position, targetPosition, GameTime.deltaTime * speed); transform.rotation = Quaternion.Lerp(transform.rotation, targetRotation, GameTime.deltaTime * 25f); } }}

    初始化Start方法 

     CharacterController controller; Vector3 hight_offset; void Start() { if (target != null) { Cursor.lockState = CursorLockMode.Locked; // 锁定光标到游戏窗口中心 Cursor.visible = false;//隐藏鼠标 不可见 controller = target.GetComponent(); hight_offset = controller.center * 1.75f; } }

     Cursor.lockStateCursor.visible是用于控制鼠标光标行为的核心属性:

    Cursor.lockState

    1. ​​语法结构​

    • ​属性​​:Cursor.lockState
    • ​类型​​:枚举(CursorLockMode
    • ​赋值​​:CursorLockMode.Locked

    2. ​​功能​

    • ​锁定光标到屏幕中心​​:光标会被固定在游戏窗口中心,无法移动。
    • ​隐藏光标​​:无论Cursor.visible的值如何,光标在此模式下均不可见。
    • ​输入响应​​:仍能通过鼠标输入(如移动视角),但光标位置不更新

     

    Cursor.visible = false;

    1. ​​语法结构​

    • ​属性​​:Cursor.visible
    • ​类型​​:布尔值(true/false

    2. ​​功能​

    • ​控制光标可见性​​:
      • true:显示光标(默认状态)。
      • false:隐藏光标。
    • ​独立于锁定状态​​:即使光标被锁定(Locked模式),设置visiblefalse仍会进一步隐藏光标

    LateUpdate方法 

    float distanceFromTarget; public float mouse_scrollwheel_scale = 10;//鼠标滚轮速度的调整(缩放) public float speed = 5;//跟随速度 private void LateUpdate() { if (target != null) { //鼠标滑动 输入的值  xMouse += UInput.GetAxis_Mouse_X(); yMouse -= UInput.GetAxis_Mouse_Y(); yMouse = Mathf.Clamp(yMouse, -30f, 80f); //鼠标滚轮的输入 往前滑动正数 往后滑动输负数的 //离角色越近(往前滑动) distanceFromTarget -= UInput.GetAxis_Mouse_ScrollWheell() * mouse_scrollwheel_scale; //拉近或者拉远 人物镜头 distanceFromTarget = Mathf.Clamp(distanceFromTarget, 2, 15); Quaternion targetRotation = Quaternion.Euler(yMouse, xMouse, 0); Vector3 targetPosition = target.position + targetRotation * new Vector3(0, 0, -distanceFromTarget) + hight_offset; speed = controller.velocity.magnitude > 0.1f ? Mathf.Lerp(speed, 7.5f, 5f * GameTime.deltaTime) : Mathf.Lerp(speed, 25f, 5f * GameTime.deltaTime); transform.position = Vector3.Lerp(transform.position, targetPosition, GameTime.deltaTime * speed); transform.rotation = Quaternion.Lerp(transform.rotation, targetRotation, GameTime.deltaTime * 25f); } }}

    1. 鼠标输入处理

    csharpxMouse += UInput.GetAxis_Mouse_X(); // 累加水平鼠标移动量yMouse -= UInput.GetAxis_Mouse_Y(); // 累加垂直鼠标移动量(反转Y轴)yMouse = Mathf.Clamp(yMouse, -30f, 80f); // 限制垂直旋转角度

            水平旋转(绕Y轴)无角度限制,垂直旋转(绕X轴)限制在-30°到80°之间,防止摄像机翻转过度

    public static float Clamp(float value, float min, float max);若 value  max,返回 max;否则返回 value 本身
    • 将 yMouse 的当前值限制在 -30 到 80 之间。
      • 若 yMouse 小于 -30,则赋值为 -30
      • 若 yMouse 大于 80,则赋值为 80
    • 否则保持原值不变。

     2.滚轮距离控制

    csharpdistanceFromTarget -= UInput.GetAxis_Mouse_ScrollWheell() * mouse_scrollwheel_scale;distanceFromTarget = Mathf.Clamp(distanceFromTarget, 2, 15); // 限制摄像机距离
    • ​第一行​​:根据鼠标滚轮输入调整摄像机与目标的距离。

      • UInput.GetAxis_Mouse_ScrollWheell():获取鼠标滚轮的垂直滚动量(通常返回正值为向上滚动,负值为向下滚动)。
      • mouse_scrollwheel_scale:滚轮灵敏度系数,用于控制距离变化的幅度。
      • distanceFromTarget -= ...:滚轮向上滚动时,摄像机远离目标;向下滚动时,摄像机靠近目标。
        • ​滚轮向上时​​(scrollInput > 0):
          • distanceFromTarget ​​减少​​ → 摄像机与目标的距离缩短 → ​​摄像机靠近目标​
        • ​滚轮向下时​​(scrollInput < 0):
          • distanceFromTarget ​​增加​​ → 摄像机与目标的距离拉长 → ​​摄像机远离目标​
    • ​第二行​​:使用 Mathf.Clamp 限制 distanceFromTarget 的范围。

      • 最小值 2:防止摄像机与目标碰撞或视角过近。
      • 最大值 15:避免摄像机距离过远导致视野过小

    3. 摄像机位置计算

    csharpQuaternion targetRotation = Quaternion.Euler(yMouse, xMouse, 0); // 创建目标旋转Vector3 targetPosition = target.position + targetRotation * new Vector3(0, 0, -distanceFromTarget) // 计算后方偏移 + hight_offset; // 添加高度偏移csharp
    1. 第一行作用将鼠标输入转换为四元数旋转

      1. 作用​​:将欧拉角(yMousexMouse0)转换为四元数,表示摄像机的目标旋转方向。
      2. ​参数含义​​:
        • yMouse:垂直旋转角度(通常控制摄像机的俯仰角,即上下倾斜)。
        • xMouse:水平旋转角度(通常控制摄像机的偏航角,即左右旋转)。
        • 0:滚转角(通常设为0,避免摄像机侧翻)。
    2. 计算摄像机位置:

      • 基础位置​​:target.position 是目标物体的世界坐标。
      • ​后方偏移​​:
        • new Vector3(0, 0, -distanceFromTarget):定义一个沿摄像机自身 ​​Z轴负方向​​ 的偏移向量(即摄像机朝向的后方)。
        • targetRotation * ...:将偏移向量根据目标旋转方向进行变换,确保摄像机始终位于目标的 ​​正后方​​。
        • ​高度偏移​​:hight_offset 是摄像机的垂直高度(如 new Vector3(0, 2, 0) 表示在目标上方2单位处)。

    4.智能移动计算

    speed = controller.velocity.magnitude > 0.1f ? Mathf.Lerp(speed, 7.5f, 5f * GameTime.deltaTime) // 移动中:慢速跟随 : Mathf.Lerp(speed, 25f, 5f * GameTime.deltaTime); // 静止时:快速归位
    • 条件判断​
      • ​​controller.velocity.magnitude > 0.1f​​:检测角色是否在移动(速度是否超过阈值)
    Mathf.Lerp 的工作机制​​​​公式​​:result = a + (b - a) * ta:当前速度(speed)。b:目标速度(7.5f 或 25f)。t:插值比例(范围 [0, 1])。//而float t = 5f * deltaTime; // 5 * 0.02 = 0.1
    • t=0.1​ 表示 ​​每帧插值 10% 的进度​​。
    • ​过渡速度​​:若目标速度是 25f,当前速度是 0f,则每帧速度增加 25 * 0.1 = 2.5f
      • 第1帧后速度:0 + 2.5 = 2.5f
      • 第2帧后速度:2.5 + 2.5 = 5f
      • 第10帧后速度:25f(达到目标值)
    • ​值越大(如 5f)​​ → t 越大 → 每帧变化幅度越大 → ​​过渡越快​​。
    • ​值越小(如 1f)​​ → t 越小 → 每帧变化幅度越小 → ​​过渡越慢​​。

    5.线性插值(Lerp)实现物体位置和旋转的平滑过渡​

    transform.position = Vector3.Lerp( transform.position, // 当前物体位置 targetPosition, // 目标位置 GameTime.deltaTime * speed // 插值系数(控制移动速度));transform.rotation = Quaternion.Lerp( transform.rotation, // 当前物体旋转 targetRotation, // 目标旋转 GameTime.deltaTime * 25f // 插值系数(控制旋转速度));

    ​位置插值(Vector3.Lerp)​

    • ​公式​​:
      结果 = 当前位置 + (目标位置 - 当前位置) * t
      其中 t = GameTime.deltaTime * speed
    • ​作用​​:
      物体从当前位置向目标位置平滑移动,移动速度由 speed 控制。

    ​旋转插值(Quaternion.Lerp)​

    • ​公式​​:
      结果 = 当前旋转 + (目标旋转 - 当前旋转) * t
      其中 t = GameTime.deltaTime * 25f
    • 作用​​:
      物体从当前旋转向目标旋转平滑过渡,旋转速度由 25f 控制。

    文件配置 

    ​      1、 Odin Inspector 是 Sirenix 工具集的核心组件之一​​,而 Sirenix 文件夹通常是 Odin Inspector 及其相关工具的安装目录。 Sirenix 文件夹下的内容是 Odin 功能的核心实现(如 Assemblies/OdinInspector.dllDemosReadme 等)

            
            2、将新建的配置文件放入 StateConfig 文件夹通常意味着该文件用于管理​​与状态相关的动态参数或逻辑​

    StateScriptableObject 

    StateScriptableObject

    • 继承ScriptableObject:创建可在Unity编辑器中保存的配置文件。

    • 实现ISerializationCallbackReceiver:在序列化/反序列化时执行自定义逻辑。

    • [CreateAssetMenu]:在Unity的Asset创建菜单中添加选项,路径为配置/创建状态配置。(即右键可以创建该项目)

     

    核心功能

    1. 数据容器
      通过StateScriptableObject存储状态配置列表(List states),每个StateEntity包含状态ID和描述信息。

    2. 自动同步机制
      实现ISerializationCallbackReceiver接口,在反序列化时(如资源加载、编辑器刷新)自动同步配置数据:

      • PlayerStateData.all(静态配置表)获取最新状态数据

      • 动态增删states列表以匹配配置表变化

    3. 与传统代码状态机相比:

    using System.Collections;using System.Collections.Generic;using UnityEngine;using Sirenix.OdinInspector;using Game.Config;[CreateAssetMenu(menuName = \"配置/创建状态配置\")]public class StateScriptableObject : ScriptableObject,ISerializationCallbackReceiver{ [SerializeField] [ListDrawerSettings(ShowIndexLabels = true, ShowPaging = false, ListElementLabelName = \"info\")] public List states = new List(); public void OnAfterDeserialize() {#if UNITY_EDITOR if (states.Count == 0) { Dictionary dct = PlayerStateData.all; foreach (var item in dct) { var info = item.Value; StateEntity entity = new StateEntity(); entity.id = info.id; entity.info = info.id + \"_\" + info.info; states.Add(entity); } } else { Dictionary dct = PlayerStateData.all; if (dct.Count != states.Count) { //遍历表格所有状态 foreach (var item in dct) {  var info = item.Value;  bool add = true;  for (int i = 0; i < states.Count; i++)  { if (states[i].id == info.id) { add = false; continue; }  }  //如果是需要增加  if (add == true)  { StateEntity stateEntity = new StateEntity(); stateEntity.id = info.id; stateEntity.info = info.id + \"_\" + info.info; states.Add(stateEntity);  } } List remove = new List(); //删除掉多余的 foreach (var item in states) {  if (dct.ContainsKey(item.id) == false)  { remove.Add(item); //UDebug.LogError(remove.Count);  } } foreach (var item in remove) {  states.Remove(item); } } }#endif } public void OnBeforeSerialize() { } // Start is called before the first frame update }[System.Serializable]public class StateEntity{ public int id; public string info; [Header(\"是否忽略与单位的碰撞\")] public bool ignor_collision; [Header(\"物理位移配置\")] public List physicsConfig;}[System.Serializable]public class PhysicsConfig{ [Header(\"触发点\")] public float trigger; [Header(\"结束点\")] public float time;//结束点 [Header(\"位移距离\")] public Vector3 force; [Header(\"曲线配置\")] public AnimationCurve cure = AnimationCurve.Constant(0, 1, 1); [Header(\"是否忽略重力\")] public bool ignore_gravity; [Header(\"检测到单位后停下\")] public float stop_dst;}

     状态列表

    [SerializeField] [ListDrawerSettings(ShowIndexLabels = true, ShowPaging = false, ListElementLabelName = \"info\")] public List states = new List();

    [SerializeField]

    • 作用:强制将public List states字段序列化

    [ListDrawerSettings(...)]

    • Odin Inspector插件提供的特性

    • 作用:高级定制列表在Unity编辑器中的显示方式

    • 参数详解:

      • ShowIndexLabels = true

        • 在列表每个元素左侧显示索引标签

      • ShowPaging = false

        • 禁用列表分页功能

        • 如果列表很长(如100+元素),Odin默认会分页显示

        • 设为false强制显示完整列表(适用于元素较少的情况)

      • ListElementLabelName = \"info\"

        • 使用StateEntity类的info属性作为列表项的主标签


     当 states 列表为空时

            如果当前`states`列表为空(`states.Count == 0`)

            从`PlayerStateData.all`(角色状态表)中获取所有状态——遍历该字典,为每个状态创建一个新的`StateEntity`对象,并填充其`id`和`info`字段

            将这些新创建的对象添加到`states`列表中。​

    public List states;if (states.Count == 0) { // 遍历所有配置数据 foreach (var item in PlayerStateData.all) { StateEntity entity = new StateEntity(); entity.id = item.Value.id; // 复制ID entity.info = $\"{item.Value.id}_{item.Value.info}\"; // 拼接描述信息 states.Add(entity);  // 添加到列表 }}
    • ​场景​​:首次创建StateScriptableObject时自动填充数据
    • ​实现逻辑​​:
      • 遍历外部数据源PlayerStateData.all(假设为字典结构)
      • 将每个状态转换为StateEntity对象
      • 通过idinfo拼接生成唯一标识(如\"1001_待机\"
    • states.Add(entity);  
      • 通过 states.Add(entity) 将实体添加到 states 列表中

    当 states 列表非空时

    else//当列表为非空时if (dct.Count != states.Count) // 检查数量是否一致{ // --- 新增缺失项 --- foreach (var item in dct) { bool add = true; // 检查当前项是否已存在于 states for (int i = 0; i < states.Count; i++) { if (states[i].id == item.Value.id) { add = false; // 已存在则跳过 break; } } // 不存在则新建并添加 if (add) { StateEntity stateEntity = new StateEntity(); stateEntity.id = item.Value.id; stateEntity.info = $\"{item.Value.id}_{item.Value.info}\"; states.Add(stateEntity); } } // --- 删除多余项 --- List remove = new List(); // 标记 states 中不存在于配置数据的项 foreach (var item in states) { if (!dct.ContainsKey(item.id)) { remove.Add(item); // 加入待删除列表 } } // 移除无效项 foreach (var item in remove) { states.Remove(item); }}

            仅当配置表条目数量变化时才执行同步,数量变化一定意味着内容变化(添加/删除)


    新增缺失项(数据同步方向:dct → states

    foreach (var item in dct) { bool add = true; for (int i = 0; i < states.Count; i++) { if (states[i].id == item.Value.id) { add = false; break; } } if (add) { StateEntity stateEntity = new StateEntity(); stateEntity.id = item.Value.id; stateEntity.info = $\"{item.Value.id}_{item.Value.info}\"; states.Add(stateEntity); }}
    • 工作逻辑
      1. 遍历配置表(dct)所有条目

      2. 检查每个ID是否已存在于资源列表(states)

      3. 若不存在则创建新条目

    • 实现功能:自动添加策划在配置表中新增的状态

    删除多余项

    List remove = new List();foreach (var item in states) { if (!dct.ContainsKey(item.id)) { remove.Add(item); }}foreach (var item in remove) { states.Remove(item);}
    • 反向遍历本地数据​​:标记所有在dct中不存在的本地条目
    • ​批量删除​​:通过中间列表remove避免遍历时修改集合异常

    举例说明:
            假设此时配置表与资源列表不一致的情况如下:

    // 假设初始配置包含2个状态 配置表dctPlayerStateData.all = new Dictionary{ {1001, new PlayerStateEntity{ id=1001, info=\"IDLE\" }}, {1002, new PlayerStateEntity{ id=1002, info=\"WALK\" }}};// 初始同步后包含2个状态实体states = new List{ new StateEntity{ id=1001, info=\"1001_IDLE\" }, new StateEntity{ id=1002, info=\"1002_WALK\" }};// 开发者新增一个状态配置PlayerStateData.all.Add(1003, new PlayerStateEntity{ id=1003, info=\"RUN\" });

     代码执行流程:

    if (dct.Count != states.Count) // 3 != 2 → 进入同步流程
    foreach (var item in dct) { bool add = true; // 检查每个配置项是否存在于内存列表 for (int i = 0; i < states.Count; i++) { if (states[i].id == item.Value.id) { add = false; // 存在则跳过 break;//跑出循环 } } // 新增状态ID=1003 if (add) { states.Add(new StateEntity{ StateEntity stateEntity = new StateEntity(); stateEntity.id = item.Value.id; stateEntity.info = $\"{item.Value.id}_{item.Value.info}\";//生成1003__跑步 states.Add(stateEntity); }); }}

    得出结果如下:

    states 现在包含3个元素:1001_IDLE → 1002_WALK → 1003_RUN

    假设开发者​​删除​​了配置表中的ID=1002状态:

    states 现在包含3个元素:1001_IDLE → 1002_WALK → 1003_RUN

    触发同步:

    if (dct.Count != states.Count) // 2 != 3 → 进入同步流程
    List remove = new List();foreach (var item in states) { if (!dct.ContainsKey(item.id)) { remove.Add(item);//将要删除的元素添加到 }}foreach (var item in remove) { states.Remove(item);}

            将要删除的多个元素存入remove表中,遍历该表进行删除。

    Obj_State类 (可视化面板操控物体 )

    public class Obj_State{    [Header(\"注释说明\")]    public string info;    [Header(\"触发点\")]    public float trigger;    [Header(\"需要操作的物体对象\")]    public string[] obj_id;    [Header(\"打钩激活/反之则隐藏\")]    public bool act;    [Header(\"状态提前结束,是否也强制执行该配置\")]    public bool force;    [Header(\"循环执行(循环动作)\")]    public bool loop;}

            这是对物体某些属性进行可视化操作 

    
    

    StateEntity类 

            动态创建并初始化一个状态实体(StateEntity)​​,主要用于将外部数据源(PlayerStateEntity)中的状态信息转换为当前脚本可管理的配置实体(StateEntity),并生成显示标识 

    [System.Serializable]public class StateEntity{ public int id; public string info; [Header(\"是否忽略与单位的碰撞\")] public bool ignor_collision; [Header(\"物理位移配置\")] public List physicsConfig;}
    • info.id 是该状态的​​唯一数字标识​​(例如 12 等),用于程序逻辑中唯一识别状态。
    • info.info 是该状态的​​描述性文本​​(例如 \"跳跃中\"\"受伤\" 等),用于人工阅读或编辑器显示。

    PhysicsConfig类 

    public class PhysicsConfig{ [Header(\"触发点\")] public float trigger; [Header(\"结束点\")] public float time;//结束点 [Header(\"位移距离\")] public Vector3 force; [Header(\"曲线配置\")] public AnimationCurve cure = AnimationCurve.Constant(0, 1, 1); [Header(\"是否忽略重力\")] public bool ignore_gravity; [Header(\"检测到单位后停下\")] public float stop_dst;}

            PhysicsConfig作为配置文件表的一个子属性组件:

    观察动画设置触发点和位移

    物理逻辑服务类:PhysicsService

    using System.Collections;using System.Collections.Generic;using UnityEngine;public class PhysicsService : FSMServiceBase{ public override void Init(FSM fsm) { base.Init(fsm); } public override void OnAnimationEnd(PlayerState state) { base.OnAnimationEnd(state); ReSetAllExcuted(); } public override void OnBegin(PlayerState state) { base.OnBegin(state); ReSetAllExcuted();//把所有元素标记为未执行的状态 } public override void OnDisable(PlayerState state) { base.OnDisable(state); } public override void OnEnd(PlayerState state) { base.OnEnd(state); ReSetAllExcuted(); Stop(); } public override void OnUpdate(float normalizedTime, PlayerState state) { base.OnUpdate(normalizedTime, state); var e = state.stateEntity.physicsConfig; if (e != null && e.Count > 0) { for (int i = 0; i = entity.trigger && GetExcuted(i) == false) {  Do(entity, state);  SetExcuted(i); } } } if (begin) { //动作进度 小于 配置的结束点 if (normalizedTime  0) {  Debug.Log(\"6\");  //已经执行的时间 (当前进度-当前事件触发点)/ 需要执行的时间  var f = (normalizedTime - currentEntity.trigger) / (currentEntity.time - currentEntity.trigger);  //用于做插值运动  float lerpTime = currentEntity.cure.Evaluate(f);  var speed = Vector3.Lerp(Vector3.zero, force, lerpTime);  player.AddForce(speed, currentEntity.ignore_gravity);  if (currentEntity.stop_dst > 0)  { Debug.Log(\"7\"); var begin = player._transform.position + Vector3.up; var result = Physics.Linecast(begin, begin + player._transform.forward * currentEntity.stop_dst, player.GetEnemyLayerMask()); if (result) { Stop(); }  } } } else { Stop(); } } } private void Stop()//强制停止当前位移逻辑 { if (begin) { begin = false; player.RemoveForce(); } } bool begin = false; PhysicsConfig currentEntity; Vector3 force; public void Do(PhysicsConfig entity, PlayerState state) { //执行这个配置所需要花费的时间 float t = state.clipLength * ((entity.time - entity.trigger) / 1); if (t  0) { force = currentEntity.force / t; } else { force = currentEntity.force; } begin = true; } } public override void ReLoop(PlayerState state) { base.ReLoop(state); } public override void ReStart(PlayerState state) { base.ReStart(state); }}

    OnUpdate方法

     public override void OnUpdate(float normalizedTime, PlayerState state) { base.OnUpdate(normalizedTime, state); var e = state.stateEntity.physicsConfig; if (e != null && e.Count > 0) { for (int i = 0; i = entity.trigger && GetExcuted(i) == false) {  Do(entity, state);  SetExcuted(i); } } } if (begin) { //动作进度 小于 配置的结束点 if (normalizedTime  0) {  //已经执行的时间 (当前进度-当前事件触发点)/ 需要执行的时间  var f = (normalizedTime - currentEntity.trigger) / (currentEntity.time - currentEntity.trigger);  //用于做插值运动  float lerpTime = currentEntity.cure.Evaluate(f);  var speed = Vector3.Lerp(Vector3.zero, force, lerpTime);  player.AddForce(speed, currentEntity.ignore_gravity);  if (currentEntity.stop_dst > 0)  { var begin = player._transform.position + Vector3.up; var result = Physics.Linecast(begin, begin + player._transform.forward * currentEntity.stop_dst, player.GetEnemyLayerMask()); if (result) { Stop(); }  } } } else { Stop(); } } }

     1. 物理效果触发 (OnUpdate)

    public override void OnUpdate(float normalizedTime, PlayerState state) { // 遍历所有物理配置 for (int i = 0; i = entity.trigger && GetExcuted(i) == false) { Do(entity, state); // 执行物理效果 SetExcuted(i); // 标记为已执行(防止重复触发) } }}

    normalizedTime >= entity.trigger

    • ​功能​​:判断动画播放进度是否达到预设的触发时间点
    • ​参数说明​​:
      • normalizedTime:动画归一化时间(0~1),表示当前动画播放进度(如0.8表示播放到80%)
      • entity.trigger:物理效果的触发阈值(如0.5表示动画播放到一半时触发)
    • ​触发场景​​:
      • 角色跳跃动画到达最高点时触发落地特效
      • 武器挥舞动画到攻击判定时段时生成伤害区域

         

    !GetExcuted(i)

    • ​功能​​:检查该物理配置是否已被执行过
    • ​实现机制​​:
      • GetExcuted(i):返回第i个物理配置的执行状态(true=已执行,false=未执行)

    2.判断机制的处理

     if (begin) { //动作进度 小于 配置的结束点 if (normalizedTime  0) {

    • ​条件1:begin为真​
      只有当物理效果启动时才执行后续逻辑

    • ​条件2:normalizedTime <= currentEntity.time
      判断是否在配置的时间窗口内(即当前播放进度时间不能超过总时间长度)

    • ​条件3:currentEntity.time > 0
      区分瞬时效果(time=0)和持续效果(time>0


    3.插值力计算

    var f = (normalizedTime - currentEntity.trigger) / (currentEntity.time - currentEntity.trigger);float lerpTime = currentEntity.cure.Evaluate(f);var speed = Vector3.Lerp(Vector3.zero, force, lerpTime);
    • 动画插值计算f
      目的
      :计算当前物理运动阶段的完成比例

      • 参数

        • normalizedTime:动画当前进度(范围在0.0~1.0)

        • currentEntity.trigger:物理运动开始点(如动画30%处)

        • currentEntity.time:物理运动结束点(如动画80%处)

        • 计算示例:如果运动区间是0.3~0.8当动画进度到0.55时:f = (0.55 - 0.3) / (0.8 - 0.3) = 0.25 / 0.5 = 0.5表示运动已完成50%

           

    • lerpTime的赋值

      • 通过 currentEntity.cure(可能为动画曲线或插值器)的 Evaluate 方法,将归一化时间进度 f 转换为实际的插值时间 lerpTime

      • 在技能释放动画中,f 与 lerpTime 的计算关系由 ​​动画曲线(如贝塞尔曲线)的非线性映射​​ 决定。

      • cure.Evaluate(f)​​: 若 cure 是动画曲线(如 Unity 的 AnimationCurve),则根据 f 的值在曲线上采样,返回对应的插值时间。 通过曲线调整 lerpTime,可让速度变化速率随时间动态变化(如前半段加速平缓,后半段加速剧烈)。

         

    • var speed 的生成

      • 在 Vector3.zero(初始速度)和 force(目标速度)之间进行线性插值,生成当前速度 speed

        • ​核心作用​​:根据 lerpTime 动态调整速度,实现平滑过渡
        • Vector3.Lerp 的数学原理​​speed.x = Vector3.zero.x + (force.x - Vector3.zero.x) * lerpTime;speed.y = Vector3.zero.y + (force.y - Vector3.zero.y) * lerpTime;speed.z = Vector3.zero.z + (force.z - Vector3.zero.z) * lerpTime;

           


    4. 施加物理力和检测障碍后移除

    // 1. 施加物理力player.AddForce(speed, currentEntity.ignore_gravity);// 2. 障碍物检测(仅当配置了有效检测距离时才执行障碍检测)if (currentEntity.stop_dst > 0){ // 3. 计算检测起点(角色位置上方1单位) var begin = player._transform.position + Vector3.up; // 4. 计算检测终点(角色前方指定距离) var end = begin + player._transform.forward * currentEntity.stop_dst; // 5. 执行线性检测(射线检测) var result = Physics.Linecast(begin, end, player.GetEnemyLayerMask()); // 6. 检测到障碍物时中断运动 if (result) { Stop(); }}

    射线检测的函数:

    csharpvar result = Physics.Linecast(begin, end, player.GetEnemyLayerMask());
    • 方法Physics.Linecast

      • Unity的物理检测方法

      • 检测两点之间的碰撞体

    • 关键参数

      • player.GetEnemyLayerMask():层级过滤

        • 只检测特定层级(如\"Wall\"、\"Obstacle\")

        • 忽略无关层级(如\"Player\"、\"Trigger\")

    • 返回值

      • true:检测到障碍物

      • false:无障碍物

    Do方法 

    public void Do(PhysicsConfig entity, PlayerState state){ // 计算物理效果持续时间(秒) float t = state.clipLength * ((entity.time - entity.trigger) / 1); if (t  0) { force = currentEntity.force / t; } else { force = currentEntity.force; } begin = true; // 标记物理效果启动 }}

    1. ​​时间差计算​

    float t = state.clipLength * (entity.time - entity.trigger);
    • ​功能​​:计算物理效果的 ​​实际作用时间窗口​
    • ​公式推导​​:
      • entity.time - entity.trigger:触发时间与生效时间的差值(归一化时间)
      • 乘以clipLength将归一化时间转换为实际秒数
    • ​示例​​:
      • 若动画总长2秒,触发时间设为0.3,生效时间设为0.1 → t = 2*(0.3-0.1) = 0.4秒

    2.力分配策略

    if (t  0) ? currentEntity.force / t // 均分力到时间窗口 : currentEntity.force; // 直接使用原始力值 begin = true; // 标记物理效果启动 }}

    增加对entity.time的显式判断

    • 当 entity.time <= 0(结束点比0小)​:立即施加原始力值(瞬时效果)
    • ​当 entity.time > 0​(结束点大于0):将总力均匀分配到时间窗口(持续效果)

     


    物体逻辑服务类 ObjService

    using System.Collections;using System.Collections.Generic;using UnityEngine;public class ObjService : FSMServiceBase{ public override void OnAnimationEnd(PlayerState state) { base.OnAnimationEnd(state); } public override void OnBegin(PlayerState state) { base.OnBegin(state); ReSetAllExcuted(); } public override void OnDisable(PlayerState state) { base.OnDisable(state); } public override void OnEnd(PlayerState state) { base.OnEnd(state); // var os = state.stateEntity.obj_States; if (os != null) { for (int i = 0; i < os.Count; i++) { var item = os[i]; //强制执行该条配置 是否未执行过 if (item.force && GetExcuted(i) == false) {  DO(item); } } } ReSetAllExcuted(); } private void DO(Obj_State item) { if (item.obj_id != null) { foreach (var o_id in item.obj_id) { var obj = player.GetHangPoint(o_id); if (obj != null) {  obj.SetActive(item.act); } } } } public override void OnUpdate(float normalizedTime, PlayerState state) { base.OnUpdate(normalizedTime, state); var os = state.stateEntity.obj_States; if (os != null) { for (int i = 0; i = item.trigger && GetExcuted(i) == false) {  SetExcuted(i);  DO(item); } } } } //技能点升级的时候 比如20-50动作需要重新循环播放五六次 public override void ReLoop(PlayerState state) { base.ReLoop(state); Item_ResetExcuted(state); } public override void ReStart(PlayerState state) { base.ReStart(state); Item_ResetExcuted(state); } private void Item_ResetExcuted(PlayerState state) { var os = state.stateEntity.obj_States; if (os != null) { for (int i = 0; i < os.Count; i++) { var item = os[i]; //强制执行该条配置 是否未执行过 if (item.loop) {  ReSetExcuted(i); } } } }}

     OnUpdate方法()

     public override void OnUpdate(float normalizedTime, PlayerState state) { base.OnUpdate(normalizedTime, state); var os = state.stateEntity.obj_States;//物体特效配置表赋值(可以创建多个配置表进行控制特效的控制和生成) if (os != null) { for (int i = 0; i = item.trigger && GetExcuted(i) == false) {  SetExcuted(i);  DO(item); } } } }

    DO方法

     private void DO(Obj_State item) { if (item.obj_id != null) { foreach (var o_id in item.obj_id) { //找到当前特效——利用特效名字字符串作为特效的键 var obj = player.GetHangPoint(o_id); if (obj != null) {  obj.SetActive(item.act); } } } }

             对当前的特效物体配置表进行找到对应特效,并且激活,以下这段代码就是控制着打钩激活物体特效的功能

            

    obj.SetActive(item.act);

    OnEnd方法 

     public override void OnEnd(PlayerState state) { base.OnEnd(state); // var os = state.stateEntity.obj_States; if (os != null) { for (int i = 0; i < os.Count; i++) { var item = os[i]; //利用force判断是否需要强制执行该条配置  GetExcuted(i) == false是否未执行过 if (item.force && GetExcuted(i) == false) {  DO(item);//也是在DO方法中对特效进行隐藏 } } } ReSetAllExcuted(); }

     动画逻辑服务类  AnimationService

    using System.Collections;using System.Collections.Generic;using UnityEngine;public class AnimationService : FSMServiceBase{ public float normalizedTime;//当前动作播放进度 public string now_play_id; public override void Init(FSM fsm) { base.Init(fsm); } public override void OnAnimationEnd(PlayerState state) { base.OnAnimationEnd(state); } void Play(PlayerState state) { normalizedTime = 0; this.now_play_id = state.excel_config.anm_name; player._animator.Play(state.excel_config.anm_name);//字段对应角色状态中表的字段名 player._animator.Update(0); } public override void OnBegin(PlayerState state) { base.OnBegin(state); Play(state); } public override void OnDisable(PlayerState state) { base.OnDisable(state); } public override void OnEnd(PlayerState state) { base.OnEnd(state); } public override void OnUpdate(float normaizedTime, PlayerState state) { base.OnUpdate(normaizedTime, state); if (!string.IsNullOrEmpty(now_play_id)) { var info = player._animator.GetCurrentAnimatorStateInfo(0); if (info.IsName(now_play_id)) { //0—1 表示动作0%-100%的进度 this.normalizedTime = info.normalizedTime; if (normalizedTime >= 1) {  //UDebug.LogError($\"{transform.gameObject.name}:当前动画:{_anmID} 进度是:{normalizedTime} // {info.normalizedTime}\");  this.normalizedTime = 1;  player.AnimationOnPlayEnd();//判定是结尾时,调用动画结束时的判断接口 } } else { this.normalizedTime = 0; } //判定播放动作是否与配置一致? } } public override void ReLoop(PlayerState state) { base.ReLoop(state); } public override void ReStart(PlayerState state) { base.ReStart(state); }}

    Play方法 

    void Play(PlayerState state){ normalizedTime = 0; // 重置动画标准化时间为起始点 this.now_play_id = state.excel_config.anm_name; // 记录当前播放的动画ID player._animator.Play(state.excel_config.anm_name); // 播放指定动画 player._animator.Update(0); // 强制立即更新动画状态}

     代码逐行解析:

    1. normalizedTime = 0

      • 将动画的标准化时间重置为0(动画起始位置)

      • normalizedTime 是动画进度值(0=开始,1=结束)

    2. this.now_play_id = state.excel_config.anm_name

      • 从配置数据中获取动画名称,并记录到当前播放ID

      • 说明:state.excel_config 是从Excel表读取的配置数据

    3. player._animator.Play(...)

      • 调用Unity的Animator组件播放指定动画

      • 通过state.excel_config.anm_name动态获取动画名称(如\"run\",\"jump\")

    4. player._animator.Update(0)

      • 关键操作:强制动画器立即更新(跳过本帧等待)

      • 参数0表示不推进动画时间,但立即应用状态变化

      • 解决:避免动画播放延迟1帧的问题

     OnUpdate方法

    public override void OnUpdate(float normaizedTime, PlayerState state){ base.OnUpdate(normaizedTime, state); // 调用基类更新逻辑 if (!string.IsNullOrEmpty(now_play_id)) // 检查当前是否有有效动画ID { var info = player._animator.GetCurrentAnimatorStateInfo(0); // 获取动画器当前状态信息 if (info.IsName(now_play_id)) // 检查当前播放的动画是否与记录一致 { // 更新动画进度 (0-1表示0%-100%) this.normalizedTime = info.normalizedTime; if (normalizedTime >= 1) // 动画播放完成检测 { this.normalizedTime = 1; // 确保进度不超过1 player.AnimationOnPlayEnd(); // 触发动画结束回调 } } else // 当前播放动画与预期不一致 { this.normalizedTime = 0; // 重置进度 } }}

    这里的now_play_id是当前的动画id名字如下:


    var info = player._animator.GetCurrentAnimatorStateInfo(0); 

            该方法返回一个 AnimatorStateInfo 结构体,包含 ​​当前动画层(Layer)​​ 的状态数据:

            基础层(Base Layer)​​:仅指索引为0的主层,包含角色的核心动画逻辑(如截图中的内容)。

                            


            当方法的参数或局部变量与类的成员变量同名时,this 用于消除歧义,明确表示操作的是当前对象的成员变量。

    public override void OnUpdate(float normaizedTime, PlayerState state){ this.normalizedTime = info.normalizedTime; }

            若省略 this,左侧的 normalizedTime 会被视为参数,导致左侧的实例变量未被正确赋值。

    Override类的基类调用 

     base.OnUpdate(normaizedTime, state); // 调用基类更新逻辑

    作用是执行基类原有逻辑​
    如果父类的 OnUpdate 方法包含与动画状态、时间轴同步或其他基础功能相关的代码(例如:更新全局计时器、处理状态机基础逻辑、触发事件等),调用 base.OnUpdate 可以确保这些逻辑在子类重写的方法中仍然生效 

    受击逻辑服务类:HitService

    using System;using System.Collections;using System.Collections.Generic;using System.Resources;using UnityEngine;public class HitService : FSMServiceBase{ public override void OnAnimationEnd(PlayerState state) { base.OnAnimationEnd(state); } public override void OnBegin(PlayerState state) { base.OnBegin(state); ReSetAllExcuted(); hit_target.Clear(); last_end = Vector3.zero; } public override void OnEnd(PlayerState state) { base.OnEnd(state); ReSetAllExcuted(); } public override void OnUpdate(float normalizedTime, PlayerState state) { base.OnUpdate(normalizedTime, state); var configs = state.stateEntity.hitConfigs; if (configs != null && configs.Count > 0) { for (int i = 0; i = e.trigger && normalizedTime <= e.end) {  DO(e, state); } } } } Vector3 last_end; private void DO(HitConfig config, PlayerState state) { var obj = player.GetHangPoint(config.begin); Vector3 begin = obj.transform.position; if (config.type == 0) { Vector3 end = begin + obj.transform.forward * config.length; if (last_end == Vector3.zero) { Linecast(begin, end, config, state); } else { var _crn_id = player.currentState.id; for (int i = 0; i < 10; i++) {  Vector3 end2 = Vector3.Lerp(last_end, end, i / 10f);  Linecast(begin, end2, config, state);  if (_crn_id != player.currentState.id)  { return;  } } } last_end = end; } else if (config.type == 1) { BoxCast(obj.transform, config, state); } } List hit_target = new List();//记录哪些单位被击中过,避免多计算了伤害 public bool Linecast(Vector3 begin, Vector3 end, HitConfig config, PlayerState state) { Debug.DrawLine(begin, end, Color.red, 0.2f); //Physics.RaycastNonAlloc var result = Physics.Linecast(begin, end, out var hitInfo, player.GetEnemyLayerMask(), QueryTriggerInteraction.Collide); if (result) { //处于格挡状态 if (hitInfo.transform.CompareTag(GameDefine.WeaponTag)) { OnBlock(hitInfo); } else { OnHit(begin, config, state, hitInfo); } return true; } return false; } private void OnBlock(RaycastHit hitInfo) { //格挡方 var fsm = hitInfo.transform.GetComponentInParent(); if (fsm != null && hit_target.Contains(fsm.instance_id) == false) { hit_target.Add(fsm.instance_id); //1.生成格挡时特效 /*var blockEffect = ResourcesManager.Instance.Create_Hit_Effect(CombatConfig.Instance.Config().block_effect); if (blockEffect != null) { blockEffect.transform.position = hitInfo.point; blockEffect.transform.forward = hitInfo.normal; }*/ /*//镜头模糊控制 GameEvent.DORadialBlur?.Invoke(CombatConfig.Instance.Config().block_radialBlur);*/ /*//顿帧 GameEvent.DOHitlag?.Invoke(CombatConfig.Instance.Config().block_hitlag.frame, CombatConfig.Instance.Config().block_hitlag.lerp); //放格挡成功的音效 AudioController.Instance.Play(CombatConfig.Instance.Config().block_audio, hitInfo.point); //2.攻击方要进入弹反状态 player.BeBlock(fsm); //3.格挡方要进入格挡成功的状态 fsm.OnBlockSucces(player); //6.更新下血条 */ } } private void OnHit(Vector3 begin, HitConfig config, PlayerState state, RaycastHit hitInfo) { //表示击中单位 var fsm = hitInfo.transform.GetComponent(); if (fsm != null) { if (hit_target.Contains(fsm.instance_id) == false) { hit_target.Add(fsm.instance_id); //1.生成命中特效 var hitObject = ResourcesManager.Instance.Create_Hit_Effect(config.hitObj); hitObject.SetActive(true); //2.设置特效的位置 朝向 if (hitObject != null) {  hitObject.transform.position = hitInfo.point;  hitObject.transform.forward = hitInfo.normal; } /*//3.计算 扣掉血量 var damage = AttHelper.Instance.Damage(this.player, state, fsm); fsm.UpdateHP_OnHit(damage); //4.通知对方进入受击 死亡的动作 var fb = fsm._transform.ForwardOrBack(begin) > 0 ? 0 : 1; if (fsm.att_crn.hp > 0) {  fsm.OnHit(fb, this.player); } else {  fsm.OnDeath(fb); } //命中时的顿帧 this.player.Attack_Hitlag(state); //6.命中的音效 AudioController.Instance.Play(CombatConfig.Instance.Config().hit_enemy_audio, hitInfo.point);*/ } } } public override void ReLoop(PlayerState state) { base.ReLoop(state); } public override void ReStart(PlayerState state) { base.ReStart(state); } RaycastHit[] raycastHits = null; public bool BoxCast(Transform begin, HitConfig config, PlayerState state) { if (raycastHits == null) { raycastHits = new RaycastHit[30]; } //命中的数量 var count = Physics.BoxCastNonAlloc(begin.position + begin.transform.TransformDirection(config.box_center), config.box_size, begin.forward, raycastHits, begin.rotation, config.length, player.GetEnemyLayerMask(), QueryTriggerInteraction.Collide); if (count > 0) { int _crn_id = state.id; for (int i = 0; i < count; i++) { var hitInfo = raycastHits[i]; if (hitInfo.transform.CompareTag(GameDefine.WeaponTag)) {  OnBlock(hitInfo); } else {  OnHit(begin.position, config, state, hitInfo); } if (_crn_id != player.currentState.id) {  break; } } return true; } return false; }}

    受击更新检测OnUpdate 

     public override void OnUpdate(float normalizedTime, PlayerState state) { base.OnUpdate(normalizedTime, state); //将受击配置表的信息存储到configs中 var configs = state.stateEntity.hitConfigs; //受击配置表不为空且配置表数量不为0 if (configs != null && configs.Count > 0) { for (int i = 0; i = e.trigger && normalizedTime <= e.end) {  DO(e, state); } } } }

            根据FSM类中的OnUpdate方法,触发受击服务体的每帧检测,检测是否能执行受击逻辑的执行(DO方法)

    DO方法 

    Vector3 last_end;//在OnBegin方法中赋值为last_end = Vector3.zero; private void DO(HitConfig config, PlayerState state) { //找到当前(config.begin)路径的特效及位置,(在可视化面板中设置的) var obj = player.GetHangPoint(config.begin);  Vector3 begin = obj.transform.position; //config.type 是用来检测命中范围类型(0为射线,1为盒子) if (config.type == 0) { //begin是物体点的起始位置 //obj.transform.forward * config.length当前物体局部Z轴方向x射线长度(射线长度也是可视化面板设置) Vector3 end = begin + obj.transform.forward * config.length; if (last_end == Vector3.zero)//首次检测时 { Linecast(begin, end, config, state);//射线检测起点到终点 } else { var _crn_id = player.currentState.id;//记录当前状态 for (int i = 0; i < 10; i++) {  //a与b向量中生成十条射线并依次进行检测  Vector3 end2 = Vector3.Lerp(last_end, end, i / 10f);  Linecast(begin, end2, config, state);  //状态切换时强制返回结束  if (_crn_id != player.currentState.id)  { return;  } } } last_end = end;//记录本次检测的终点位置,作为下一帧检测的\"历史位置\" } else if (config.type == 1)//config.type 是用来检测命中范围类型(0为射线,1为盒子) { BoxCast(obj.transform, config, state);//走盒子检测方法 } }
     //obj.transform.forward * config.length当前物体局部Z轴方向x射线长度(射线长度也是可视化面板设置) Vector3 end = begin + obj.transform.forward * config.length;

            在Unity引擎中,transform.forward 表示物体自身的正前方方向(即物体局部坐标系中的 Z轴正方向)。

            它返回物体局部 Z 轴正方向在世界坐标系中的方向向量(单位向量,长度为1)

            


    last_end变量

            last_end变量用于实现连续射线检测时的插值过渡,确保在物体快速移动或旋转时不会漏掉中间区域的碰撞检测

    1. 记录上一次射线检测的终点位置

      • 在首次检测时初始化为Vector3.zero(特殊标记值)

      • 每次射线检测后更新为当前终点位置

    2. 解决快速移动导致的检测遗漏问题

      • 当物体高速运动或旋转时,如果直接从旧位置跳到新位置,中间区域可能漏检

      • 通过插值在last_end(上次终点)end(本次终点)之间生成10个中间点

      • 对每个中间点执行射线检测(类似\"补帧\"检测)

    3. 更新终点记录的目的

      • 下一帧开始检测:

        csharpelse // last_end 不是 zero{ // 使用 last_end (上一帧终点) 和当前 end 进行插值 Vector3 end2 = Vector3.Lerp(last_end, end, i/10f);}
      • 如果没有这个更新:

        • 所有后续检测都会使用首次的终点位置

        • 插值计算完全错误

        • 检测区域无法跟随物体运动

    // 首次检测(没有历史数据)if (last_end == Vector3.zero) { // 直接检测从起点到终点的射线 Linecast(begin, end, config, state);}// 后续检测(有历史数据)else { // 生成10个过渡点(从上次终点向本次终点渐变) for (int i = 0; i < 10; i++) { // 计算插值点:从last_end到end的10%位置 Vector3 end2 = Vector3.Lerp(last_end, end, i / 10f); // 检测从固定起点到移动终点的射线 Linecast(begin, end2, config, state); // 状态变化时提前终止(如角色死亡) if (player.currentState.id != originalState) return; }}// 更新终点记录last_end = end;

    插值计算点

    for (int i = 0; i < 10; i++) { // 计算插值点:从last_end到end的10%位置 Vector3 end2 = Vector3.Lerp(last_end, end, i / 10f); }

    Vector3.Lerp(a, b, t) 是Unity引擎中Vector3类型的静态方法:用于计算两个向量之间的线性插值:

    当 t = 0 时,返回向量 a(即 last_end)当 t = 1 时,返回向量 b(即 end)当 t = 0.5 时,返回 a 和 b 的中点(t可以理解为从起点a到终点b的进度百分比(0%到100%))公式:result = a + (b - a) * t

    在这里是循环插值生成向量

    射线检测Linecast 

     public bool Linecast(Vector3 begin, Vector3 end, HitConfig config, PlayerState state) { Debug.DrawLine(begin, end, Color.red, 0.2f);//用红色0.2f粗的线条,绘制出射线 //射线检测(起始点,结束点,碰撞信息,检测层级,碰撞器交互效果) var result = Physics.Linecast(begin, end, out var hitInfo, player.GetEnemyLayerMask(), QueryTriggerInteraction.Collide); if (result)//如果有射线碰撞到信息 { //处于格挡状态激活格挡方法 if (hitInfo.transform.CompareTag(GameDefine.WeaponTag)) { OnBlock(hitInfo); } else//没被格挡就执行受击方法 { OnHit(begin, config, state, hitInfo); } return true; } return false; }

    result 的含义

    Physics.Linecast() 方法返回一个 布尔值 (bool)

    • true:表示射线检测到了碰撞(命中了碰撞体或触发器)

    • false:表示射线没有检测到任何碰撞(没有命中任何物体)

    if (result) 的作用

    这个条件判断的意思是:只有当射线检测到碰撞时,才执行内部的命中处理逻辑

    OnHit方法 

     List hit_target = new List();//记录哪些单位被击中过,避免多计算了伤害 private void OnHit(Vector3 begin, HitConfig config, PlayerState state, RaycastHit hitInfo) { //表示击中单位并且获取该单位的FSM组件 var fsm = hitInfo.transform.GetComponent(); if (fsm != null) { //判断hit_target是否记录着当前实例id if (hit_target.Contains(fsm.instance_id) == false) { hit_target.Add(fsm.instance_id);//没有记录则添加该实例 //1.生成命中特效 var hitObject = ResourcesManager.Instance.Create_Hit_Effect(config.hitObj); hitObject.SetActive(true); //2.设置特效的位置 朝向 if (hitObject != null) {  hitObject.transform.position = hitInfo.point;  hitObject.transform.forward = hitInfo.normal; } //3.计算 扣掉血量 var damage = AttHelper.Instance.Damage(this.player, state, fsm); fsm.UpdateHP_OnHit(damage); //4.通知对方进入受击 死亡的动作 var fb = fsm._transform.ForwardOrBack(begin) > 0 ? 0 : 1; if (fsm.att_crn.hp > 0) {  fsm.OnHit(fb, this.player); } else {  fsm.OnDeath(fb); } //命中时的顿帧 this.player.Attack_Hitlag(state); //6.命中的音效 AudioController.Instance.Play(CombatConfig.Instance.Config().hit_enemy_audio, hitInfo.point); } } }

     1、获取击中目标的FSM组件

    var fsm = hitInfo.transform.GetComponent();
    1. hitInfo.transform

      • 这是从射线检测结果中获取的被命中物体的Transform组件

      • hitInfo 是 RaycastHit 结构体,包含碰撞信息

      • transform 属性指向被命中游戏对象的Transform

    2. GetComponent()

      • 从被命中的游戏对象上获取 FSM 组件

    var fsm = hitInfo.transform.GetComponent(); ​​不能直接替换​​为 var fsm = hitInfo.GetComponent();因为RaycastHit 类本身​​没有 GetComponent() 方法

    2、防止多次命中同一目标

    if (hit_target.Contains(fsm.instance_id) == false)//防止同一攻击动作多次命中同一目标(如武器挥动过程中多次检测到同一个敌人)//该变量在FSM类中instance_id = _gameObject.GetInstanceID();
    1. GetInstanceID()

      • 这是 Unity 的 Object 类提供的方法

      • 返回一个唯一的整数标识符,代表该对象在本次游戏运行中的实例


    3、特效生成的位置和朝向

    if (hitObject != null) { hitObject.transform.position = hitInfo.point; hitObject.transform.forward = hitInfo.normal; }
    • 将命中特效(hitObject)的位置设置为碰撞点(hitInfo.point)

      • hitInfo.point 是射线检测得到的精确碰撞位置(世界坐标系)

    • 将特效的正面(Z轴正方向)设置为碰撞表面的法线方向

      • hitInfo.normal 是碰撞表面的垂直方向向量(单位向量)

    BOX检测 

     RaycastHit[] raycastHits = null; public bool BoxCast(Transform begin, HitConfig config, PlayerState state) { if (raycastHits == null) { raycastHits = new RaycastHit[30];//新建一个容量30的射线检测存储数组 } //使用 Physics.BoxCastNonAlloc 在 begin 位置生成一个盒状检测区域 //返回int类型即命中物体数量 var count = Physics.BoxCastNonAlloc(begin.position + begin.transform.TransformDirection(config.box_center), config.box_size, begin.forward, raycastHits, begin.rotation, config.length, player.GetEnemyLayerMask(), QueryTriggerInteraction.Collide); if (count > 0) { int _crn_id = state.id;//记录当前状态id for (int i = 0; i < count; i++) { var hitInfo = raycastHits[i];遍历取出每个记录过的碰撞信息目标 if (hitInfo.transform.CompareTag(GameDefine.WeaponTag))// 判断碰撞物体标签 {  OnBlock(hitInfo);//若是标签为武器则格挡 } else {  OnHit(begin.position, config, state, hitInfo);//否则就调用攻击状态 } if (_crn_id != player.currentState.id)// 状态检查:若玩家状态变化 (如死亡),终止处理 {  break; } } return true;// 有碰撞返回true } return false;// 无碰撞返回false }
    1. RaycastHit:Unity 的结构体(struct),表示一次射线检测命中的结果,包含:

      • collider:命中的碰撞体

      • point:射线命中点的世界坐标

      • normal:命中表面的法线方向

      • distance:从射线起点到命中点的距离

      • transform:命中对象的Transform组件


            2、Physics.BoxCastNonAlloc

            该方法是Unity Physics类中的一个静态方法,用于执行盒状射线投射

    public static int BoxCastNonAlloc( Vector3 center, // 盒体中心点(世界坐标) Vector3 halfExtents, // 盒体半尺寸(XYZ轴向的半径) Vector3 direction, // 检测方向 RaycastHit[] results, // 结果存储数组 Quaternion orientation, // 盒体旋转 float maxDistance, // 最大检测距离 int layerMask,  // 层级掩码 QueryTriggerInteraction queryTriggerInteraction // 触发器处理方式);

             检测区域如下:起始盒体位置——结束盒体位置——以及两者之间移动时扫过的整个空间。

    开始位置 ↓ ┌───────────────────┐ │ 盒体 │ ← 初始位置和旋转 └───────────────────┘ ↓ 沿方向移动 (begin.forward) ┌───────────────────┐ │  │ │ 扫描区域(体积) │ ← 检测区域 │  │ └───────────────────┘ ↓ 最大距离 (config.length) ┌───────────────────┐ │ 盒体 │ ← 结束位置 └───────────────────┘

          在该代码中的对应关系:

    Physics.BoxCastNonAlloc( begin.position + begin.transform.TransformDirection(config.box_center), // 中心点 config.box_size,  // 盒体半尺寸 begin.forward,  // 方向 raycastHits,  // 结果数组 begin.rotation,  // 旋转 config.length,  // 最大距离 player.GetEnemyLayerMask(), // 层级掩码 QueryTriggerInteraction.Collide // 触发器处理);

    Physics.BoxCastNonAlloc的返回值

    • 类型:int

    • 含义:实际检测到的碰撞数量(不会超过结果数组长度)

    顿帧服务逻辑类:HitlagService

    using System.Collections;using System.Collections.Generic;using UnityEngine;public class HitlagService : FSMServiceBase{ public override void Init(FSM fsm) { base.Init(fsm); } public override void OnAnimationEnd(PlayerState state) { base.OnAnimationEnd(state); } public override void OnBegin(PlayerState state) { base.OnBegin(state); ReSetAllExcuted(); } public override void OnEnd(PlayerState state) { base.OnEnd(state); ReSetAllExcuted(); } public override void OnDisable(PlayerState state) { base.OnDisable(state); } public override void OnUpdate(float normalizedTime, PlayerState state) { base.OnUpdate(normalizedTime, state); if (state.stateEntity.hitlagConfig != null && state.stateEntity.hitlagConfig.Count > 0) { for (int i = 0; i = x.trigger && GetExcuted(i) == false) {  SetExcuted(i);  GameEvent.DOHitlag?.Invoke(x.frame, x.lerp); } } } } public override void ReLoop(PlayerState state) { base.ReLoop(state); } public override void ReStart(PlayerState state) { base.ReStart(state); } public void DOHitlag_OnAttack(float normalizedTime, PlayerState state) { if (state.stateEntity.hitlagConfig != null && state.stateEntity.hitlagConfig.Count > 0) { for (int i = 0; i = x.trigger && normalizedTime <= x.trigger2) {  if (GetExcuted(i) == false)  { SetExcuted(i); GameEvent.DOHitlag?.Invoke(x.frame, x.lerp);  } } } } } }

    顿帧更新方法 OnUpdate

     public override void OnUpdate(float normalizedTime, PlayerState state) { base.OnUpdate(normalizedTime, state); //如果当前顿帧配置表不为空且数量不为0 if (state.stateEntity.hitlagConfig != null && state.stateEntity.hitlagConfig.Count > 0) { //遍历所有顿帧配置表 for (int i = 0; i = x.trigger && GetExcuted(i) == false) {  SetExcuted(i);  GameEvent.DOHitlag?.Invoke(x.frame, x.lerp); } } } }
    GameEvent.DOHitlag?.Invoke(x.frame, x.lerp);

    作用:当满足特定条件时,触发全局的\"受击停顿\"(Hitlag)效果,用于实现游戏中的\"子弹时间\"或打击感强化效果 

    1. 触发受击停顿

      • 当攻击命中目标时,调用此代码使游戏进入短暂慢动作状态。

      • x.frame:控制慢放持续多少帧

      • x.lerp:决定是否使用渐变过渡(否则直接暂停)

    2. 全局事件调度

      • 通过静态事件系统 GameEvent.DOHitlag 将触发指令传递到游戏核心系统

    语法结构:

    1. 空条件运算符 ?.

    csharpGameEvent.DOHitlag?.Invoke()
    • 等效逻辑

      csharpif (GameEvent.DOHitlag != null) { GameEvent.DOHitlag.Invoke(...);}

       

    2. 事件委托 Invoke()

    csharp.Invoke(x.frame, x.lerp)
    • 作用:触发所有绑定到 DOHitlag 的事件处理器

    • 参数传递

      • x.frame → 传递给 Main.DOHitlag(int frame, bool lerp) 的 frame 参数

      • x.lerp → 传递给 lerp 参数

    顿帧攻击方法: DOHitlag_OnAttack

    这段代码用于在攻击动作的特定时间点触发击中停顿(Hitlag)效果。  

     public void DOHitlag_OnAttack(float normalizedTime, PlayerState state) { if (state.stateEntity.hitlagConfig != null && state.stateEntity.hitlagConfig.Count > 0) { for (int i = 0; i = x.trigger && normalizedTime <= x.trigger2) {  if (GetExcuted(i) == false)  { SetExcuted(i); GameEvent.DOHitlag?.Invoke(x.frame, x.lerp);  } } } } }
    1. 条件检查

      csharpif (state.stateEntity.hitlagConfig != null && state.stateEntity.hitlagConfig.Count > 0)
      • 检查玩家状态(state)中是否存在有效的击中停顿配置(hitlagConfig列表非空)。
         

    2. 遍历配置列表

      csharpfor (int i = 0; i < state.stateEntity.hitlagConfig.Count; i++)
      • 遍历所有预先配置的Hitlag触发条件。
         

    3. 触发条件判断

      csharpvar x = state.stateEntity.hitlagConfig[i];//triggerType == 1意味着命中单位触发,且动画进度在两个触发点内if (x.triggerType == 1 && normalizedTime >= x.trigger && normalizedTime <= x.trigger2)
      • triggerType == 1:特定类型的触发条件(例如攻击动作)。

      • 当动画进度处于配置的区间[x.trigger, x.trigger2]时,满足触发条件
         

    4. 防止重复触发并执行hitlag效果

      csharpif (GetExcuted(i) == false){ SetExcuted(i);// 触发事件 GameEvent.DOHitlag?.Invoke(x.frame, x.lerp);}
      • GetExcuted/SetExcuted:确保同一配置只触发一次(避免同一动画帧内重复触发)

      • 发布事件,传递参数:

        • x.frame:停顿持续的帧数(控制卡顿时长)。

        • x.lerp:插值参数(可能用于控制停顿的平滑度或强度)

     
     

            注意事项:这里普通攻击的触发点大于0.15时,此时攻击动画会穿过目标,所以不再执行顿帧。

     两种触发方法的特性

    触发类型 触发条件 职责归属 Type 0 基于时间点触发
    (normalizedTime >= x.trigger) 动画系统
    (在OnUpdate中处理动画时间推进) Type 1 基于时间范围触发
    (normalizedTime ∈ [x.trigger, x.trigger2]) 战斗系统
    (在DOHitlag_OnAttack中处理攻击命中)

    AudioController类 

    using System.Collections;using System.Collections.Generic;using UnityEngine;public class AudioController{ //所有音频操作通过AudioController.Instance调用,避免多个实例导致的资源冲突 static AudioController instance = new AudioController();//确保全局唯一访问点 public static AudioController Instance => instance;//Instance 属性提供全局访问入口 Dictionary<string, Stack> pool = new Dictionary<string, Stack>(); public void Play(string path, Vector3 point, bool loop = false, float volume = 1, float spactialBlend = 1) { AudioSource audio = null; if (pool.ContainsKey(path) && pool[path].Count > 0) { audio = pool[path].Pop(); audio.gameObject.SetActive(true); } else { GameObject go = new GameObject(\"audio\"); audio = go.AddComponent(); } audio.transform.position = point; audio.clip = ResourcesManager.Instance.Load(path); audio.loop = loop; audio.volume = volume; audio.spatialBlend = spactialBlend; audio.Play(); } public void Stop(string path, AudioSource audioSource) { if (pool.ContainsKey(path) == false) { pool[path] = new Stack(); } audioSource.Stop(); audioSource.gameObject.SetActive(false); pool[path].Push(audioSource); }}

     初始化属性

    //所有音频操作通过AudioController.Instance调用,避免多个实例导致的资源冲突 static AudioController instance = new AudioController();//确保全局唯一访问点 public static AudioController Instance => instance;//Instance 属性提供全局访问入口 Dictionary<string, Stack> pool = new Dictionary<string, Stack>();

            对象池系统:

            对象池属于​​创建型设计模式​​,通过预初始化对象集合,实现对象的重复利用而非频繁创建/销毁,这里对象池系统的存储结构是Dictionary。

    Play方法 

     public void Play(string path, Vector3 point, bool loop = false, float volume = 1, float spactialBlend = 1) { AudioSource audio = null; if (pool.ContainsKey(path) && pool[path].Count > 0) { audio = pool[path].Pop(); audio.gameObject.SetActive(true); } else { GameObject go = new GameObject(\"audio\"); audio = go.AddComponent(); } audio.transform.position = point; audio.clip = ResourcesManager.Instance.Load(path); audio.loop = loop; audio.volume = volume; audio.spatialBlend = spactialBlend; audio.Play(); }

     1、声明属性的作用

    public void Play(string path, Vector3 point, bool loop = false, float volume = 1, float spactialBlend = 1)
    • path:音频资源在Resources目录下的路径(如\"Audio/SFX/jump\"
    • point:音频播放的3D世界坐标
    • loop:是否循环播放(默认关闭)
    • volume:音量(0~1,默认最大)
    • spatialBlend:3D/2D混合比例(0为纯2D,1为纯3D,默认全3D

    2、初始化为null

    AudioSource audio = null;

            对于引用类型的局部变量,使用`= null`进行初始化是一种常见的做法,特别是当变量将在后续的条件分支中被赋值时。

    Q1:不能直接用new函数进行初始化吗

    A1:

    • 每次调用Play方法都会创建一个新的AudioSource实例。
    • 即使对象池中有可用的实例,也会被这个新创建的实例覆盖,导致对象池失去意义。
    • 同时,这个新创建的AudioSource并没有附加到任何GameObject上(因为AudioSource是组件,必须依附于GameObject),所以这样写本身也是错误的

    3、检验对象池——有则取出激活,无则新建添加入池

    if (pool.ContainsKey(path) && pool[path].Count > 0) // 检查对象池{ audio = pool[path].Pop(); // 从池中取出 audio.gameObject.SetActive(true); // 激活对象}else // 池中没有可用对象{ GameObject go = new GameObject(\"audio\"); // 创建新游戏对象 audio = go.AddComponent(); // 添加音频组件}
    • 优先复用:通过路径查找对象池中闲置的 AudioSource

    • 按需创建:无可用对象时新建 GameObject + AudioSource

    • pool[path] 字典索引器,访问pool字典中键为pathStack实例 .Pop() 调用StackPop()方法,移除并返回栈顶元素

    4、音频参数配置并启动播放

    audio.transform.position = point; // 设置声源位置(3D音效关键)audio.clip = ResourcesManager.Instance.Load(path); // 加载音频资源audio.loop = loop;  // 设置是否循环播放audio.volume = volume;  // 设置音量(0.0-1.0)audio.spatialBlend = spactialBlend; // 设置3D/2D混合(0.0=纯2D, 1.0=纯3D)audio.Play(); // 开始播放音频

    Stop方法

            这段代码是 AudioController 类中的 Stop 方法,主要功能是停止音频播放并回收 AudioSource 到对象池 

    public void Stop(string path, AudioSource audioSource) { if (pool.ContainsKey(path) == false) { pool[path] = new Stack(); } audioSource.Stop(); audioSource.gameObject.SetActive(false); pool[path].Push(audioSource); }
    1. 确保对象池存在

      csharpif (pool.ContainsKey(path) == false){ pool[path] = new Stack();}
      • 检查该音频路径对应的对象池是否存在

      • 不存在则创建新的 Stack(按音频路径分类的对象池)

    2. 停止音频播放

      csharpaudioSource.Stop(); // 立即停止音频播放
    3. 禁用游戏对象

      csharpaudioSource.gameObject.SetActive(false);
      • 将关联的 GameObject 设为非激活状态

      • 避免在场景中显示(虽然音频对象通常不可见)

      • 准备对象复用

    4. 回收资源到对象池

      csharppool[path].Push(audioSource);
      • 将 AudioSource 压入对应路径的栈中

      • 标记为可复用状态

    Q1:为什么Stop方法中要检测音频路径对应的对象池是否存在?

    A1:需要注意的是,在当前的 AudioController 设计中,Play() 方法不会创建对象池

    • Play() 职责:获取/创建 AudioSource 并播放音频

    • Stop() 职责:回收资源并管理对象池

    对象池创建时机如下:

    • 对象池只在首次回收资源时创建

    • 播放时不需要池结构,只需要获取资源

      • 如果 Play() 中创建池,且对于导致大量零元素的空栈占用内存,对于只播放一次的音频是纯粹浪费

    CombatConfig类 和GlobalComabat

    using System.Collections;using System.Collections.Generic;using UnityEngine;public class CombatConfig{ static CombatConfig instance = new CombatConfig(); public static CombatConfig Instance => instance; GlobalCombatConfig config; public void Init() { config = ResourcesManager.Instance.Load(\"GlobalConfig/Combat\"); } public GlobalCombatConfig Config() { return config; }}
         static CombatConfig instance = new CombatConfig();//在类加载时创建唯一静态实例    public static CombatConfig Instance => instance;//通过属性公开该实例
    1. 单例模式实现

      • 效果:全局任何地方都可通过CombatConfig.Instance访问同一个配置管理器

     

    public void Init(){ // 从资源管理器加载配置 config = ResourcesManager.Instance.Load(\"GlobalConfig/Combat\");}
    • 资源路径\"GlobalConfig/Combat\"(Unity的Resources路径)

    • 加载方式:通过自定义的ResourcesManager加载泛型资源

    • 加载时机:需显式调用Init()初始化(通常在游戏启动时)

    • 注意事项:加载资源的路径名要相同

    [CreateAssetMenu(menuName = \"配置/创建战斗全局配置\")]public class GlobalCombatConfig : ScriptableObject{ [Header(\"格挡时的顿帧配置\")] public HitlagConfig block_hitlag; /*[Header(\"格挡时的镜头径向模糊效果\")] public RadialBlurConfig block_radialBlur;*/ [Header(\"格挡成功的特效\")] public string block_effect; [Header(\"格挡时的音效\")] public string block_audio; [Header(\"攻击到敌人的音效\")] public string hit_enemy_audio;}

            GlobalCombatConfig也是相当于继承了ScriptableObject类的写法:功能即是融合了​​状态模式​​、​​数据驱动设计​​ 和 ​​ScriptableObject资产化

            

    ResourcesManager类

    using System.Collections;using System.Collections.Generic;using UnityEngine;public class ResourcesManager{ static ResourcesManager instance = new ResourcesManager(); public static ResourcesManager Instance => instance; public T Load(string path) where T : Object { return Resources.Load(path); } public T Instantiate(string path) where T : Object { var r = Load(path); if (r != null) { return Object.Instantiate(r); } return null; } public void Destroy(GameObject go) { Object.Destroy(go); } Stack hit_effect = new Stack(50); public GameObject Create_Hit_Effect(string path) { if (hit_effect.Count > 0) { var go = hit_effect.Pop(); //go.SetActive(true); return go; } else { var obj = Instantiate(path); Object.DontDestroyOnLoad(obj);//切场景不进行销毁 return obj; } } public void Destroy_Hit_Effect(GameObject go) { if (go != null) { go.SetActive(false); hit_effect.Push(go); } }}

    资源的加载和实例化 

    1. 单例模式 (Singleton)

    csharpstatic ResourcesManager instance = new ResourcesManager();public static ResourcesManager Instance => instance;
    • 作用:确保全局只有一个资源管理器实例,通过 ResourcesManager.Instance 全局访问。

    2.资源管理——加载资源和生成实例

    public T Load(string path) where T : Object { return Resources.Load(path);}

            功能:从 Resources 文件夹加载资源(如预制体、纹理等)

            示例Load(\"Prefabs/Effect\") 加载特效预制体。

     

    public T Instantiate(string path) where T : Object { var r = Load(path); if (r != null) { return Object.Instantiate(r); } return null; }
    • 功能:加载并实例化资源(如生成游戏对象)。

    • 示例Instantiate(\"Effects/Explosion\") 创建爆炸效果


    3.销毁对象

    csharppublic void Destroy(GameObject go) { Object.Destroy(go);}
    • 功能:销毁游戏对象(直接调用 Unity 的 Destroy


    4. 对象池系统 (重点)

    (1) 对象池容器

    csharpStack hit_effect = new Stack(50);
    • 作用:用栈存储可重用的击中效果对象(初始化容量 50)。
       

    (2) 获取击中效果

    csharppublic GameObject Create_Hit_Effect(string path) { if (hit_effect.Count > 0) { var go = hit_effect.Pop(); // 从池中取出 // go.SetActive(true); // 需取消注释以激活对象 return go; } else { var obj = Instantiate(path); Object.DontDestroyOnLoad(obj); // 跨场景不销毁 return obj; }}
    • 逻辑

      • 池中有对象 → 直接取出复用(需手动激活对象,当前代码注释了 SetActive(true))。

      • 池为空 → 创建新对象并标记为跨场景不销毁。
         

    (3) 回收击中效果

    csharppublic void Destroy_Hit_Effect(GameObject go) { if (go != null) { go.SetActive(false); // 隐藏而非销毁 hit_effect.Push(go); // 压入栈中备用 }}
    • 逻辑:将当前击中特效对象设为非激活状态并存入对象池,避免重复实例化。

    Q1:资源加载和生成实例化有什么区别(Load与Instantiate的功能区别)

    A1:

    Load:资源加载(获取引用)

    csharppublic T Load(string path) where T : Object { return Resources.Load(path);}
    • 核心作用:从磁盘加载资源到内存(获取资源引用

    • 返回结果:资源的原始文件引用(未实例化)

    • 类比:从仓库取出设计图纸(图纸本身不能直接使用)

    Instantiate:实例化(创建对象)

    csharppublic T Instantiate(string path) where T : Object { var r = Load(path); if (r != null) return Object.Instantiate(r); return null;}
    • 核心作用:创建资源的场景实例生成游戏对象

    • 返回结果:在场景中新创建的实例对象

    • 类比:根据图纸生产具体产品(产品可放入场景使用

     Create_Hit_Effect方法

    Stack hit_effect = new Stack(50);//为特效物体声明一个栈进行存储 public GameObject Create_Hit_Effect(string path) { if (hit_effect.Count > 0)//当存储特效物体栈不为0 { var go = hit_effect.Pop();取出栈中的物体赋值给go //go.SetActive(true); return go;//把go的值返回 } else { var obj = Instantiate(path);//通过path路径初始化生成特效物体赋值给obj Object.DontDestroyOnLoad(obj);//切场景不进行销毁 return obj;//把obj的值返回 } }

    伤害计算辅助:AttHelper类 

    using System.Collections;using System.Collections.Generic;using Game.Config;using UnityEngine;public class AttHelper{ static AttHelper instance = new AttHelper(); public static AttHelper Instance => instance;//创建实例避免外部修改 ///  /// 通过ID获取属性配置表对应的实体 ///  ///  ///  public UnitAttEntity Creat(int id) { var a = UnitAttData.Get(id); if (a == null) return null; UnitAttEntity b = new UnitAttEntity(); b.id = a.id; b.hp = a.hp; b.phy_atk = a.phy_atk; b.magic_atk = a.magic_atk; b.phy_def = a.phy_def; b.magic_def = a.magic_def; b.critical_hit_rate = a.critical_hit_rate; b.critical_hit_multiple = a.critical_hit_multiple; b.skill_speed = a.skill_speed; return b; } public UnitAttEntity Creat(UnitAttEntity a) { UnitAttEntity b = new UnitAttEntity(); b.id = a.id; b.hp = a.hp; b.phy_atk = a.phy_atk; b.magic_atk = a.magic_atk; b.phy_def = a.phy_def; b.magic_def = a.magic_def; b.critical_hit_rate = a.critical_hit_rate; b.critical_hit_multiple = a.critical_hit_multiple; b.skill_speed = a.skill_speed; return b; } public int Damage(FSM atk, PlayerState state, FSM hit) { int damage = 0; var critical = UnityEngine.Random.Range(0, 101f) <= atk.att_crn.critical_hit_rate; //没有暴击的情况 if (critical == false) { damage = (int)((atk.att_crn.phy_atk - hit.att_crn.phy_def + state.skill.phy_damage)); } else { //暴击 damage = (int)((atk.att_crn.phy_atk - hit.att_crn.phy_def + state.skill.phy_damage) * atk.att_crn.critical_hit_multiple); } return damage; }}

     
    两种创建UnitAttEntity类型表格的方式

    public UnitAttEntity Creat(int id) 根据int类型生成UnitAttEntity类型表格 { var a = UnitAttData.Get(id); if (a == null) return null;//如果a是空表,退出当前方法,也不会执行另一个生成表格方式 UnitAttEntity b = new UnitAttEntity(); b.id = a.id; b.hp = a.hp; b.phy_atk = a.phy_atk; b.magic_atk = a.magic_atk; b.phy_def = a.phy_def; b.magic_def = a.magic_def; b.critical_hit_rate = a.critical_hit_rate; b.critical_hit_multiple = a.critical_hit_multiple; b.skill_speed = a.skill_speed; return b; } public UnitAttEntity Creat(UnitAttEntity a)//通过现有实体创建(克隆) { UnitAttEntity b = new UnitAttEntity(); b.id = a.id; b.hp = a.hp; b.phy_atk = a.phy_atk; b.magic_atk = a.magic_atk; b.phy_def = a.phy_def; b.magic_def = a.magic_def; b.critical_hit_rate = a.critical_hit_rate; b.critical_hit_multiple = a.critical_hit_multiple; b.skill_speed = a.skill_speed; return b; }

     Damage方法

     public int Damage(FSM atk, PlayerState state, FSM hit) { int damage = 0; //生成一个 0-100的随机浮点数,与攻击方的暴击率比较 //随机数 ≤ 暴击率 → 触发暴击 (critical=true) var critical = UnityEngine.Random.Range(0, 101f) <= atk.att_crn.critical_hit_rate; //没有暴击的情况 if (critical == false) { damage = (int)((atk.att_crn.phy_atk - hit.att_crn.phy_def + state.skill.phy_damage)); //攻击方物理攻击力 - 防御方物理防御力 + 技能附加伤害=基础伤害 } else { //暴击 damage = (int)((atk.att_crn.phy_atk - hit.att_crn.phy_def + state.skill.phy_damage) * atk.att_crn.critical_hit_multiple); //基础伤害*暴击倍数=暴击伤害 } return damage; }}

     1、随机生成数进行判断比较

     var critical = UnityEngine.Random.Range(0, 101f) <= atk.att_crn.critical_hit_rate;
    1. UnityEngine.Random.Range(0, 101f)

      • ​功能​​:生成一个 ​​0(包含)到 101(包含)​​ 之间的随机浮点数。
      • ​参数说明​​:
        • 第一个参数是下限(minInclusive),第二个参数是上限(maxInclusive)。
    2. <= atk.att_crn.critical_hit_rate

      • ​比较运算符​​:判断生成的随机数是否 ​​小于等于​​ 攻击方的暴击率(critical_hit_rate)。
        • 所有比较运算符(如 ==!=><>=<=)​​ 的运算结果均为 ​​布尔值(bool
      • ​暴击率含义​​:例如若 critical_hit_rate = 10,表示 10% 的暴击概率(由表格中的值所设置确定)。
    3. ​赋值给 var critical

      • ​变量类型推断​​:var 自动推导为 bool 类型,结果为 true(暴击)或 false(未暴击)。

    FSM类补充(受击相关方法):

    OnHit方法(被攻击时) 

     public FSM atk_target;//攻击方 public void OnHit(int fd, FSM atk) { if (currentState.excel_config.on_hit != null) //看当前状态的Excel是否有写入受击相关词条 { // ToNext(currentState.excel_config.on_hit[fd]); if (fd == 0)//如果目标在角色正前方 { ToNext(currentState.excel_config.on_hit[0]); } else if (fd == 1)//如果目标在角色正后方 { ToNext(currentState.excel_config.on_hit[1]); } } }

    OnDeath方法(死亡判定)

     public void OnDeath(int fd) { //也是根据当前状态的EXcel表看是否写入死亡相关动作接口 if (currentState.excel_config.on_death != null) { if (fd == 0)//前方 { ToNext(currentState.excel_config.on_death[0]); } else if (fd == 1)//后方 { ToNext(currentState.excel_config.on_death[1]); } characterController.enabled = false; //击杀boss 特殊逻辑处理 //主角死亡 特殊的逻辑处理(游戏循环) } }

    UpdateHP_OnHit方法(伤害累计判断角色)

     internal void UpdateHP_OnHit(int damage) { this.att_crn.hp -= damage;//对伤害的累积赋值给当前FSM持有者的hp if (this.att_crn.hp < 0)如果当前目标生命值小于0 { this.att_crn.hp = 0; } if (AI)//如果勾选了FSM中的选项,说明当前FSM持有者是敌人 { //更新敌人血条 if (unitEntity.type == 3) { //更新Boss的血条 } else if (unitEntity.type == 1 || unitEntity.type == 2) { //更新小兵的血条 } } else//反之则为敌人 { //更新主角的血条 } }

    BeBlock方法(格挡)

    public void BeBlock(FSM player) { if (currentState.excel_config.be_block != null)//看当前状态的Excel表中是否写入了能被格挡的情况 { if (_transform.ForwardOrBack(player._transform.position) > 0) { ToNext(currentState.excel_config.be_block[0]); } else { ToNext(currentState.excel_config.be_block[1]); } } }

    OnBlockSucces方法(当格挡成功时)

     public FSM atk_target;//攻击方 internal void OnBlockSucces(FSM atk) {  this.atk_target = atk; //记录当前格挡动作的发起者(攻击方) if (currentState.excel_config.on_block_succes != 0) //查看该状态的Excel表中是否有格挡成功时的判定 { ToNext(currentState.excel_config.on_block_succes); //格挡成功后的动作 } }

    位置计算辅助:TransHelper类

    using System.Collections.Generic;using UnityEngine;public static class TransformHelper{ ///  /// 检测在前后 ///  ///  ///  /// 大于0在前方 等于0平行 小于0在后方 public static float ForwardOrBack(this Transform t, Vector3 targetPosition) { // 1. 创建水平方向向量(忽略Y轴高度差) //通过将目标位置的Y坐标设置为当前物体位置的Y坐标,确保计算在水平面进行 Vector3 delta = new Vector3(targetPosition.x, t.position.y, targetPosition.z) - t.position; // 2. 计算物体前方向量与目标方向向量的点积 float v = Vector3.Dot(t.forward, delta); // 3. 返回点积值作为结果 return v; }}
    1. 点积计算

      csharpfloat v = Vector3.Dot(t.forward, delta);
      • t.forward:物体自身的正前方向量(蓝色箭头方向)

      • delta:从物体指向目标的水平方向向量

      • 点积结果反映两个向量之间的方向关系

    举例说明Dot方法如何判断方位 

    示例1:目标在物体前方

    假设:

    物体位置:(0, 0, 0)物体朝向:正Z轴 (0, 0, 1)目标位置:(0, 0, 5) // 正前方

    计算:

    csharpVector3 delta = new Vector3(0, 0, 5) - (0, 0, 0) = (0, 0, 5);float dot = Vector3.Dot(new Vector3(0, 0, 1), new Vector3(0, 0, 5));// 计算:0*0 + 0*0 + 1*5 = 5

    结果:5 > 0,表示目标在前方


    示例2:目标在物体后方

    假设:

    物体位置:(0, 0, 0)物体朝向:正Z轴 (0, 0, 1)目标位置:(0, 0, -3) // 正后方

    计算:

    csharpVector3 delta = new Vector3(0, 0, -3) - (0, 0, 0) = (0, 0, -3);float dot = Vector3.Dot(new Vector3(0, 0, 1), new Vector3(0, 0, -3));// 计算:0*0 + 0*0 + 1*(-3) = -3

    结果:-3 < 0,表示目标在后方


    示例3:目标在物体侧方

    假设:

    物体位置:(0, 0, 0)物体朝向:正Z轴 (0, 0, 1)目标位置:(4, 0, 0) // 正右侧

    计算:

    csharpVector3 delta = new Vector3(4, 0, 0) - (0, 0, 0) = (4, 0, 0);float dot = Vector3.Dot(new Vector3(0, 0, 1), new Vector3(4, 0, 0));// 计算:0*4 + 0*0 + 1*0 = 0

    结果:0,表示目标在正侧方

     问题:径向模糊处理出现问题,报错太多,需要了解shader使用原理和渲染管线原理

    格挡循环播放我没这样设置但是能持续格挡,看看之后会出什么问题吗