当前位置: 首页 > news >正文

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-coreSystem.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance);List<string> fileLst = new List<string>();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<int, PlayerState> stateData = new Dictionary<int, PlayerState>();//存储各个角色状态信息的目录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<Animator>();characterController =GetComponent<CharacterController>();unitEntity = UnitData.Get(id);// 通过ID加载角色配置数据,这里在Inspctor中已经填写1001ServiceInit();  // 创建核心服务系统StateInit();    // 加载所有状态配置ToNext(1001);  // 进入ID为1001的初始状态
(这里需要注意的是currentPlayerState已在ToNext(1001);赋值了)}

        这里id已经赋值为1001

unitEntity = UnitData.Get(id);
public static UnitEntity Get(int id)
{// 1. 检查缓存字典是否已初始化且包含目标IDif (entityDic != null && entityDic.TryGetValue(id, out var entity)){// 2. 如果找到则直接返回缓存对象return entity;}// 3. 找不到则返回nullreturn 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<int, UnitEntity>(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<int, UnitEntity> all {get {return entityDic;}}static Dictionary<int, UnitEntity> 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<int, UnitEntity>(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<AnimationService>();physicsService=AddService<PhysicsService>();service_count = fSMService.Count;}

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

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

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

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

AddService方法(添加服务层) 

public T AddService<T>() 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>(),这里就会创建一个 AnimationService 实例。

  


fSMService.Add(com);

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

  所以fSMService.Add(com) 的 com ​​既是 <AnimationService> 类型,也是 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>() 来创建它,并保存引用供后续使用。
animationService = AddService<AnimationService>();

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

  • 当前状态改变时:

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

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

StateInit方法(状态初始化)

 public void StateInit(){anmConfig = Resources.Load<StateScriptableObject>($"StateConfig/{id}");Dictionary<int, StateEntity> state_config = new Dictionary<int, StateEntity>();foreach (var item in anmConfig.states){state_config[item.id] = item;}var clips = _animator.runtimeAnimatorController.animationClips;Dictionary<string, float> clipLength = new Dictionary<string, float>();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 < item.Value.excel_config.on_jump.Length; i++){Debug.Log($"item.Value.excel_config.on_jump[{i}]: {item.Value.excel_config.on_jump[i]}");}AddListener(item.Key,StateEventType.update, OnJump);}if (item.Value.excel_config.on_jump_end != 0){Debug.Log("item.Value.excel_config.on_jump_end" + item.Value.excel_config.on_jump_end);          AddListener(item.Key, StateEventType.update, OnJumpUpdate);}if (item.Value.excel_config.add_f_move > 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<StateScriptableObject>($"StateConfig/{id}");
//这里的id是初始值1001,在可视化面板编辑的,也是路径stateConfig的子物体名称
Dictionary<int, StateEntity> state_config = new Dictionary<int, StateEntity>();
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<string, float> clipLength = new Dictionary<string, float>();
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<int, PlayerStateEntity>(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<int, PlayerStateEntity>  all{get {return entityDic;}}

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

PlayerStateEntity e0 = new PlayerStateEntity(id: 1001,info: @"待机",                    // 状态说明:待机anm_name: @"idle",                // 动画名称:idleon_anm_end: 0,                    // 动画结束时无操作on_move: new float[]{1f,1f,1002f},// 移动时切换到ID 1002(跑)on_stop: 0,                       // 停止移动时无操作on_pow_atk: new float[]{1f,1f,1019f}, // 蓄力攻击时切换到ID 1019on_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 1009on_skill2: new float[]{1f,1f,1010f}, // 技能2时切换到ID 1010on_skill3: new float[]{1f,1f,1011f}, // 技能3时切换到ID 1011on_skill4: new float[]{1f,1f,1012f}, // 技能4时切换到ID 1012on_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<int, PlayerState> stateData = new Dictionary<int, PlayerState>();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<int, PlayerState> 类型的数据结构,其中:
    • 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<Action>>> actions = new Dictionary<int, Dictionary<StateEventType, List<Action>>>();public void Addlinstenr(int id, StateEventType t, Action action){// 1. 确保外层字典有id对应的条目if (!actions.TryGetValue(id, out var innerDict)){innerDict = new Dictionary<StateEventType, List<Action>>();actions[id] = innerDict;}// 2. 确保内层字典有t对应的列表if (!innerDict.TryGetValue(t, out var actionList)){actionList = new List<Action>();innerDict[t] = actionList;}// 3. 添加action到列表actionList.Add(action);}

public Dictionary<int, Dictionary<StateEventType, List<Action>>> actions = new Dictionary<int, Dictionary<StateEventType, List<Action>>>();
  1. 数据结构

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

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

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

      • 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<Action>>();
    • 把 innerDict 添加到 actions[100]
  2. innerDict 中没有 StateEventType.update
    • 创建一个新的 actionList = new List<Action>()
    • 把 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<Action>
    • 存入 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<Action>
  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<int, SkillEntity>(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<int, SkillEntity> all {get {return entityDic;}}static Dictionary<int, SkillEntity> 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<AnimationService>() 创建动画服务对象(也可以添加其他服务对象)

    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; // 保存当前玩家状态的 IDfor (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; // 保存当前玩家状态的 IDfor (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[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<string, GameObject> hangPoint = new Dictionary<string, GameObject>();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<string, GameObject> hangPoint = new Dictionary<string, GameObject>();

    这里注意类型定义​​:键为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 framevoid 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<CharacterController>();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<CharacterController>();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 < min,返回 min;
    若 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) * t
    a:当前速度(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<StateEntity> states),每个StateEntity包含状态ID和描述信息。

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

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

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

    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<StateEntity> states = new List<StateEntity>();public void OnAfterDeserialize(){
    #if UNITY_EDITORif (states.Count == 0){Dictionary<int, PlayerStateEntity> 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<int, PlayerStateEntity> 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<StateEntity> remove = new List<StateEntity>();//删除掉多余的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> 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<StateEntity> states = new List<StateEntity>();

    [SerializeField]

    • 作用:强制将public List<StateEntity> 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<StateEntity> states;if (states.Count == 0) 
    {// 遍历所有配置数据foreach (var item in PlayerStateData.all) {StateEntity entity = new StateEntity();entity.id = item.Value.id;          // 复制IDentity.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;// 检查当前项是否已存在于 statesfor (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<StateEntity> remove = new List<StateEntity>();// 标记 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<StateEntity> remove = new List<StateEntity>();
    foreach (var item in states) 
    {if (!dct.ContainsKey(item.id)) {remove.Add(item);}
    }
    foreach (var item in remove) 
    {states.Remove(item);
    }
    • 反向遍历本地数据​​:标记所有在dct中不存在的本地条目
    • ​批量删除​​:通过中间列表remove避免遍历时修改集合异常

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

    // 假设初始配置包含2个状态  配置表dct
    PlayerStateData.all = new Dictionary<int, PlayerStateEntity>{{1001, new PlayerStateEntity{ id=1001, info="IDLE" }},{1002, new PlayerStateEntity{ id=1002, info="WALK" }}
    };// 初始同步后包含2个状态实体
    states = new List<StateEntity>{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=1003if (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<StateEntity> remove = new List<StateEntity>();
    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> 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 < e.Count; i++){var entity = e[i];if (normalizedTime >= entity.trigger && GetExcuted(i) == false){Do(entity, state);SetExcuted(i);}}}if (begin){//动作进度 小于 配置的结束点if (normalizedTime <= currentEntity.time){if (currentEntity.time > 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){begin = false;}else{currentEntity = entity;if (entity.time > 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 < e.Count; i++){var entity = e[i];if (normalizedTime >= entity.trigger && GetExcuted(i) == false){Do(entity, state);SetExcuted(i);}}}if (begin){//动作进度 小于 配置的结束点if (normalizedTime <= currentEntity.time){if (currentEntity.time > 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 < e.Count; i++) {// 当动画进度到达触发点,且未执行过时if (normalizedTime >= 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 <= currentEntity.time){if (currentEntity.time > 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();}
    }


    射线检测的函数:

    csharp
    var 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){begin = false;  // 无效时间窗口,禁用效果}else{currentEntity = entity;  // 绑定当前物理配置if (entity.time > 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){begin = false;  // 无效时间窗口,禁用效果}else{currentEntity = entity;  // 绑定当前物理配置// 根据时间配置计算力值force = (entity.time > 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 < os.Count; i++){var item = os[i];//强制执行该条配置 是否未执行过if (normalizedTime >= 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 < os.Count; i++){//对配置表进行遍历,如果达到触发点且未被标记过var item = os[i];if (normalizedTime >= 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; // 记录当前播放的动画IDplayer._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; // 确保进度不超过1player.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 < configs.Count; i++){var e = configs[i];if (normalizedTime >= 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<int> hit_target = new List<int>();//记录哪些单位被击中过,避免多计算了伤害public bool Linecast(Vector3 begin, Vector3 end, HitConfig config, PlayerState state){Debug.DrawLine(begin, end, Color.red, 0.2f);//Physics.RaycastNonAllocvar 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<FSM>();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<FSM>();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;//受击配置表不为空且配置表数量不为0if (configs != null && configs.Count > 0){for (int i = 0; i < configs.Count; i++){//选中当前的受击配置表,如果当前进度在触发点和结束点范围之内,则触发DO方法var e = configs[i];if (normalizedTime >= 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<int> hit_target = new List<int>();//记录哪些单位被击中过,避免多计算了伤害private void OnHit(Vector3 begin, HitConfig config, PlayerState state, RaycastHit hitInfo){//表示击中单位并且获取该单位的FSM组件var fsm = hitInfo.transform.GetComponent<FSM>();if (fsm != null){//判断hit_target是否记录着当前实例idif (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<FSM>();
    1. hitInfo.transform

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

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

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

    2. GetComponent<FSM>()

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

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

    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 是碰撞表面的垂直方向向量(单位向量)

    顿帧服务逻辑类: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 < state.stateEntity.hitlagConfig.Count; i++){var x = state.stateEntity.hitlagConfig[i];if (x.triggerType == 0 && normalizedTime >= 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 < state.stateEntity.hitlagConfig.Count; i++){var x = state.stateEntity.hitlagConfig[i];if (x.triggerType == 1 && normalizedTime >= 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);//如果当前顿帧配置表不为空且数量不为0if (state.stateEntity.hitlagConfig != null && state.stateEntity.hitlagConfig.Count > 0){//遍历所有顿帧配置表for (int i = 0; i < state.stateEntity.hitlagConfig.Count; i++){//对当前遍历选中的配置表进行赋值var x = state.stateEntity.hitlagConfig[i];//如果触发类型是0(直接触发)且在触发点,且未被标记if (x.triggerType == 0 && normalizedTime >= 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 < state.stateEntity.hitlagConfig.Count; i++){var x = state.stateEntity.hitlagConfig[i];if (x.triggerType == 1 && normalizedTime >= 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<AudioSource>> pool = new Dictionary<string, Stack<AudioSource>>();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<AudioSource>();}audio.transform.position = point;audio.clip = ResourcesManager.Instance.Load<AudioClip>(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>();}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<AudioSource>> pool = new Dictionary<string, Stack<AudioSource>>();

            对象池系统:

            对象池属于​​创建型设计模式​​,通过预初始化对象集合,实现对象的重复利用而非频繁创建/销毁,这里对象池系统的存储结构是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<AudioSource>();}audio.transform.position = point;audio.clip = ResourcesManager.Instance.Load<AudioClip>(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>(); // 添加音频组件
    }
    • 优先复用:通过路径查找对象池中闲置的 AudioSource

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

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

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

    audio.transform.position = point; // 设置声源位置(3D音效关键)
    audio.clip = ResourcesManager.Instance.Load<AudioClip>(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>();}audioSource.Stop();audioSource.gameObject.SetActive(false);pool[path].Push(audioSource);}
    1. 确保对象池存在

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

      • 不存在则创建新的 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类 

    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<GlobalCombatConfig>("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<GlobalCombatConfig>("GlobalConfig/Combat");
    }
    • 资源路径"GlobalConfig/Combat"(Unity的Resources路径)

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

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

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

    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<T>(string path) where T : Object{return Resources.Load<T>(path);}public T Instantiate<T>(string path) where T : Object{var r = Load<T>(path);if (r != null){return Object.Instantiate(r);}return null;}public void Destroy(GameObject go){Object.Destroy(go);}Stack<GameObject> hit_effect = new Stack<GameObject>(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<GameObject>(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<T>(string path) where T : Object {return Resources.Load<T>(path);
    }

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

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


     

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

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


    3.销毁对象

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


    4. 对象池系统 (重点)

    (1) 对象池容器

    csharpStack<GameObject> hit_effect = new Stack<GameObject>(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<GameObject>(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<T>与Instantiate<T>的功能区别)

    A1:

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

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

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

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

    Instantiate<T>:实例化(创建对象)

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

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

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

     Create_Hit_Effect方法

    Stack<GameObject> hit_effect = new Stack<GameObject>(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<GameObject>(path);//通过path路径初始化生成特效物体赋值给objObject.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;//创建实例避免外部修改/// <summary>/// 通过ID获取属性配置表对应的实体/// </summary>/// <param name="id"></param>/// <returns></returns>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(未暴击)。

     

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

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

    http://www.lryc.cn/news/599874.html

    相关文章:

  • 负载均衡:提升业务性能的关键技术
  • 后端项目中大量 SQL 执行的性能优化
  • ptmalloc(glibc-2.12.1)源码解析2
  • 基于米尔瑞芯微RK3576开发板部署运行TinyMaix:超轻量级推理框架
  • Shopify Section Rendering API
  • 小白如何认识并处理Java异常?
  • 【嵌入式汇编基础】-ARM架构基础(二)
  • 从0到1:初创企业适合做企业架构吗?TOGAF 能带来什么?
  • 小架构step系列25:错误码
  • Haproxy七层代理及配置
  • 数据结构2-集合类ArrayList与洗牌算法
  • 在Word和WPS文字中添加的拼音放到文字右边
  • JS与Go:编程语言双星的碰撞与共生
  • 初识opencv04——图像预处理3
  • ModelWhale+数据分析 消费者行为数据分析实战
  • 判断子序列-leetcode
  • 广州 VR 安全用电技术:工作原理、特性及优势探析​
  • CTF-Web题解:“require_once(‘flag.php‘); assert(“$i == $u“);”
  • Linux系统基本配置以及认识文件作用
  • 双非上岸985!专业课140分经验!信号与系统考研专业课140+上岸中南大学,通信考研小马哥
  • 20分钟学会TypeScript
  • 本地内网IP映射到公网访问如何实现?内网端口映射外网工具有哪些?
  • VUE2 学习笔记6 vue数据监测原理
  • 局域网 IP地址
  • Linux tcpdump 抓取udp 报文
  • 开源语音TTS与ASR大模型选型指南(2025最新版)(疯聊AI提供)
  • 动态规划:从入门到精通
  • 中国开源Qwen3 Coder与Kimi K2哪个最适合编程
  • 电子电子架构 --- 软件项目的开端:裁剪
  • 【IDEA】IDEA中如何通过分支/master提交git?