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

配置表格

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

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

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

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

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

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

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

ExcelTools 编辑器脚本的功能

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

  1. 提供菜单项

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

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

  2. 流程控制和状态检查

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

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

  3. 路径配置和初始化

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

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

  4. 外部进程管理

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

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

  5. 编译和刷新操作

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

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

Excel2CS 脚本的功能

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

  1. Excel 文件读取

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

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

  2. 数据解析和转换

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

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

  3. 生成 C# 配置类

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

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

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

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

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

  5. 错误处理和日志记录

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

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

两者的协同工作关系

  1. 触发和流程管理

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

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

  2. 核心转换逻辑执行

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

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

  3. 后续处理

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

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

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

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

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

正确放置位置:

截取部分Excel2CS脚本

复制代码
static string path = AppDomain.CurrentDomain.BaseDirectory + "/../../../../../Tools/Excel/";
static string writePath = ... // 输出CS文件的路径
static string jsonPath = ... // 输出JSON文件的路径


      static void Start()
        {
            //https://github.com/ExcelDataReader/ExcelDataReader#important-note-on-net-core
            System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance);
            List<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)

      csharp

      if (!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中已经填写1001

        ServiceInit();  // 创建核心服务系统
        StateInit();    // 加载所有状态配置
        ToNext(1001);  // 进入ID为1001的初始状态
(这里需要注意的是currentPlayerState已在ToNext(1001);赋值了)
    }

这里id已经赋值为1001

复制代码
unitEntity = UnitData.Get(id);

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

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

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

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

    • id: 要查找的键

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

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

UnitData 类

复制代码
using System.Collections.Generic;
using UnityEngine;

namespace Game.Config
{
    public class UnitData
    {

        static UnitData()
        {
            entityDic = new Dictionary<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
    {
        //TemplateMember
		public int id;//单位ID
		public string info;//说明
		public int type;//类型
		public int camp;//阵营
		public int att_id;//属性表ID
		public int ntk1;//技能ID_普攻1
		public int ntk2;//技能ID_普攻2
		public int ntk3;//技能ID_普攻3
		public int ntk4;//技能ID_普攻4
		public int skill1;//技能ID_技能1
		public int skill2;//技能ID_技能2
		public int skill3;//技能ID_技能3
		public int skill4;//技能ID_技能4
		public 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
    {
        //TemplateMember
		public int id;//单位ID
		public string info;//说明
		public int type;//类型
		public int camp;//阵营
		public int att_id;//属性表ID
		public 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 类型的新实例。例如,如果调用的是 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 实例作为参数传递给 AnimationServiceInit 方法,这意味着:

  • 每个服务都可以持有对主控类(这里是 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方法(状态初始化)

cs 复制代码
 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);
            }
        }
    }

加载状态配置资源

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

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

复制代码
 //PlayerState的类
public class PlayerState
{
    public int id;
    //配置表
    public PlayerStateEntity excel_config;
    
    internal float clipLength;
    public SkillEntity skill;
    //动画通知事件
    public float begin_time;}

//Dictionary<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

  1. 检查 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 和事件类型上

  1. 检查 do_move == 1

    if (item.Value.excel_config.do_move == 1)
    {
    Addlinstenr(item.Key, StateEventType.update, PlayerMove);
    }

  • 如果 do_move 的值为 1,则添加另一个更新事件监听器 PlayerMove

  1. 检查 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
    {
        //TemplateMember
		public 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方法

cs 复制代码
 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. 状态存在性检查

    复制代码
    csharp
    
    if (stateData.ContainsKey(Next)) // Next = 1001
    • 检查字典中是否存在ID为1001的状态配置
  2. 日志输出

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

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

  3. 结束旧状态处理

    复制代码
    csharp
    
    if (currentPlayerstate != null)
    {
        DOStateEvent(currentPlayerstate.id, StateEventType.end);
        ServicesOnEnd();
    }
    • 当前无旧状态,跳过此段代码
  4. 设置新状态

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

    复制代码
    csharp
    
    DOStateEvent(currentPlayerstate.id, StateEventType.begin);
    • 执行所有注册到状态1001的begin类型事件

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

  6. 服务系统初始化

    复制代码
    csharp
    
    ServiceOnBegin();
    • 调用所有服务的OnBegin方法(如AnimationService)

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

  7. 返回结果

    复制代码
    csharp
    
    return 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方法)

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

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

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

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

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

  3. 执行所有注册的方法

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

ServiceOnUpdate() 方法的作用

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

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

  • 实时检测状态变化

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

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

重载virtue方法

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

public class FSMServiceBase
{
    public FSM player;

   //每一帧更新
    public virtual void OnUpdate(float normaizedTime,PlayerState state) { }
}



//AnimationService是FSMServiceBase的基类
public class AnimationService : FSMServiceBase
{
    public float normalizedTime;//当前动作播放进度
    public string now_play_id;
    public override void OnUpdate(float normaizedTime, PlayerState state)
    {
        base.OnUpdate(normaizedTime, state);

        if (!string.IsNullOrEmpty(now_play_id))
        {
            ..................
            }

            //判定播放动作是否与配置一致?
        }
    }

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

  1. 多态机制

    由于 FSMServiceBase 中的 OnUpdatevirtual 方法,且 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. 输入方向归一化

    csharp

    Vector3 inputDirection = new Vector3(x, 0f, z).normalized;

  • 数学逻辑 :将原始输入值 (x, z) 转换为单位向量(长度为1)。

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


  1. 计算目标旋转角度

    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)

  1. 平滑旋转插值

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


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

    Vector3 targetDirection = Quaternion.Euler(0.0f, _targetRotation, 0.0f) * Vector3.forward;

  2. ​四元数构造​ ​:
    Quaternion.Euler(0, _targetRotation, 0) 创建一个绕Y轴旋转 _targetRotation 度的四元数。例如:

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

    • 例如:当Y轴旋转90度时,Vector3.forward 会被旋转到世界坐标系的X轴正方向((1,0,0))。
  4. ​坐标系转换​ ​:

    该运算等效于将角色当前的​​本地前方向量​​(即角色面朝方向)转换为世界坐标系下的目标方向。(这里涉及到四元数与向量的乘法计算,这里就抽象记忆只要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. 坐标系转换(可选)

复制代码
csharp

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

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

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

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


  1. 重力处理

    csharp

    Vector3 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._Gravitynew 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 单位

  1. 执行移动

    csharp

    characterController.Move(d2);

  • 调用Unity的CharacterController组件执行实际移动

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


  1. 地面检测标记

    csharp

    if (_do_ground_check) {
    ground_check = true;
    }

  • 设置标记通知系统需要更新地面状态

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

运动状态的结束判定

动画结束回调方法AnimationOnPlayEnd

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

cs 复制代码
   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

复制代码
csharp

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

2. 触发状态事件

复制代码
csharp

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

3. 执行服务层逻辑

复制代码
csharp

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

4. 关键状态校验

复制代码
csharp

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

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


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

复制代码
csharp

switch (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

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

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

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

cs 复制代码
    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方法

cs 复制代码
    internal void Attack_Hitlag(PlayerState state)
    {
        hitlagService.DOHitlag_OnAttack(animationService.normalizedTime, state);
    }

用来作为事件触发

单例类Main

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

  • 必要性

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

  • 优势

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

  1. 关键系统依赖的宿主
  • 时间缩放控制(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. 物体必须处于激活状态(否则协程不会执行)

  1. 全局单例的稳定访问
  • 示例CombatConfig.Instance.Init()

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

  1. 相机等关键引用托管
  • 代码GameDefine._Camera = GameObject.Find("Camera").transform

    通过空物体集中获取并存储场景中的关键对象(如主摄像机),供全局访问。

  1. 时间管理的统一入口
  • 代码GameTime.Update()Update中调用

    空物体作为持久存在的"时间管理器",确保每帧更新游戏时间逻辑。

cs 复制代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Main : MonoBehaviour
{
    public void Awake()
    {
        SystemInit();
    }

    private void SystemInit()
    {
        CombatConfig.Instance.Init();
        GameDefine._Camera = GameObject.Find("Camera").transform;

//当 GameEvent.DOHitlag 委托被赋值指向 Main.DOHitlag 方法时,它实际绑定的是当前 Main 实例。
        GameEvent.DOHitlag = DOHitlag;
        GameDefine.Init();
        
    }

    void Start()
    {
        
    }

    // Update is called once per frame
    void Update()
    {
        
        GameTime.Update();
    }

    Coroutine coroutine_hitlag;
    public void DOHitlag(int frame, bool lerp)
    {
        if (frame > 0 && Time.timeScale == 1)
        {
            if (coroutine_hitlag != null)
            {
                StopCoroutine(coroutine_hitlag);
            }
            coroutine_hitlag = StartCoroutine(Hitlag(frame, lerp));
        }
    }


    IEnumerator Hitlag(int frame, bool lerp)
    {
        for (int i = 0; i < frame; i++)
        {
            Time.timeScale = lerp ? Mathf.Lerp(1, 0, (float)i / frame) : 0;
            yield return new WaitForEndOfFrame();
        }
        Time.timeScale = 1;
        coroutine_hitlag = null;
    }
}
  1. 系统初始化 (SystemInit)

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

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

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

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

协程类:Hitlag

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

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

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

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

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

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

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

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

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

DOHitlag类

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

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

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

  2. 智能管理

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

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

  3. 条件过滤

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

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

实例类:UCameracontroller

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

复制代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class UCameracontroller : MonoBehaviour
{
    // Start is called before the first frame update
    //滑动鼠标,相机旋转
    //鼠标滚轮,相机离角色远近
    public Transform target;

    //滑动鼠标 相机旋转
    //鼠标滚轮 相机离角色远近

    CharacterController controller;
    Vector3 hight_offset;
    void Start()
    {
        if (target != null)
        {

            Cursor.lockState = CursorLockMode.Locked; ;
            Cursor.visible = false;//隐藏鼠标 不可见
            controller = target.GetComponent<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
  1. ​功能​
  • ​锁定光标到屏幕中心​:光标会被固定在游戏窗口中心,无法移动。
  • ​隐藏光标​ :无论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. 鼠标输入处理

    csharp

    xMouse += 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 的当前值限制在 -3080 之间。
    • yMouse 小于 -30,则赋值为 -30
    • yMouse 大于 80,则赋值为 80
  • 否则保持原值不变。

2.滚轮距离控制

复制代码
csharp

distanceFromTarget -= 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:避免摄像机距离过远导致视野过小

  1. 摄像机位置计算

    csharp

    Quaternion targetRotation = Quaternion.Euler(yMouse, xMouse, 0); // 创建目标旋转
    Vector3 targetPosition = target.position
    + targetRotation * new Vector3(0, 0, -distanceFromTarget) // 计算后方偏移
    + hight_offset; // 添加高度偏移csharp

  2. 第一行作用将鼠标输入转换为四元数旋转

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

    • 基础位置​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列表以匹配配置表变化

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

cs 复制代码
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_EDITOR
        if (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;
}

状态列表

cs 复制代码
[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`列表中。​

cs 复制代码
public List<StateEntity> states;

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

if (dct.Count != states.Count) // 检查数量是否一致
{
    // --- 新增缺失项 ---
    foreach (var item in dct) 
    {
        bool add = true;
        // 检查当前项是否已存在于 states
        for (int i = 0; i < states.Count; i++) 
        {
            if (states[i].id == item.Value.id) 
            {
                add = false; // 已存在则跳过
                break;
            }
        }
        // 不存在则新建并添加
        if (add) 
        {
            StateEntity stateEntity = new StateEntity();
            stateEntity.id = item.Value.id;
            stateEntity.info = $"{item.Value.id}_{item.Value.info}";
            states.Add(stateEntity);
        }
    }

    // --- 删除多余项 ---
    List<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

cs 复制代码
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. 若不存在则创建新条目

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

删除多余项

cs 复制代码
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避免遍历时修改集合异常
    举例说明:

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

cs 复制代码
// 假设初始配置包含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" });

代码执行流程:

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

得出结果如下:

cs 复制代码
states 现在包含3个元素:
1001_IDLE → 1002_WALK → 1003_RUN

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

cs 复制代码
states 现在包含3个元素:
1001_IDLE → 1002_WALK → 1003_RUN

触发同步:

cs 复制代码
if (dct.Count != states.Count) // 2 != 3 → 进入同步流程
cs 复制代码
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方法

cs 复制代码
 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

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

    复制代码
      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;

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

    // 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

cs 复制代码
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方法()

cs 复制代码
  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方法

cs 复制代码
   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);
                }
            }
        }
    }

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

cs 复制代码
obj.SetActive(item.act);

OnEnd方法

cs 复制代码
   public override void OnEnd(PlayerState state)
    {
        base.OnEnd(state);

        //
        var os = state.stateEntity.obj_States;
        if (os != null)
        {

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

动画逻辑服务类 AnimationService

复制代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

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

    public override void OnAnimationEnd(PlayerState state)
    {
        base.OnAnimationEnd(state);
    }

    void Play(PlayerState state)
    {
        normalizedTime = 0;
        this.now_play_id = state.excel_config.anm_name;
        player._animator.Play(state.excel_config.anm_name);//字段对应角色状态中表的字段名
        player._animator.Update(0);
        
    }

    public override void OnBegin(PlayerState state)
    {
        base.OnBegin(state);
        Play(state);
    }

    public override void OnDisable(PlayerState state)
    {
        base.OnDisable(state);
    }

    public override void OnEnd(PlayerState state)
    {
        base.OnEnd(state);
    }

    public override void OnUpdate(float normaizedTime, PlayerState state)
    {
        base.OnUpdate(normaizedTime, state);

        if (!string.IsNullOrEmpty(now_play_id))
        {
            var info = player._animator.GetCurrentAnimatorStateInfo(0);
            if (info.IsName(now_play_id))
            {
                //0---1 表示动作0%-100%的进度
                this.normalizedTime = info.normalizedTime;
                if (normalizedTime >= 1)
                {
                    //UDebug.LogError($"{transform.gameObject.name}:当前动画:{_anmID} 进度是:{normalizedTime}   //  {info.normalizedTime}");
                    this.normalizedTime = 1;
                    player.AnimationOnPlayEnd();//判定是结尾时,调用动画结束时的判断接口
                }
            }
            else
            {
                this.normalizedTime = 0;
            }

            //判定播放动作是否与配置一致?
        }
    }

  
    public override void ReLoop(PlayerState state)
    {
        base.ReLoop(state);
    }

    public override void ReStart(PlayerState state)
    {
        base.ReStart(state);
    }
}

Play方法

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

代码逐行解析:

  1. normalizedTime = 0

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

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

  2. this.now_play_id = state.excel_config.anm_name

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

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

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

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

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

  4. player._animator.Update(0)

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

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

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

OnUpdate方法

复制代码
public override void OnUpdate(float normaizedTime, PlayerState state)
{
    base.OnUpdate(normaizedTime, state); // 调用基类更新逻辑

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

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


复制代码
var info = player._animator.GetCurrentAnimatorStateInfo(0); 

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

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


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

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

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

Override类的基类调用

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

作用是执行基类原有逻辑​

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

受击逻辑服务类:HitService

cs 复制代码
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.RaycastNonAlloc
        var result = Physics.Linecast(begin, end, out var hitInfo, player.GetEnemyLayerMask(), QueryTriggerInteraction.Collide);
        if (result)
        {
            //处于格挡状态
            if (hitInfo.transform.CompareTag(GameDefine.WeaponTag))
            {
                OnBlock(hitInfo);

            }
            else
            {
                OnHit(begin, config, state, hitInfo);
            }

            return true;
        }
        return false;
    }

    private void OnBlock(RaycastHit hitInfo)
    {
        //格挡方
        var fsm = hitInfo.transform.GetComponentInParent<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

cs 复制代码
  public override void OnUpdate(float normalizedTime, PlayerState state)
    {
        base.OnUpdate(normalizedTime, state);

        //将受击配置表的信息存储到configs中
        var configs = state.stateEntity.hitConfigs;

        //受击配置表不为空且配置表数量不为0
        if (configs != null && configs.Count > 0)
        {
            for (int i = 0; i < configs.Count; i++)
            {
                //选中当前的受击配置表,如果当前进度在触发点和结束点范围之内,则触发DO方法
                var e = configs[i];
                if (normalizedTime >= e.trigger && normalizedTime <= e.end)
                {
                    DO(e, state);
                }
            }
        }

    }

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

DO方法

cs 复制代码
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);//走盒子检测方法
        }
    }
cs 复制代码
       //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. 更新终点记录的目的

    • 下一帧开始检测:

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

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

      • 插值计算完全错误

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

cs 复制代码
// 首次检测(没有历史数据)
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;

插值计算点

cs 复制代码
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类型的静态方法:用于计算两个向量之间的线性插值:

cs 复制代码
当 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

cs 复制代码
   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方法

cs 复制代码
    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是否记录着当前实例id
            if (hit_target.Contains(fsm.instance_id) == false)
            {
                hit_target.Add(fsm.instance_id);//没有记录则添加该实例

                //1.生成命中特效
                var hitObject = ResourcesManager.Instance.Create_Hit_Effect(config.hitObj);
                hitObject.SetActive(true);
                //2.设置特效的位置 朝向

                if (hitObject != null)
                {
                    hitObject.transform.position = hitInfo.point;
                    hitObject.transform.forward = hitInfo.normal;
                }

                //3.计算 扣掉血量
                var damage = AttHelper.Instance.Damage(this.player, state, fsm);
                fsm.UpdateHP_OnHit(damage);

                //4.通知对方进入受击 死亡的动作
                var fb = fsm._transform.ForwardOrBack(begin) > 0 ? 0 : 1;

                if (fsm.att_crn.hp > 0)
                {
                    fsm.OnHit(fb, this.player);
                }
                else
                {
                    fsm.OnDeath(fb);
                }
                //命中时的顿帧
                this.player.Attack_Hitlag(state);

                //6.命中的音效
                AudioController.Instance.Play(CombatConfig.Instance.Config().hit_enemy_audio, hitInfo.point);

            }
        }
    }

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

复制代码
var fsm = hitInfo.transform.GetComponent<FSM>();
  1. hitInfo.transform

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

    • hitInfoRaycastHit 结构体,包含碰撞信息

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

  2. GetComponent<FSM>()

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

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


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

cs 复制代码
if (hit_target.Contains(fsm.instance_id) == false)
//防止同一攻击动作多次命中同一目标(如武器挥动过程中多次检测到同一个敌人)


//该变量在FSM类中
instance_id = _gameObject.GetInstanceID();
  1. GetInstanceID()

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

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


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

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

    • hitInfo.point 是射线检测得到的精确碰撞位置(世界坐标系)
  • 将特效的正面(Z轴正方向)设置为碰撞表面的法线方向

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

BOX检测

复制代码
    RaycastHit[] raycastHits = null;
    public bool BoxCast(Transform begin, HitConfig config, PlayerState state)
    {
        if (raycastHits == null)
        {
            raycastHits = new RaycastHit[30];//新建一个容量30的射线检测存储数组
        }
        //使用 Physics.BoxCastNonAlloc 在 begin 位置生成一个盒状检测区域
        //返回int类型即命中物体数量
        var count = Physics.BoxCastNonAlloc(begin.position + begin.transform.TransformDirection(config.box_center), config.box_size,
             begin.forward, raycastHits, begin.rotation, config.length, player.GetEnemyLayerMask(),
             QueryTriggerInteraction.Collide);

        if (count > 0)
        {
            int _crn_id = state.id;//记录当前状态id
            for (int i = 0; i < count; i++)
            {
                var hitInfo = raycastHits[i];遍历取出每个记录过的碰撞信息目标
                if (hitInfo.transform.CompareTag(GameDefine.WeaponTag))// 判断碰撞物体标签
                {
                    OnBlock(hitInfo);//若是标签为武器则格挡
                }
                else
                {
                    OnHit(begin.position, config, state, hitInfo);//否则就调用攻击状态
                }
                if (_crn_id != player.currentState.id)// 状态检查:若玩家状态变化
                                                                    (如死亡),终止处理
                {
                    break;
                }
            }
            return true;// 有碰撞返回true
        }
        return false;// 无碰撞返回false
    }
  1. RaycastHit:Unity 的结构体(struct),表示一次射线检测命中的结果,包含:

    • collider:命中的碰撞体

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

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

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

    • transform:命中对象的Transform组件


2、Physics.BoxCastNonAlloc

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

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

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

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

在该代码中的对应关系:

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

Physics.BoxCastNonAlloc的返回值

  • 类型:int

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

受击格挡OnBlock方法

复制代码
    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.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.更新下血条 

        }
    }

顿帧服务逻辑类:HitlagService

cs 复制代码
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

cs 复制代码
 public override void OnUpdate(float normalizedTime, PlayerState state)
    {
        base.OnUpdate(normalizedTime, state);
        //如果当前顿帧配置表不为空且数量不为0
        if (state.stateEntity.hitlagConfig != null && state.stateEntity.hitlagConfig.Count > 0)
        {
        //遍历所有顿帧配置表
            for (int i = 0; i < 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);
                }
            }
        }
    }
cs 复制代码
GameEvent.DOHitlag?.Invoke(x.frame, x.lerp);

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

  1. 触发受击停顿

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

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

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

  2. 全局事件调度

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

语法结构:

  1. 空条件运算符 ?.
cs 复制代码
csharp

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

    cs 复制代码
    csharp
    
    if (GameEvent.DOHitlag != null) {
        GameEvent.DOHitlag.Invoke(...);
    }
  1. 事件委托 Invoke()
cs 复制代码
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. 条件检查

    复制代码
    csharp
    
    if (state.stateEntity.hitlagConfig != null && state.stateEntity.hitlagConfig.Count > 0)
    • 检查玩家状态(state)中是否存在有效的击中停顿配置(hitlagConfig列表非空)。
  2. 遍历配置列表

    复制代码
    csharp
    
    for (int i = 0; i < state.stateEntity.hitlagConfig.Count; i++)
    • 遍历所有预先配置的Hitlag触发条件。
  3. 触发条件判断

    复制代码
    csharp
    
    var 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效果

    复制代码
    csharp
    
    if (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) 

  {
        // 1. 检查对象池中是否存在指定路径(path)的可用音频对象
    if (pool.ContainsKey(path) && pool[path].Count > 0)
    {
    // 2. 从对象池取出并激活
    audio = pool[path].Pop();       // 从栈中取出对象(也是添加音频组件的空物体)
    audio.gameObject.SetActive(true); // 激活该空物体
    }
    else
    {
    // 3. 对象池无可用对象时创建新实例
    GameObject go = new GameObject("audio"); // 创建空游戏对象名字audio
    audio = go.AddComponent<AudioSource>();  // 添加音频组件的空物体赋值给audio
    }
        //后续传入值控制该添加音频组件的物体相关属性
        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、检验对象池------有则取出激活,无则新建添加入池

复制代码
   // 2. 从对象池取出并激活
    audio = pool[path].Pop();       // 从栈中取出对象(也是添加音频组件的空物体)
    audio.gameObject.SetActive(true); // 激活该空物体
    }
    else
    {
    // 3. 对象池无可用对象时创建新实例
    GameObject go = new GameObject("audio"); // 创建空游戏对象名字audio
    audio = go.AddComponent<AudioSource>();  // 添加音频组件的空物体赋值给audio
    }
  • 优先复用:通过路径查找对象池中闲置的 AudioSource

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

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

需要注意的是audio是添加AudioSource组件的空物体,clip、loop、volume这些属性都是 `AudioSource` 组件的属性,游戏物体可以直接调用组件方法来操作该组件(这点别忘了)


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. 确保对象池存在

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

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

  2. 停止音频播放

    复制代码
    csharp
    
    audioSource.Stop();  // 立即停止音频播放
  3. 禁用游戏对象

    复制代码
    csharp
    
    audioSource.gameObject.SetActive(false);
    • 将关联的 GameObject 设为非激活状态

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

    • 准备对象复用

  4. 回收资源到对象池

    复制代码
    csharp
    
    pool[path].Push(audioSource);
    • 将 AudioSource 压入对应路径的栈中

    • 标记为可复用状态

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

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

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

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

对象池创建时机如下:

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

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

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

CombatConfig类 和GlobalComabat

复制代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class CombatConfig
{
    static CombatConfig instance = new CombatConfig();
    public static CombatConfig Instance => instance;

    GlobalCombatConfig config;

    public void Init()
    {
        config = ResourcesManager.Instance.Load<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()初始化(通常在游戏启动时)

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

复制代码
[CreateAssetMenu(menuName = "配置/创建战斗全局配置")]
public class GlobalCombatConfig : ScriptableObject
{

    [Header("格挡时的顿帧配置")]
    public HitlagConfig block_hitlag;

    /*[Header("格挡时的镜头径向模糊效果")]
    public RadialBlurConfig block_radialBlur;*/

    [Header("格挡成功的特效")]
    public string block_effect;

    [Header("格挡时的音效")]
    public string block_audio;

    [Header("攻击到敌人的音效")]
    public string hit_enemy_audio;


}

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

ResourcesManager类

复制代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ResourcesManager
{
    static ResourcesManager instance = new ResourcesManager();
    public static ResourcesManager Instance => instance;

    public T Load<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)

复制代码
csharp

static 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.销毁对象

复制代码
csharp

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

4. 对象池系统 (重点)

(1) 对象池容器

复制代码
csharp

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

(2) 获取击中效果

复制代码
csharp

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;
    }
}
  • 逻辑

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

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

(3) 回收击中效果

复制代码
csharp

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

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

A1:

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

复制代码
csharp

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

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

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

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

复制代码
csharp

public 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路径初始化生成特效物体赋值给obj
            Object.DontDestroyOnLoad(obj);//切场景不进行销毁
            return obj;//把obj的值返回
        }
    }

伤害计算辅助:AttHelper类

复制代码
using System.Collections;
using System.Collections.Generic;
using Game.Config;
using UnityEngine;

public class AttHelper
{
    static AttHelper instance = new AttHelper();
    public static AttHelper Instance => instance;//创建实例避免外部修改

    /// <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(未暴击)。

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

OnHit方法(被攻击时)

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

        }
    }

OnDeath方法(死亡判定)

复制代码
   public void OnDeath(int fd)
    {
        //也是根据当前状态的EXcel表看是否写入死亡相关动作接口
        if (currentState.excel_config.on_death != null)
        {

            if (fd == 0)//前方
            {
                ToNext(currentState.excel_config.on_death[0]);
            }
            else if (fd == 1)//后方
            {
                ToNext(currentState.excel_config.on_death[1]);
            }
            characterController.enabled = false;
            //击杀boss 特殊逻辑处理

            //主角死亡  特殊的逻辑处理(游戏循环)
        }
    }

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

复制代码
    internal void UpdateHP_OnHit(int damage)
    {
        this.att_crn.hp -= damage;//对伤害的累积赋值给当前FSM持有者的hp
        if (this.att_crn.hp < 0)如果当前目标生命值小于0
        {
            this.att_crn.hp = 0;
        }

        if (AI)//如果勾选了FSM中的选项,说明当前FSM持有者是敌人
        {
            //更新敌人血条 
            if (unitEntity.type == 3)
            {
                //更新Boss的血条

            }
            else if (unitEntity.type == 1 || unitEntity.type == 2)
            {
                //更新小兵的血条
            }
        }
        else//反之则为敌人
        {
            //更新主角的血条
        }
    }

BeBlock方法(格挡)

复制代码
public void BeBlock(FSM player)
    {

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

        }
    }

OnBlockSucces方法(当格挡成功时)

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

位置计算辅助:TransHelper类

复制代码
using System.Collections.Generic;
using UnityEngine;

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

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

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

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

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

示例1:目标在物体前方

假设:

复制代码
物体位置:(0, 0, 0)

物体朝向:正Z轴 (0, 0, 1)

目标位置:(0, 0, 5) // 正前方

计算:

复制代码
csharp

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

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


示例2:目标在物体后方

假设:

复制代码
物体位置:(0, 0, 0)

物体朝向:正Z轴 (0, 0, 1)

目标位置:(0, 0, -3) // 正后方

计算:

复制代码
csharp

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

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


示例3:目标在物体侧方

假设:

复制代码
物体位置:(0, 0, 0)

物体朝向:正Z轴 (0, 0, 1)

目标位置:(4, 0, 0) // 正右侧

计算:

复制代码
csharp

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

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

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

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

相关推荐
小徐不徐说1 小时前
每日一算:华为-批萨分配问题
数据结构·c++·算法·leetcode·华为·动态规划·后端开发
菜鸟555553 小时前
图论:最小生成树
算法·图论
2401_872945094 小时前
【补题】Codeforces Round 735 (Div. 2) C. Mikasa
算法
叫我:松哥4 小时前
基于网络爬虫的在线医疗咨询数据爬取与医疗服务分析系统,技术采用django+朴素贝叶斯算法+boostrap+echart可视化
人工智能·爬虫·python·算法·django·数据可视化·朴素贝叶斯
Star在努力4 小时前
14-C语言:第14天笔记
c语言·笔记·算法
赴3356 小时前
Numpy 库 矩阵数学运算,点积,文件读取和保存等
人工智能·算法·numpy·random·dot
自由随风飘6 小时前
机器学习-SVM支持向量机
算法·机器学习·支持向量机
★YUI★7 小时前
学习游戏制作记录(克隆技能)7.25
学习·游戏·unity·c#
屁股割了还要学7 小时前
【C语言进阶】柔性数组
c语言·开发语言·数据结构·c++·学习·算法·柔性数组
草莓熊Lotso7 小时前
【LeetCode刷题指南】--有效的括号
c语言·数据结构·其他·算法·leetcode·刷题