ARPG开发流程第一章——方法合集
配置表格
1、给Excel插件脚本配置:(都放置在Editor文件夹中)
Excel2CS.cs
:这是你之前提到的用于将Excel数据转换为C#脚本的脚本文件。
ExcelTools.cs
:这是另一个工具脚本,可能包含了一些辅助方法或菜单项,用于在Unity编辑器中操作Excel数据。
ExcelDataReader.DataSet
:这是一个与ExcelDataReader
相关的数据集文件,可能用于存储和管理从Excel文件中读取的数据。
ExcelDataReader
:这是一个DLL文件或脚本文件,提供了读取Excel文件的核心功能。有关该程序文件的下载:下载及使用方法2、 将配置的表格导入成脚本:
Tools脚本编辑器与Excel2CS脚本之间的功能联动
ExcelTools 编辑器脚本的功能
ExcelTools
是一个编辑器脚本,主要功能是为用户提供更友好的操作界面和流程管理,以便在 Unity 编辑器中方便地启动和管理 Excel 数据的转换过程。它的功能包括:
提供菜单项:
在 Unity 编辑器的菜单栏中添加菜单项(如
Tools -> Excel工具 -> 生成游戏配置脚本
),方便用户触发转换操作。这些菜单项封装了对
Excel2CS
脚本的调用逻辑,使用户无需直接操作脚本代码即可进行转换。流程控制和状态检查:
在执行转换操作前,进行一系列的状态检查,比如检查 Unity 是否处于运行状态、是否有编译正在进行等。
如果检查不通过,则提示用户相应的错误信息,避免在不合适的时机执行转换操作可能导致的问题。
路径配置和初始化:
提供对 Excel 文件输入路径、C# 脚本输出路径和 JSON 文件输出路径的配置。
通过
Init()
方法初始化这些路径,确保Excel2CS
脚本能够正确找到输入文件和输出位置。外部进程管理:
杀死可能占用 Excel 文件的外部进程(如 WPS 和 Excel),以防止文件被占用导致转换失败。
这一步骤对于确保转换过程顺利进行非常重要,因为如果文件被其他程序占用,可能会导致读取或写入失败。
编译和刷新操作:
在转换完成后,请求 Unity 编译新的脚本,并在编译完成后刷新资产数据库,使新的配置类能够立即生效。
这有助于用户快速查看转换结果并继续后续的开发工作。
Excel2CS 脚本的功能
Excel2CS
是核心的转换逻辑实现脚本,主要功能是处理 Excel 文件的数据转换工作。具体包括:
Excel 文件读取:
使用合适的库(如
ExcelDataReader
)读取 Excel 文件的内容。将表格中的数据加载到内存中,以便进行后续的处理。
数据解析和转换:
解析读取到的 Excel 数据,将其转换为适合游戏开发的结构化数据。
这通常包括将每一行数据映射为一个对象或数据结构,定义字段类型等。
生成 C# 配置类:
根据转换后的数据生成对应的 C# 类文件。
这些类文件定义了游戏中的配置数据结构,方便在游戏代码中引用和使用这些数据。
生成 JSON 文件(如果需要):
除了生成 C# 类文件,还可以将数据导出为 JSON 格式,用于其他需要的地方。
JSON 文件可以方便地进行数据交换和配置管理。
错误处理和日志记录:
在转换过程中处理可能出现的错误,并记录日志以便排查问题。
为用户提供了一定的调试信息,帮助他们了解转换过程中的问题所在。
两者的协同工作关系
触发和流程管理:
用户通过
ExcelTools
编辑器脚本提供的菜单项触发转换操作。
ExcelTools
负责检查环境状态并准备好转换所需的路径和配置,然后调用Excel2CS
脚本的核心逻辑。核心转换逻辑执行:
Excel2CS
脚本接收到ExcelTools
传递的参数(如路径配置等),开始执行 Excel 文件的读取、解析和转换工作。它生成所需的 C# 配置类和 JSON 文件,并将它们输出到指定的位置。
后续处理:
转换完成后,
ExcelTools
编辑器脚本继续执行后续操作,如请求 Unity 编译新生成的脚本,并刷新资产数据库。这使得转换后的文件能够立即在 Unity 项目中生效,用户可以继续开发工作
Excel2CS脚本作用(Tools文件夹中与Excel表并排)
曾出现了路径中未能找到Excel表格的报错:原因是因为TOOLs文件夹没能放在Unity practice文件夹中。(需要避免混淆的是这里主要找到Excel表的位置,实际发挥作用的是Editor中的脚本)
如果出现了Tool没有Excel工具的情况,重新导入Editor文件即可
正确放置位置:
截取部分Excel2CS脚本static string path = AppDomain.CurrentDomain.BaseDirectory + "/../../../../../Tools/Excel/"; static string writePath = ... // 输出CS文件的路径 static string jsonPath = ... // 输出JSON文件的路径static void Start(){//https://github.com/ExcelDataReader/ExcelDataReader#important-note-on-net-coreSystem.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance);List<string> fileLst = new List<string>();GetAllFiles(path,ref fileLst);//获取要转化的配置表string[] files = fileLst.ToArray(); //Directory.GetFiles(path);for (int i = 0; i < files.Length; i++){Console.WriteLine(files[i]);if (!files[i].Contains("~$") && files[i].EndsWith(".xlsx")&&files[i].Contains("_"))//xlsx{excelList.Add(files[i]);//Console.WriteLine(files[i]);}}
路径构建逻辑:
AppDomain.CurrentDomain.BaseDirectory
:获取程序执行的基目录(通常是bin/Debug
或bin/Release
)通过
/../../../../../
向上跳转5级目录(假设项目结构为:项目根/Tools/Excel/
)最终指向:
[项目根目录]/Tools/Excel/
潜在问题:依赖固定目录层级结构,项目结构调整会导致路径失效
System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance);
解决编码问题:
必需调用:使ExcelDataReader支持旧版Excel编码(如GB2312)
仅需执行一次(静态方法中注册全局有效)
GetAllFiles(path, ref fileLst);
递归获取文件(假设自定义方法):
实现深度遍历所有子目录
等效于:
Directory.GetFiles(path, "*", SearchOption.AllDirectories)
csharpif (!files[i].Contains("~$") && files[i].EndsWith(".xlsx") &&files[i].Contains("_"))
筛选条件:
排除Office临时文件(
~$
开头的隐藏文件)仅处理
.xlsx
格式文件名必须包含下划线
_
(自定义规则)
FSM运行逻辑
同为PlayerState类型变量;stateData成员与currentPlayerstate成员作用完全不同。
![]()
初始化阶段(Awake)
using System;
using System.Collections;
using System.Collections.Generic;
using Game.Config;
using UnityEngine;public class FSM : MonoBehaviour
{public int id;private PlayerState currentPlayerstate;Dictionary<int, PlayerState> stateData = new Dictionary<int, PlayerState>();//存储各个角色状态信息的目录public UnitEntity unitEntity;//单位基础表(在Game.Config命名空间中)[HideInInspector]public Transform _transform;[HideInInspector]public GameObject _gameObject;public Animator _animator;public CharacterController characterController;private void Awake(){_transform = this.transform;_gameObject = this.gameObject;_animator =_transform.GetChild(0).GetComponent<Animator>();characterController =GetComponent<CharacterController>();unitEntity = UnitData.Get(id);// 通过ID加载角色配置数据,这里在Inspctor中已经填写1001ServiceInit(); // 创建核心服务系统StateInit(); // 加载所有状态配置ToNext(1001); // 进入ID为1001的初始状态
(这里需要注意的是currentPlayerState已在ToNext(1001);赋值了)}
这里id已经赋值为1001
unitEntity = UnitData.Get(id);
public static UnitEntity Get(int id) {// 1. 检查缓存字典是否已初始化且包含目标IDif (entityDic != null && entityDic.TryGetValue(id, out var entity)){// 2. 如果找到则直接返回缓存对象return entity;}// 3. 找不到则返回nullreturn null; }
entityDic != null
: 检查字典是否已初始化
&&
: 逻辑与运算符(两个条件都必须满足)
entityDic.TryGetValue(id, out var entity)
: 字典的安全查找方法
TryGetValue()
: Dictionary类的方法,尝试获取指定键的值
id
: 要查找的键
out var entity
: 输出参数,如果找到则赋值给entity变量(请查看UnitData的拆解)
- 使用
out var entity
可以同时获取值;- 不用
out
的话,即使存在 key,你也不知道对应的值是什么。unitEntity获取的
entity如下:
UnitData 类
using System.Collections.Generic;
using UnityEngine;namespace Game.Config
{public class UnitData{static UnitData(){entityDic = new Dictionary<int, UnitEntity>(4);UnitEntity e0 = new UnitEntity(1001,@"玄影剑姬",0,0,1,10011,10012,10013,10014,10015,10016,10017,10018,80,60,30,50,30);entityDic.Add(e0.id, e0);UnitEntity e1 = new UnitEntity(1002,@"红焰邪姬",1,1,2,20011,20012,20013,20014,20015,20016,20017,20018,80,60,30,50,20);entityDic.Add(e1.id, e1);UnitEntity e2 = new UnitEntity(1003,@"独目锤影",3,1,2,30011,30012,30013,30014,30015,30016,30017,30018,80,60,30,50,20);entityDic.Add(e2.id, e2);UnitEntity e3 = new UnitEntity(1004,@"小兵C",3,1,2,20011,20012,20013,20014,20015,20016,20017,20018,80,60,30,50,20);entityDic.Add(e3.id, e3);}public static Dictionary<int, UnitEntity> all {get {return entityDic;}}static Dictionary<int, UnitEntity> entityDic;public static UnitEntity Get(int id){if (entityDic!=null&&entityDic.TryGetValue(id,out var entity)){return entity;}return null;}}public class UnitEntity{//TemplateMemberpublic int id;//单位IDpublic string info;//说明public int type;//类型public int camp;//阵营public int att_id;//属性表IDpublic int ntk1;//技能ID_普攻1public int ntk2;//技能ID_普攻2public int ntk3;//技能ID_普攻3public int ntk4;//技能ID_普攻4public int skill1;//技能ID_技能1public int skill2;//技能ID_技能2public int skill3;//技能ID_技能3public int skill4;//技能ID_技能4public int block_probability;//格挡概率public int dodge_probability;//躲闪概率public int atk_probability;//对拼概率public int active_attack_probability;//主动发起攻击概率public int pacing_probability;//踱步概率public UnitEntity(int id,string info,int type,int camp,int att_id,int ntk1,int ntk2,int ntk3,int ntk4,int skill1,int skill2,int skill3,int skill4,int block_probability,int dodge_probability,int atk_probability,int active_attack_probability,int pacing_probability){this.id = id;this.info = info;this.type = type;this.camp = camp;this.att_id = att_id;this.ntk1 = ntk1;this.ntk2 = ntk2;this.ntk3 = ntk3;this.ntk4 = ntk4;this.skill1 = skill1;this.skill2 = skill2;this.skill3 = skill3;this.skill4 = skill4;this.block_probability = block_probability;this.dodge_probability = dodge_probability;this.atk_probability = atk_probability;this.active_attack_probability = active_attack_probability;this.pacing_probability = pacing_probability;}}
}
using System.Collections.Generic; using UnityEngine;namespace Game.Config {public class UnitData{static UnitData(){entityDic = new Dictionary<int, UnitEntity>(4);UnitEntity e0 = new UnitEntity(1001,@"玄影剑姬",0,0,1,10011,10012,10013,10014,10015,10016,10017,10018,80,60,30,50,30);entityDic.Add(e0.id, e0);UnitEntity e1 = new UnitEntity(1002,@"红焰邪姬",1,1,2,20011,20012,20013,20014,20015,20016,20017,20018,80,60,30,50,20); ………………………………//还有两个类似格式的UnitEntity e3-e4}
public class UnitEntity{//TemplateMemberpublic int id;//单位IDpublic string info;//说明public int type;//类型public int camp;//阵营public int att_id;//属性表IDpublic int ntk1;//技能ID_普攻1……public int pacing_probability;//踱步概率public UnitEntity(int id,string info,int type,int camp,……,int pacing_probability){this.id = id;this.info = info;this.type = type;this.camp = camp;this.att_id = att_id;this.ntk1 = ntk1;this.ntk2 = ntk2;……this.pacing_probability = pacing_probability;}
UnitEntity在此属于构造方法(Constructor)
作用
用于在创建类的实例(对象)时初始化对象的属性。当调用new UnitEntity(...)
时,此方法会被执行。特点
方法名与类名相同(此处为
UnitEntity
)无返回值类型(连
void
都没有)通常用
public
修饰(表示可公开访问)e0的内容如下:
ServiceInit方法(服务初始化)
public void ServiceInit(){animationService=AddService<AnimationService>();physicsService=AddService<PhysicsService>();service_count = fSMService.Count;}
1、通过 AddService<T>()将实例化后的服务体,返回com的值就是AddService<AnimationService>();并将其赋值到animationService。
2、记录当前 FSM 中注册的服务数量,便于后续统一调用这些服务的生命周期方法:
就是把当前已注册的服务数量保存下来,供后续使用,比如:
- 在
ServiceOnBegin()
、ServiceOnUpdate()
等方法中循环调用每个服务的生命周期方法;- 控制服务更新顺序或进行性能统计;
- fSMService.Count会在每次调用
AddService<T>()
方法时增加。
AddService方法(添加服务层)
public T AddService<T>() where T : FSMServiceBase, new()
{T com = new T();fSMService.Add(com);com.Init(this);//传入当前的 FSM 实例作为参数return com;
}
T
是一个泛型参数,表示你要添加的服务类型。Unity游戏开发——对于泛型的理解 - 知乎https://zhuanlan.zhihu.com/p/73374032
where T : FSMServiceBase
:表示T
必须继承自FSMServiceBase
类(即它是某种状态机服务)。new()
:表示T
必须有一个无参构造函数创建了一个
T
类型的新实例。例如,如果调用的是AddService<AnimationService>()
,这里就会创建一个AnimationService
实例。
fSMService.Add(com);
把新创建的服务对象加入到
fSMService
容器中(它是List<FSMServiceBase>
或类似的集合,而此时因为animationService=AddService<AnimationService>
所以fSMService.Add(com)
的com
既是<AnimationService>
类型,也是FSMServiceBase
类型。
com.Init(this); return com;
- 调用服务对象的
Init
方法,并将当前对象作为参数传入,用于初始化服务,返回刚刚创建并初始化好的服务对象。
Q1:
com.Init(this);//传入当前的 FSM 实例作为参数
这段代码作用怎么理解?
A1:
public class AnimationService : FSMServiceBase {public float normalizedTime;//当前动作播放进度public string now_play_id;public override void Init(FSM fsm){base.Init(fsm);}
调用
com.Init(this)
,将当前的FSM
实例作为参数传递给AnimationService
的Init
方法,这意味着:
- 每个服务都可以持有对主控类(这里是
FSM
)的引用;- 这样它们就可以访问管理器中的公共资源、状态、方法等
Q2:FSM和AnimationService具体关系说明
A2:1. 从属关系:AnimationService 是 FSM 的子服务
FSM
是核心控制器;AnimationService
是其中一个功能模块;FSM
通过AddService<AnimationService>()
来创建它,并保存引用供后续使用。animationService = AddService<AnimationService>();
2. 协作关系:共同完成状态切换与动画播放
当前状态改变时:
FSM.ToNext(id)
切换状态;FSM.ServiceOnBegin()
触发所有服务的OnBegin()
;AnimationService.OnBegin()
调用Play(state)
开始播放动画;动画播放结束后:
AnimationService.OnUpdate()
监测到动画播放完毕;- 调用
player.AnimationOnPlayEnd()
;FSM.AnimationOnPlayEnd()
处理状态切换逻辑(如循环或跳转)
StateInit方法(状态初始化)
public void StateInit(){anmConfig = Resources.Load<StateScriptableObject>($"StateConfig/{id}");Dictionary<int, StateEntity> state_config = new Dictionary<int, StateEntity>();foreach (var item in anmConfig.states){state_config[item.id] = item;}var clips = _animator.runtimeAnimatorController.animationClips;Dictionary<string, float> clipLength = new Dictionary<string, float>();foreach (var clip in clips){clipLength[clip.name] = clip.length;}foreach (var item in PlayerStateData.all){ PlayerState P = new PlayerState();P.id = item.Key;P.excel_config = item.Value;P.stateEntity = state_config[P.id];if (clipLength.TryGetValue(item.Value.anm_name, out var length_clip)){P.clipLength = length_clip;}stateData[item.Key] = P;}//事件技能赋值 stateData[1005].skill = SkillData.Get(unitEntity.ntk1);stateData[1006].skill = SkillData.Get(unitEntity.ntk2);stateData[1007].skill = SkillData.Get(unitEntity.ntk3);stateData[1008].skill = SkillData.Get(unitEntity.ntk4);stateData[1009].skill = SkillData.Get(unitEntity.skill1);stateData[1010].skill = SkillData.Get(unitEntity.skill2);stateData[1011].skill = SkillData.Get(unitEntity.skill3);stateData[1012].skill = SkillData.Get(unitEntity.skill4);//添加事件监听器foreach (var item in stateData ){if(item.Value.excel_config.on_move != null){AddListener(item.Key, StateEventType.update, OnMove);}if (item.Value.excel_config.do_move == 1){AddListener(item.Key, StateEventType.update, PlayerMove);}if (item.Value.excel_config.on_stop != 0){AddListener(item.Key, StateEventType.update, OnStop);}if (item.Value.excel_config.on_jump != null){for (int i = 0; i < item.Value.excel_config.on_jump.Length; i++){Debug.Log($"item.Value.excel_config.on_jump[{i}]: {item.Value.excel_config.on_jump[i]}");}AddListener(item.Key,StateEventType.update, OnJump);}if (item.Value.excel_config.on_jump_end != 0){Debug.Log("item.Value.excel_config.on_jump_end" + item.Value.excel_config.on_jump_end); AddListener(item.Key, StateEventType.update, OnJumpUpdate);}if (item.Value.excel_config.add_f_move > 0){AddListener(item.Key, StateEventType.update, AddForwardMove);}if (item.Value.excel_config.on_atk != null){AddListener(item.Key, StateEventType.update, OnAtk);}if (item.Value.excel_config.on_skill1 != null){AddListener(item.Key, StateEventType.update, OnSkill1);}if (item.Value.excel_config.on_skill2 != null){AddListener(item.Key, StateEventType.update, OnSkill2);}if (item.Value.excel_config.on_skill3 != null){AddListener(item.Key, StateEventType.update, OnSkill3);}if (item.Value.excel_config.on_skill4 != null){AddListener(item.Key, StateEventType.update, OnSkill4);}if (item.Value.excel_config.on_defense != null){AddListener(item.Key, StateEventType.update, OnDefense);}if (item.Value.excel_config.on_defense_quit != 0){AddListener(item.Key, StateEventType.update, OnDefenseQuit);}if (item.Value.excel_config.on_sprint != null){AddListener(item.Key, StateEventType.update, OnSprint);}if (item.Value.excel_config.on_pow_atk != null){AddListener(item.Key, StateEventType.update, OnPowAtk);}if (item.Value.excel_config.do_rotate != 0){AddListener(item.Key, StateEventType.update, DORotate);}if (item.Value.stateEntity.ignor_collision == true){AddListener(item.Key, StateEventType.begin, DisableCollider);AddListener(item.Key, StateEventType.end, EnableCollider);}}}
加载状态配置资源
StateScriptableObject anmConfig; anmConfig = Resources.Load<StateScriptableObject>($"StateConfig/{id}"); //这里的id是初始值1001,在可视化面板编辑的,也是路径stateConfig的子物体名称 Dictionary<int, StateEntity> state_config = new Dictionary<int, StateEntity>(); foreach (var item in anmConfig.states) {state_config[item.id] = item; }
作用:从Unity资源系统加载ScriptableObject格式的状态配置
细节:
Resources.Load
:从"Resources/StateConfig"目录加载指定ID的配置资源
创建
state_config
字典:将配置中的状态数据按ID索引存储配置内容:包含状态ID、动画名称、过渡条件等状态机参数
获取动画剪辑长度
var clips = _animator.runtimeAnimatorController.animationClips; Dictionary<string, float> clipLength = new Dictionary<string, float>(); foreach (var clip in clips) {clipLength[clip.name] = clip.length; }
细节:
_animator.runtimeAnimatorController.animationClips
:获取角色Animator上的所有动画片段创建
clipLength
字典:以动画名称为键,存储对应动画的长度(秒)目的:为后续状态配置提供精确的动画时长数据
_animator.runtimeAnimatorController.animationClips
部分 类型 说明 _animator
对象引用 类字段/属性(通常是 Unity 的 Animator 组件实例) .runtimeAnimatorController
属性访问 Animator 组件的属性,使用此表示可在运行时期间更改 Animator Controller。 .animationClips
属性访问 RuntimeAnimatorController 的属性,返回包含的动画剪辑数组
foreach (var item in PlayerStateData.all) {PlayerState P = new PlayerState();P.id = item.Key;P.excel_config = item.Value;stateData[item.Key] = P; // 存储到字典 }
将PlayerStateData中记录的Excel配置表(Excel表格已经转存为Unity数据了) ,通过遍历将相应数据ID和数据信息存储至新的字典stateData中。
题外话:
当外部访问
All
属性时,直接返回entityDic
字典本身(而非副本)第一个参数
1001
就是这个对象的id,即e0.id;这个id作为PlayerStateEntity类型
e0数据信息的一部分。static PlayerStateData(){entityDic = new Dictionary<int, PlayerStateEntity>(44);PlayerStateEntity e0 = new PlayerStateEntity(1001,@"待机",@"idle",0,new float[]{1f,1f,1002f},0,new float[]{1f,1f,1019f},new float[]{1f,1f,1005f},new float[]{1f,1f,1014f},new float[]{1f,1f,1013f},0,new float[]{1f,1f,1020f},0,new float[]{1f,1f,1009f},new float[]{1f,1f,1010f},new float[]{1f,1f,1011f},new float[]{1f,1f,1012f},new int[]{1015,1016},0,new int[]{1017,1018},new int[]{1028,1029},0,null,0f,1,1,5f,1,0,0f,0f);entityDic.Add(e0.id, e0);……//还有40组这类结构的寄存}public static Dictionary<int, PlayerStateEntity> all{get {return entityDic;}}
返回的实体为玩家状态实体:
PlayerStateEntity e0 = new PlayerStateEntity(id: 1001,info: @"待机", // 状态说明:待机anm_name: @"idle", // 动画名称:idleon_anm_end: 0, // 动画结束时无操作on_move: new float[]{1f,1f,1002f},// 移动时切换到ID 1002(跑)on_stop: 0, // 停止移动时无操作on_pow_atk: new float[]{1f,1f,1019f}, // 蓄力攻击时切换到ID 1019on_atk: new float[]{1f,1f,1005f}, // 普攻时切换到ID 1005(普攻1)on_sprint: new float[]{1f,1f,1014f}, // 冲刺时切换到ID 1014(突进)on_defense: new float[]{1f,1f,1013f}, // 格挡时切换到ID 1013(格挡起手)on_defense_quit: 0, // 取消格挡时无操作on_jump: new float[]{1f,1f,1020f}, // 跳跃时切换到ID 1020(跳跃)on_jump_end: 0, // 跳跃结束时无操作on_skill1: new float[]{1f,1f,1009f}, // 技能1时切换到ID 1009on_skill2: new float[]{1f,1f,1010f}, // 技能2时切换到ID 1010on_skill3: new float[]{1f,1f,1011f}, // 技能3时切换到ID 1011on_skill4: new float[]{1f,1f,1012f}, // 技能4时切换到ID 1012on_hit: new int[]{1015,1016}, // 受击时切换到ID 1015(前受击)或1016(后受击)tag: 0, // 标签:0(无特殊标签)on_bash: new int[]{1017,1018}, // 重击时切换到ID 1017(前击飞)或1018(后击飞)on_death: new int[]{1028,1029}, // 死亡时切换到ID 1028(前死亡)或1029(后死亡)on_block_succes: 0, // 成功格挡时无操作be_block: null, // 被格挡时无操作trigger_atk: 0f, // 攻击决策调度概率:0(不调度)trigger_dodge: 1, // 触发躲闪:1(允许)first_strike: 1, // 触发抢攻:1(允许)active_attack: 5f, // 随机发起攻击概率:5%trigger_pacing: 1, // 进入踱步状态:1(允许)do_move: 0, // 执行移动:0(不移动)do_rotate: 0f, // 朝向控制:0(不旋转)add_f_move: 0f // 叠加正向位移:0(无位移) );
StateData[]数组即是存储不同状态下的对应全部状态表。如以上就是id=1001的待机状态表与待机状态下全部的状态表
//PlayerState的类
public class PlayerState
{public int id;//配置表public PlayerStateEntity excel_config;internal float clipLength;public SkillEntity skill;//动画通知事件public float begin_time;}//Dictionary<int, PlayerState> stateData = new Dictionary<int, PlayerState>();stateData[1005].skill = SkillData.Get(unitEntity.ntk1);
... 类似处理 1006-1008 (ntk2-ntk4) 和 1009-1012 (skill1-skill4)
UnitEntity中的存储数据如下:
SkillData取出储存到UnitEntity字典中的ntk1的int,赋值到stateData的skill板块中,让技能状态效果表连接到stateData的skill去:
foreach (var item in stateData ){if(item.Value.excel_config.on_move != null){Addlinstenr(item.Key, StateEventType.update, OnMove);}if (item.Value.excel_config.do_move == 1){Addlinstenr(item.Key, StateEventType.update, PlayerMove);}if (item.Value.excel_config.on_stop != 0){Addlinstenr(item.Key, StateEventType.update, OnStop);}}}
1. 遍历
stateData
foreach (var item in stateData)
stateData
是一个Dictionary<int, PlayerState>
类型的数据结构,其中:
- Key 是状态 ID(int)
- Value 是某种包含 Excel 配置的对象(比如
StateConfig
)
2. 检查
on_move
是否不为 null,若不为null则开始移动if(item.Value.excel_config.on_move != null) {Addlinstenr(item.Key, StateEventType.update, OnMove); }
- 如果当前状态的
excel_config.on_move
不为 null,则添加一个更新事件监听器OnMove
- 使用
Addlinstenr
方法将OnMove
注册到对应的状态 ID 和事件类型上
3. 检查
do_move == 1
if (item.Value.excel_config.do_move == 1) {Addlinstenr(item.Key, StateEventType.update, PlayerMove); }
- 如果
do_move
的值为 1,则添加另一个更新事件监听器PlayerMove
4. 检查
on_stop != 0
if (item.Value.excel_config.on_stop != 0) {Addlinstenr(item.Key, StateEventType.update, OnStop); }
- 如果
on_stop
不等于 0,则添加一个更新事件监听器OnStop
AddListener方法(监听状态)
public Dictionary<int, Dictionary<StateEventType, List<Action>>> actions = new Dictionary<int, Dictionary<StateEventType, List<Action>>>();public void Addlinstenr(int id, StateEventType t, Action action){// 1. 确保外层字典有id对应的条目if (!actions.TryGetValue(id, out var innerDict)){innerDict = new Dictionary<StateEventType, List<Action>>();actions[id] = innerDict;}// 2. 确保内层字典有t对应的列表if (!innerDict.TryGetValue(t, out var actionList)){actionList = new List<Action>();innerDict[t] = actionList;}// 3. 添加action到列表actionList.Add(action);}
public Dictionary<int, Dictionary<StateEventType, List<Action>>> actions = new Dictionary<int, Dictionary<StateEventType, List<Action>>>();
数据结构:
Dictionary<int, Dictionary<StateEventType, List<Action>>> actions
外层字典:键为
int
类型的id
,表示唯一标识(如对象ID)。内层字典:键为
StateEventType
(事件类型枚举),值为List<Action>
。
List<Action>
:存储多个无参数、无返回值的委托(方法),表示需要执行的操作。
Action
是 C# 中的一个预定义委托(delegate)类型,Action
是一个没有返回值(void
)、没有参数的委托类型。方法
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);
执行流程:
actions
中没有id = 100
:
TryGetValue
返回false
- 创建一个新的
innerDict
:new Dictionary<StateEventType, List<Action>>();
- 把
innerDict
添加到actions[100]
innerDict
中没有StateEventType.update
:
- 创建一个新的
actionList = new List<Action>()
- 把
actionList
添加到innerDict[StateEventType.update]
- 把
OnMove
加入这个列表此时:
actions[100][update] = [OnMove]
📌 第二次调用:
Addlinstenr(100, StateEventType.update, PlayerMove);
执行流程:
actions[100]
已存在:
innerDict
被取出innerDict[update]
存在:
actionList
被取出- 把
PlayerMove
加入这个列表actions[100][update] = [OnMove, PlayerMove]
📌 第三次调用:
Addlinstenr(100, StateEventType.stop, OnStop);
执行流程:
actions[100]
存在 → 取出innerDict
innerDict[stop]
不存在:
- 创建新的
List<Action>
- 存入
innerDict[stop]
- 把
OnStop
加入列表现在:
actions[100][update] = [OnMove, PlayerMove] actions[100][stop] = [OnStop]
📌 第四次调用:
Addlinstenr(200, StateEventType.update, OnMove);
执行流程:
actions[200]
不存在 → 创建新innerDict
innerDict[update]
不存在 → 创建新List<Action>
- 把
OnMove
加入列表actions[200][update] = [OnMove]
✅ 最终结构示意图
actions = {100: {update: [OnMove, PlayerMove],stop: [OnStop]},200: {update: [OnMove]} }
SkillData 类
using System.Collections.Generic;
using UnityEngine;namespace Game.Config
{public class SkillData{static SkillData(){entityDic = new Dictionary<int, SkillEntity>(25);SkillEntity e0 = new SkillEntity(10011,0,0f,5,20f,0f,0.3f,0,3f);……SkillEntity e24 = new SkillEntity(30019,0,7.8f,5,113f,0f,5f,0,3f);entityDic.Add(e24.id, e24);}public static Dictionary<int, SkillEntity> all {get {return entityDic;}}static Dictionary<int, SkillEntity> entityDic;public static SkillEntity Get(int id){if (entityDic!=null&&entityDic.TryGetValue(id,out var entity)){return entity;}return null;}}public class SkillEntity{//TemplateMemberpublic int id;//技能ID……public float atk_distance;//施法距离public SkillEntity(){}public SkillEntity(int id,int tag,float cd,int hit_max,float phy_damage,float magic_damage,float add_fly,int ignor_collision,float atk_distance){this.id = id;……this.atk_distance = atk_distance;}}
}
与UnitData同理:根据技能ID找寻到该技能的全部效果作为实例传递给entityDic后,等待被调用SkillData.Get方法后赋值:
得到的实例如下:
运动状态的控制和切换
具体详细流程请看
To Next方法
public bool ToNext(int Next){if (stateData.ContainsKey(Next))//如果导入进来的状态已经是当前状态{if (currentPlayerstate != null){Debug.Log($"{this.gameObject.name}:切换状态:{stateData[Next].Info()} 当前状态:{currentPlayerstate.Info()} ");}else{Debug.Log($"{this.gameObject.name}: 切换状态:{stateData[Next].Info()} ");}if (currentPlayerstate != null){DOStateEvent(currentPlayerstate.id, StateEventType.end);//状态绑定的退出事件ServicesOnEnd();}currentPlayerstate = stateData[Next];currentPlayerstate.SetBeginTime();//执行当前状态的开始(进入)事件DOStateEvent(currentPlayerstate.id, StateEventType.begin);ServiceOnBegin();return true;}return false;}
状态存在性检查:
csharpif (stateData.ContainsKey(Next)) // Next = 1001
检查字典中是否存在ID为1001的状态配置
日志输出:
csharpif (currentPlayerstate != null) {Debug.Log($"{this.gameObject.name}:切换状态:{stateData[Next].Info()}..."); } else {Debug.Log($"{this.gameObject.name}: 切换状态:{stateData[Next].Info()} "); }
由于是首次初始化,
currentPlayerstate
为null
,执行else分支输出类似:"PlayerObject: 切换状态:1001_待机状态"
结束旧状态处理:
csharpif (currentPlayerstate != null) {DOStateEvent(currentPlayerstate.id, StateEventType.end);ServicesOnEnd(); }
当前无旧状态,跳过此段代码
设置新状态:
csharpcurrentPlayerstate = stateData[Next]; // 获取ID=1001的状态对象 currentPlayerstate.SetBeginTime(); // 记录状态开始时间
触发新状态开始事件:
csharpDOStateEvent(currentPlayerstate.id, StateEventType.begin);
执行所有注册到状态1001的
begin
类型事件通过
Addlinstenr
添加的事件处理函数会被触发服务系统初始化:
csharpServiceOnBegin();
调用所有服务的
OnBegin
方法(如AnimationService)服务系统开始为当前状态工作
返回结果:
csharpreturn true;
表示状态切换成功
SetBeginTime方法
public void SetBeginTime(){begin_time = Time.time;// 记录开始时刻}
DOStateEvent方法(执行事件的方法)
这里需要知道的是:通过状态初始化,action记录着已注册的行为状态表
public void DOStateEvent(int id, StateEventType t){if (actions.TryGetValue(id, out var v)){if (v.TryGetValue(t, out var lst)){for (int i = 0; i < lst.Count; i++){lst[i].Invoke();}}}}
检查状态是否存在:(action数据请查看AddListener方法)
csharpif (actions.TryGetValue(id, out var v)) //查找当前状态1001的所有事件类型如Begin/Update
在全局事件字典
actions
中查找指定状态ID如果存在,将事件字典赋值给变量
v
检查事件类型是否存在:
csharpif (v.TryGetValue(t, out var lst)) //查找当前状态1001当前事件类型(Begin)的对应方法(待机回血/播放待机语音)
在状态的事件字典中查找特定事件类型(begin/update等)
如果存在,将事件列表赋值给
lst
执行所有注册的方法:
csharpfor (int i = 0; i < lst.Count; i++) {lst[i].Invoke(); }
遍历该事件类型下的所有注册方法
逐个调用(Invoke)这些方法
ServiceOnBegin方法
通过ServiceInit方法设置初始的服务状态,并且将初始服务状态都存储到数组Fsmservice中:
public void ServiceOnBegin(){for (int i = 0; i < service_count; i++){fSMService[i].OnBegin(currentPlayerstate);}}
1、当id=1001时,先执行Awake方法中的ServiceInit方法,添加初始状态的服务体,
2、AddService<AnimationService>()
创建动画服务对象(也可以添加其他服务对象)3、再执行Awake方法中的ToNext(1001)方法,执行ServiceOnBegin,对当前已有服务对象进行激活。
Update方法+服务体更新ServiceOnUpdate
void Update(){if (currentPlayerstate != null){ if (ServiceOnUpdate() == true){DOStateEvent(currentPlayerstate.id, StateEventType.update);//状态每帧执行的事件}}}
Update()
方法的作用
状态更新协调:
在
Update()
方法中每帧都会调用ServiceOnUpdate()
方法,查看是否需要切换。事件触发条件:仅当
ServiceOnUpdate()
返回true
(即所有服务更新完成且状态未改变)时,触发DOStateEvent
事件,执行与当前状态相关的每帧逻辑(如动画播放、物理效果更新)。
若返回
true
(状态未变,即id=1001未发生改变),执行当前状态的每帧逻辑(DOStateEvent
)。若返回
false
(状态已变,即id=1001改变成id=1002),跳过当前帧的状态更新(避免旧状态逻辑干扰新状态)。
public bool ServiceOnUpdate(){int crn_state_id = currentPlayerstate.id; // 保存当前玩家状态的 IDfor (int i = 0; i < service_count; i++){fSMService[i].OnUpdate(animationService.normalizedTime, currentPlayerstate); // 调用每个服务的 OnUpdate 方法if (currentPlayerstate.id != crn_state_id) // 检查玩家状态是否改变{return false; // 如果状态改变,返回 false}}return true; // 如果状态没有改变,返回 true}
ServiceOnUpdate()
方法的作用
记录当前状态ID:保存进入时的状态ID(
crn_state_id
)。遍历所有服务:调用每个服务的
OnUpdate()
方法(可能包含条件检测,如“血量低于30%触发受伤状态”)实时检测状态变化:
若某个服务触发了状态切换(
currentPlayerstate.id
改变),立即中断循环并返回false
。若所有服务执行后状态未变,返回
true
重载virtue方法
public bool ServiceOnUpdate(){int crn_state_id = currentState.id; // 保存当前玩家状态的 IDfor (int i = 0; i < service_count; i++){fSMService[i].OnUpdate(animationService.normalizedTime, currentState); // 调用每个服务的 OnUpdate 方法if (currentState.id != crn_state_id) // 检查玩家状态是否改变{return false; // 如果状态改变,返回 false}}return true; // 如果状态没有改变,返回 true}
public class FSMServiceBase
{public FSM player;//每一帧更新public virtual void OnUpdate(float normaizedTime,PlayerState state) { }
}//AnimationService是FSMServiceBase的基类
public class AnimationService : FSMServiceBase
{public float normalizedTime;//当前动作播放进度public string now_play_id;public override void OnUpdate(float normaizedTime, PlayerState state){base.OnUpdate(normaizedTime, state);if (!string.IsNullOrEmpty(now_play_id)){………………}//判定播放动作是否与配置一致?}}
在代码中,
fSMService[i].OnUpdate(animationService.normalizedTime, currentState)
调用的具体实现取决于fSMService[i]
的实际类型:
多态机制:
由于FSMServiceBase
中的OnUpdate
是virtual
方法,且AnimationService
通过override
重写了该方法,实际调用的是对象的运行时类型(实际类型)的OnUpdate
方法。具体调用逻辑:
如果
fSMService[i]
是AnimationService
实例 → 调用AnimationService.OnUpdate()
(如代码中通过now_play_id
检查动画状态并更新normalizedTime
)如果
fSMService[i]
是FSMServiceBase
其他子类的实例 (如—_ObjSerivce)→ 调用子类重写的OnUpdate()
如果未重写(如直接使用
FSMServiceBase
)→ 调用基类默认的空方法(base.OnUpdate
)
OnMove方法
public void OnMove(){//如果垂直或者水平方向输入不为0,说明发生了移动if (UInput.GetAxis_Horizontal() != 0 || UInput.GetAxis_Vertical() != 0){if (CheckConfig(currentPlayerstate.excel_config.on_move)){ToNext((int)currentPlayerstate.excel_config.on_move[2]);}}}
-
excel_config.on_move
:假设配置数组为[02,08,1005](@ref)
:
config[0](@ref)= 0.2
:动画前20%时间可触发。config[1](@ref)= 0.8
:动画后20%时间可触发。config[2](@ref)= 1005
:目标状态ID(移动状态)。- 触发条件:
- 当动画播放到 0-20% 或 80-100% 时,检测到移动输入则切换到状态
1005
CheckConfig
public bool CheckConfig(float[] config){if (config == null){return false;}else{if ((animationService.normalizedTime >= 0 && animationService.normalizedTime <= config[0]) ||(animationService.normalizedTime >= config[1] && animationService.normalizedTime <= 1)){return true;}return false;}}
动画播放进度 → CheckConfig() 检查 ├─ 时间在 [0, config[0]] → 返回 true ├─ 时间在 [config[1], 1] → 返回 true └─ 其他情况 → 返回 false
- 该段代码的作用是根据动画的播放进度(
normalizedTime
)和传入的配置参数(config
数组)判断当前是否处于有效的操作时间窗口。
第一个时间窗口:动画开始阶段
[0, config[0]]
第二个时间窗口:动画结束阶段
[config[1], 1]
如果当前动画进度落在任一窗口内,返回
true
;否则返回false
具体含义:
当动画进度在
0% → config[0]%
范围内时,系统认为处于"动画开始阶段"例如:
config[0] = 0.2
表示动画前 20% 的时间段设计目的:
允许在动画刚开始播放时触发特定操作
常见用例:
攻击动画:前10%时间允许取消技能
跳跃动画:前5%时间允许中断起跳
受击动画:前15%时间播放受击特效
PlayOnMove 方法
private void PlayerMove(){var x = UInput.GetAxis_Horizontal();var z = UInput.GetAxis_Vertical();if (x != 0 || z != 0){Vector3 inputDirection = new Vector3(x, 0f, z).normalized;//Mathf.Atan2 正切函数 求弧度 * Mathf.Rad2Deg(弧度转度数) >> 度数//第一:先求出输入的角度//第二:加上当前相机的Y轴旋转的量//第三:得到目标朝向的角度_targetRotation = Mathf.Atan2(inputDirection.x, inputDirection.z) * Mathf.Rad2Deg +GameDefine._Camera.transform.eulerAngles.y;//做一个插值运动float rotation = Mathf.SmoothDampAngle(_transform.eulerAngles.y, _targetRotation, ref _rotationVelocity,RotationSmoothTime);//角色先旋转到目标角度去// rotate to face input direction relative to camera position_transform.rotation = Quaternion.Euler(0.0f, rotation, 0.0f);//计算目标方向 通过这个角度Vector3 targetDirection = Quaternion.Euler(0.0f, _targetRotation, 0.0f) * Vector3.forward;Move(targetDirection.normalized * (_speed * GameTime.deltaTime), false, false, false, true);}}
1. 输入方向归一化
csharpVector3 inputDirection = new Vector3(x, 0f, z).normalized;
数学逻辑:将原始输入值
(x, z)
转换为单位向量(长度为1)。目的:消除不同输入强度(如轻推摇杆 vs 全推摇杆)对移动速度的影响,确保移动方向准确。
2. 计算目标旋转角度
csharp_targetRotation = Mathf.Atan2(inputDirection.x, inputDirection.z) * Mathf.Rad2Deg + GameDefine._Camera.transform.eulerAngles.y;
数学逻辑:
Mathf.Atan2(x, z)
:计算输入方向相对于 Z轴正方向(世界前方) 的弧度角。
例如:输入
(0,1)
→ 角度0°
(正前),输入(1,0)
→ 角度90°
(正右)。
* Mathf.Rad2Deg
:将弧度转换为角度(0~360°
)。
+ Camera.eulerAngles.y
:叠加相机的Y轴旋转角度。目的:将输入方向从局部坐标系(相对于相机)转换为世界坐标系(相对于地图)。
示例:相机旋转
90°
时,玩家按“前”键 → 输入方向(0,1)
→ 实际世界方向(1,0)
3. 平滑旋转插值
float rotation = Mathf.SmoothDampAngle(_transform.eulerAngles.y, // 当前角度_targetRotation, // 目标角度ref _rotationVelocity, // 当前角速度(引用传递)RotationSmoothTime // 平滑时间 ); _transform.rotation = Quaternion.Euler(0.0f, rotation, 0.0f);
4. 计算世界空间移动方向
Vector3 targetDirection = Quaternion.Euler(0.0f, _targetRotation, 0.0f) * Vector3.forward;
四元数构造:
Quaternion.Euler(0, _targetRotation, 0)
创建一个绕Y轴旋转_targetRotation
度的四元数。例如:
- 若
_targetRotation = 90°
,则生成绕Y轴顺时针旋转90度的四元数。向量旋转:
* Vector3.forward
表示将默认的世界坐标系前方向量(即(0,0,1)
)应用该旋转。
- 例如:当Y轴旋转90度时,
Vector3.forward
会被旋转到世界坐标系的X轴正方向((1,0,0)
)。坐标系转换:
该运算等效于将角色当前的本地前方向量(即角色面朝方向)转换为世界坐标系下的目标方向。(这里涉及到四元数与向量的乘法计算,这里就抽象记忆只要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)
d:基础移动向量
transformDirection:是否将向量从局部空间转换到世界空间
frame:是否考虑时间增量(默认true)
_Add_Gravity:是否添加重力(默认true)
_do_ground_check:是否执行地面检测(默认true)
1. 坐标系转换(可选)
csharpif (transformDirection) {d = this._transform.TransformDirection(d); }
若
transformDirection=true
,将输入方向d
从世界空间转换到角色局部空间(例如:按"W"键时角色向前移动,而非固定世界坐标的Z轴)。
作用:当
transformDirection=true
时,将输入向量d
被解释为相对于当前游戏对象(this._transform
)的局部坐标系,这个方法的使用就将角色局部空间转换为世界空间示例:如果角色面朝右(X+),输入(0,0,1)会转换为(1,0,0)
2. 重力处理
csharpVector3 d2; if (_Add_Gravity) {d2 = (d + GameDefine._Gravity) * (frame ? GameTime.deltaTime : 1); } else {d2 = d * (frame ? GameTime.deltaTime : 1); }
重力添加:
如果设置重力_Add_Gravity=true
:将重力向量加到移动向量上(通常为负Y方向)
物理意义:
这是将当前方向向量d
与重力向量GameDefine._Gravity
进行叠加,得到总的影响向量。
比如向前跳跃时,既有水平向前的速度,也有垂直下落的重力速度
GameDefine._Gravity
为new Vector3(0, -9.8f, 0)
时间处理:
(frame ? GameTime.deltaTime : 1)
是一个三元运算符,根据frame
的值选择时间缩放因子:
frame=true
:乘以GameTime.deltaTime
使移动与帧率无关
-
frame
为假:直接使用1
(无时间缩放)。-
frame
为真:使用GameTime.deltaTime
(上一帧到当前帧的时间间隔)。
frame=false
:直接使用原始向量(可能用于特殊动画或瞬移)
-
GameTime.deltaTime
:表示上一帧到当前帧的实际时间间隔(单位:秒),例如:
- 30 FPS 时,
deltaTime ≈ 0.0333 秒
(每帧约 33.3 毫秒)- 60 FPS 时,
deltaTime ≈ 0.0167 秒
(即每帧约 16.7 毫秒)。- 帧率无关化的理解:
假设物体速度为 d,则:确保每秒移动距离为 d * 速度系数,与帧率无关。例如:假设 d = (5, 0, 0)(向右移动),_Add_Gravity = false,则 // 高帧率(60 FPS) d2 = (5, 0, 0) * 0.0167 ≈ (0.0835, 0, 0) 每帧 每秒总位移 = 0.0835 * 60 ≈ 5 单位// 低帧率(30 FPS) d2 = (5, 0, 0) * 0.0333 ≈ (0.1665, 0, 0) 每帧 每秒总位移 = 0.1665 * 30 ≈ 5 单位
3. 执行移动
csharpcharacterController.Move(d2);
调用Unity的CharacterController组件执行实际移动
自动处理碰撞检测和物理响应
4. 地面检测标记
csharpif (_do_ground_check) {ground_check = true; }
设置标记通知系统需要更新地面状态
实际检测可能在FixedUpdate或其他位置执行
运动状态的结束判定
动画结束回调方法AnimationOnPlayEnd
这段代码是一个动画播放结束时的回调方法,主要处理动画结束后的状态逻辑。
public void AnimationOnPlayEnd(){var _id = currentState.id;DOStateEvent(currentState.id, StateEventType.onAnmEnd);ServicesOnAnimationEnd();if (currentState.id != _id){return;}switch (currentState.excel_config.on_anm_end){case 1:break;case 0:ServicesOnReStart();return;default:ToNext(currentState.excel_config.on_anm_end);break;}}
1. 保存当前状态ID
csharpvar _id = currentState.id;
记录当前状态的唯一标识
id
,用于后续检查状态是否被外部修改。
2. 触发状态事件
csharpDOStateEvent(currentState.id, StateEventType.onAnmEnd);
发送动画结束事件 (
StateEventType.onAnmEnd
),其他模块可能监听此事件并修改状态(如强制中断、跳转等)。
3. 执行服务层逻辑
csharpServicesOnAnimationEnd();
调用与动画结束相关的服务方法(如资源清理、数据上报等)。
4. 关键状态校验
csharpif (currentState.id != _id) {return; // 状态已变更,直接退出 }
防干扰设计:检查当前状态ID是否与最初保存的
_id
一致。若不一致,说明在
DOStateEvent
或ServicesOnAnimationEnd
中触发了状态切换(如跳转到新状态),此时直接退出,避免执行无效操作。
5. 根据配置执行动画结束策略
csharpswitch (currentState.excel_config.on_anm_end) {case -1: // 保持当前状态(无操作)break;case 0: // 重启当前状态ServicesOnReStart(); // 执行重启逻辑return; // 直接退出(不再执行后续代码)default: // 跳转到指定状态ToNext(currentState.excel_config.on_anm_end); // 跳转到配置ID对应的状态break; }
配置策略说明:
on_anm_end = -1
:动画结束后停留在当前状态(break
后方法自然结束)。
on_anm_end = 0
:重启当前状态(调用ServicesOnReStart()
后退出)。其他值(如2/3/100):将配置值作为目标状态ID,调用
ToNext
跳转。
碰撞启用EnableCollider与禁用DisableCollide
private void EnableCollider(){characterController.excludeLayers = 0;// 设置为0表示不排除任何层}
作用:启用与所有层的碰撞检测。
行为:将
excludeLayers
设为0
(二进制全0),表示角色控制器不再忽略任何碰撞层,可以与场景中所有物体发生碰撞。使用场景:通常用于需要恢复完整碰撞时(如角色结束无敌状态、恢复正常交互时)。
private void DisableCollider(){characterController.excludeLayers = GameDefine.Enemy_LayerMask;// 设置为敌人层的掩码}
作用:禁用与指定层的碰撞检测(此处针对敌人层)。
行为:
GameDefine.Enemy_LayerMask
是一个预定义的层掩码(如1 << 8
),代表"敌人"所在的层级。设置后,角色控制器会忽略与敌人层物体的碰撞(角色可穿过敌人)。
使用场景:通常用于技能无敌状态、过场动画等需要临时避免与敌人碰撞的情况
特效物体控制 GetHangPoint
核心作用:通过字典缓存机制,快速获取场景中指定名称的
GameObject
,避免重复调用Unity的Find
方法(该方法性能较低)。适用于需要频繁访问特定游戏对象的场景(如UI管理、动态加载对象等)。这里有两处引用,在HitService中抓取起点位置空物体,在ObjService中抓取特效物体
//将物体特效做成字典存储起来
Dictionary<string, GameObject> hangPoint = new Dictionary<string, GameObject>();internal GameObject GetHangPoint(string o_id)
{if (hangPoint.TryGetValue(o_id, out var x)) // 尝试从字典获取{return x; // 缓存命中,直接返回}var go = _transform.Find(o_id); // 未命中,调用Unity的Find方法if (go != null){hangPoint[o_id] = go.gameObject; // 缓存找到的对象return go.gameObject;}else{hangPoint[o_id] = null; // 缓存未找到的结果return null;}
}
Dictionary<string, GameObject> hangPoint = new Dictionary<string, GameObject>();
这里注意类型定义:键为
string
(对象名称),值为GameObject
(游戏对象)
攻击和受击相关接口
Attack_Hitlag方法
internal void Attack_Hitlag(PlayerState state){hitlagService.DOHitlag_OnAttack(animationService.normalizedTime, state);}
用来作为事件触发
单例类Main
1. 全局初始化的中心节点
作用:作为游戏启动时的核心初始化入口(在
Awake
中调用SystemInit()
)。必要性:
Unity需要场景中的激活GameObject挂载脚本才能执行Awake
/Start
。空物体作为轻量级载体,确保初始化代码在场景加载时自动运行。优势:
避免将初始化逻辑分散到多个物体上,集中管理游戏启动流程(如配置加载、事件绑定)。2. 关键系统依赖的宿主
时间缩放控制(Hitlag):
DOHitlag
方法通过协程修改Time.timeScale
,需挂载在激活物体上(协程依赖MonoBehaviour
)。
协程的载体要求:
Unity 的协程系统 (IEnumerator
+yield
) 必须通过MonoBehaviour.StartCoroutine()
启动。而MonoBehaviour
只能存在于挂载在场景 GameObject 上的脚本中。帧等待的引擎依赖:
yield return new WaitForEndOfFrame()
需要 Unity 的帧循环系统驱动,只有场景中激活的 GameObject 上的脚本才能接入此循环。时间缩放的作用域:
Time.timeScale
是全局状态,修改它会影响整个游戏。需要一个持久存在且权威的控制器,避免多物体竞争修改导致状态混乱。事件系统桥梁:
GameEvent.DOHitlag = DOHitlag
将事件绑定到实际方法,需物体持续存在以确保事件触发有效。
委托绑定的生命周期问题:
当GameEvent.DOHitlag
委托被赋值指向Main.DOHitlag
方法时,它实际绑定的是当前Main
实例。如果该实例被销毁(如场景切换),委托将指向无效内存,触发NullReferenceException
。事件触发可靠性:
游戏中的攻击判定可能在任何时间发生(如角色技能、子弹碰撞)。需要确保当事件触发时:
委托目标(
Main
实例)必须存在物体必须处于激活状态(否则协程不会执行)
3. 全局单例的稳定访问
示例:
CombatConfig.Instance.Init()
空物体保证初始化代码在场景中最早执行,避免其他脚本访问未初始化的单例。4. 相机等关键引用托管
代码:
GameDefine._Camera = GameObject.Find("Camera").transform
通过空物体集中获取并存储场景中的关键对象(如主摄像机),供全局访问。5. 时间管理的统一入口
代码:
GameTime.Update()
在Update
中调用
空物体作为持久存在的"时间管理器",确保每帧更新游戏时间逻辑。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;public class Main : MonoBehaviour
{public void Awake(){SystemInit();}private void SystemInit(){CombatConfig.Instance.Init();GameDefine._Camera = GameObject.Find("Camera").transform;//当 GameEvent.DOHitlag 委托被赋值指向 Main.DOHitlag 方法时,它实际绑定的是当前 Main 实例。GameEvent.DOHitlag = DOHitlag;GameDefine.Init();}void Start(){}// Update is called once per framevoid Update(){GameTime.Update();}Coroutine coroutine_hitlag;public void DOHitlag(int frame, bool lerp){if (frame > 0 && Time.timeScale == 1){if (coroutine_hitlag != null){StopCoroutine(coroutine_hitlag);}coroutine_hitlag = StartCoroutine(Hitlag(frame, lerp));}}IEnumerator Hitlag(int frame, bool lerp){for (int i = 0; i < frame; i++){Time.timeScale = lerp ? Mathf.Lerp(1, 0, (float)i / frame) : 0;yield return new WaitForEndOfFrame();}Time.timeScale = 1;coroutine_hitlag = null;}
}
系统初始化 (
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));}
}
入口点:
提供外部调用接口,用于触发击中停滞效果
被绑定到全局事件
GameEvent.DOHitlag
(在SystemInit
中设置)智能管理:
确保同一时间只有一个停滞效果运行
新效果会中断旧效果(防止效果叠加)
条件过滤:
只在游戏正常运行时触发(
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.lockState
和Cursor.visible
是用于控制鼠标光标行为的核心属性:
Cursor.lockState
1. 语法结构
- 属性:
Cursor.lockState
- 类型:枚举(
CursorLockMode
)- 赋值:
CursorLockMode.Locked
2. 功能
- 锁定光标到屏幕中心:光标会被固定在游戏窗口中心,无法移动。
- 隐藏光标:无论
Cursor.visible
的值如何,光标在此模式下均不可见。- 输入响应:仍能通过鼠标输入(如移动视角),但光标位置不更新
Cursor.visible = false;
1. 语法结构
- 属性:
Cursor.visible
- 类型:布尔值(
true
/false
)2. 功能
- 控制光标可见性:
true
:显示光标(默认状态)。false
:隐藏光标。- 独立于锁定状态:即使光标被锁定(
Locked
模式),设置visible
为false
仍会进一步隐藏光标
LateUpdate方法
float distanceFromTarget;public float mouse_scrollwheel_scale = 10;//鼠标滚轮速度的调整(缩放)public float speed = 5;//跟随速度private void LateUpdate(){if (target != null){//鼠标滑动 输入的值xMouse += UInput.GetAxis_Mouse_X();yMouse -= UInput.GetAxis_Mouse_Y();yMouse = Mathf.Clamp(yMouse, -30f, 80f);//鼠标滚轮的输入 往前滑动正数 往后滑动输负数的//离角色越近(往前滑动) distanceFromTarget -= UInput.GetAxis_Mouse_ScrollWheell() * mouse_scrollwheel_scale; //拉近或者拉远 人物镜头distanceFromTarget = Mathf.Clamp(distanceFromTarget, 2, 15);Quaternion targetRotation = Quaternion.Euler(yMouse, xMouse, 0);Vector3 targetPosition = target.position + targetRotation * new Vector3(0, 0, -distanceFromTarget) + hight_offset;speed = controller.velocity.magnitude > 0.1f ? Mathf.Lerp(speed, 7.5f, 5f * GameTime.deltaTime): Mathf.Lerp(speed, 25f, 5f * GameTime.deltaTime);transform.position = Vector3.Lerp(transform.position, targetPosition, GameTime.deltaTime * speed);transform.rotation = Quaternion.Lerp(transform.rotation, targetRotation, GameTime.deltaTime * 25f);}}
}
1. 鼠标输入处理
csharpxMouse += UInput.GetAxis_Mouse_X(); // 累加水平鼠标移动量 yMouse -= UInput.GetAxis_Mouse_Y(); // 累加垂直鼠标移动量(反转Y轴) yMouse = Mathf.Clamp(yMouse, -30f, 80f); // 限制垂直旋转角度
水平旋转(绕Y轴)无角度限制,垂直旋转(绕X轴)限制在-30°到80°之间,防止摄像机翻转过度
public static float Clamp(float value, float min, float max);若 value < min,返回 min; 若 value > max,返回 max; 否则返回 value 本身
- 将
yMouse
的当前值限制在-30
到80
之间。
- 若
yMouse
小于-30
,则赋值为-30
;- 若
yMouse
大于80
,则赋值为80
;- 否则保持原值不变。
2.滚轮距离控制
csharpdistanceFromTarget -= UInput.GetAxis_Mouse_ScrollWheell() * mouse_scrollwheel_scale; distanceFromTarget = Mathf.Clamp(distanceFromTarget, 2, 15); // 限制摄像机距离
第一行:根据鼠标滚轮输入调整摄像机与目标的距离。
UInput.GetAxis_Mouse_ScrollWheell()
:获取鼠标滚轮的垂直滚动量(通常返回正值为向上滚动,负值为向下滚动)。mouse_scrollwheel_scale
:滚轮灵敏度系数,用于控制距离变化的幅度。distanceFromTarget -= ...
:滚轮向上滚动时,摄像机远离目标;向下滚动时,摄像机靠近目标。
- 滚轮向上时(
scrollInput > 0
):
distanceFromTarget
减少 → 摄像机与目标的距离缩短 → 摄像机靠近目标- 滚轮向下时(
scrollInput < 0
):
distanceFromTarget
增加 → 摄像机与目标的距离拉长 → 摄像机远离目标第二行:使用
Mathf.Clamp
限制distanceFromTarget
的范围。
- 最小值
2
:防止摄像机与目标碰撞或视角过近。- 最大值
15
:避免摄像机距离过远导致视野过小
3. 摄像机位置计算
csharpQuaternion targetRotation = Quaternion.Euler(yMouse, xMouse, 0); // 创建目标旋转 Vector3 targetPosition = target.position + targetRotation * new Vector3(0, 0, -distanceFromTarget) // 计算后方偏移+ hight_offset; // 添加高度偏移csharp
第一行作用将鼠标输入转换为四元数旋转
- 作用:将欧拉角(
yMouse
,xMouse
,0
)转换为四元数,表示摄像机的目标旋转方向。- 参数含义:
yMouse
:垂直旋转角度(通常控制摄像机的俯仰角,即上下倾斜)。xMouse
:水平旋转角度(通常控制摄像机的偏航角,即左右旋转)。0
:滚转角(通常设为0,避免摄像机侧翻)。计算摄像机位置:
- 基础位置:
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.dll
、Demos
、Readme
等)
2、将新建的配置文件放入StateConfig
文件夹通常意味着该文件用于管理与状态相关的动态参数或逻辑
StateScriptableObject
类
StateScriptableObject
类
继承
ScriptableObject
:创建可在Unity编辑器中保存的配置文件。实现
ISerializationCallbackReceiver
:在序列化/反序列化时执行自定义逻辑。
[CreateAssetMenu]
:在Unity的Asset创建菜单中添加选项,路径为配置/创建状态配置
。(即右键可以创建该项目)
核心功能
数据容器
通过StateScriptableObject
存储状态配置列表(List<StateEntity> states
),每个StateEntity
包含状态ID和描述信息。自动同步机制
实现ISerializationCallbackReceiver
接口,在反序列化时(如资源加载、编辑器刷新)自动同步配置数据:
从
PlayerStateData.all
(静态配置表)获取最新状态数据动态增删
states
列表以匹配配置表变化
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Sirenix.OdinInspector;
using Game.Config;[CreateAssetMenu(menuName = "配置/创建状态配置")]
public class StateScriptableObject : ScriptableObject,ISerializationCallbackReceiver
{[SerializeField][ListDrawerSettings(ShowIndexLabels = true, ShowPaging = false, ListElementLabelName = "info")]public List<StateEntity> states = new List<StateEntity>();public void OnAfterDeserialize(){
#if UNITY_EDITORif (states.Count == 0){Dictionary<int, PlayerStateEntity> dct = PlayerStateData.all;foreach (var item in dct){var info = item.Value;StateEntity entity = new StateEntity();entity.id = info.id;entity.info = info.id + "_" + info.info;states.Add(entity);}}else{Dictionary<int, PlayerStateEntity> dct = PlayerStateData.all;if (dct.Count != states.Count){//遍历表格所有状态foreach (var item in dct){var info = item.Value;bool add = true;for (int i = 0; i < states.Count; i++){if (states[i].id == info.id){add = false;continue;}}//如果是需要增加if (add == true){StateEntity stateEntity = new StateEntity();stateEntity.id = info.id;stateEntity.info = info.id + "_" + info.info;states.Add(stateEntity);}}List<StateEntity> remove = new List<StateEntity>();//删除掉多余的foreach (var item in states){if (dct.ContainsKey(item.id) == false){remove.Add(item);//UDebug.LogError(remove.Count);}}foreach (var item in remove){states.Remove(item);}}}
#endif}public void OnBeforeSerialize(){}// Start is called before the first frame update}[System.Serializable]
public class StateEntity
{public int id;public string info;[Header("是否忽略与单位的碰撞")]public bool ignor_collision;[Header("物理位移配置")]public List<PhysicsConfig> physicsConfig;
}[System.Serializable]
public class PhysicsConfig
{[Header("触发点")]public float trigger;[Header("结束点")]public float time;//结束点[Header("位移距离")]public Vector3 force;[Header("曲线配置")]public AnimationCurve cure = AnimationCurve.Constant(0, 1, 1);[Header("是否忽略重力")]public bool ignore_gravity;[Header("检测到单位后停下")]public float stop_dst;
}
状态列表
[SerializeField][ListDrawerSettings(ShowIndexLabels = true, ShowPaging = false, ListElementLabelName = "info")]public List<StateEntity> states = new List<StateEntity>();
[SerializeField]
作用:强制将
public List<StateEntity> states
字段序列化
[ListDrawerSettings(...)]
Odin Inspector插件提供的特性
作用:高级定制列表在Unity编辑器中的显示方式
参数详解:
ShowIndexLabels = true
在列表每个元素左侧显示索引标签
ShowPaging = false
禁用列表分页功能
如果列表很长(如100+元素),Odin默认会分页显示
设为
false
强制显示完整列表(适用于元素较少的情况)
ListElementLabelName = "info"
使用
StateEntity
类的info
属性作为列表项的主标签
当
states
列表为空时如果当前`states`列表为空(`states.Count == 0`)
从`PlayerStateData.all`(角色状态表)中获取所有状态——遍历该字典,为每个状态创建一个新的`StateEntity`对象,并填充其`id`和`info`字段
将这些新创建的对象添加到`states`列表中。
public List<StateEntity> states;if (states.Count == 0) {// 遍历所有配置数据foreach (var item in PlayerStateData.all) {StateEntity entity = new StateEntity();entity.id = item.Value.id; // 复制IDentity.info = $"{item.Value.id}_{item.Value.info}"; // 拼接描述信息states.Add(entity); // 添加到列表} }
- 场景:首次创建
StateScriptableObject
时自动填充数据- 实现逻辑:
- 遍历外部数据源
PlayerStateData.all
(假设为字典结构)- 将每个状态转换为
StateEntity
对象- 通过
id
和info
拼接生成唯一标识(如"1001_待机"
)- states.Add(entity);
- 通过
states.Add(entity)
将实体添加到states
列表中
当
states
列表非空时else//当列表为非空时if (dct.Count != states.Count) // 检查数量是否一致 {// --- 新增缺失项 ---foreach (var item in dct) {bool add = true;// 检查当前项是否已存在于 statesfor (int i = 0; i < states.Count; i++) {if (states[i].id == item.Value.id) {add = false; // 已存在则跳过break;}}// 不存在则新建并添加if (add) {StateEntity stateEntity = new StateEntity();stateEntity.id = item.Value.id;stateEntity.info = $"{item.Value.id}_{item.Value.info}";states.Add(stateEntity);}}// --- 删除多余项 ---List<StateEntity> remove = new List<StateEntity>();// 标记 states 中不存在于配置数据的项foreach (var item in states) {if (!dct.ContainsKey(item.id)) {remove.Add(item); // 加入待删除列表}}// 移除无效项foreach (var item in remove) {states.Remove(item);} }
仅当配置表条目数量变化时才执行同步,数量变化一定意味着内容变化(添加/删除)
新增缺失项(数据同步方向:dct → states
)foreach (var item in dct) {bool add = true;for (int i = 0; i < states.Count; i++) {if (states[i].id == item.Value.id) {add = false;break;}}if (add) {StateEntity stateEntity = new StateEntity();stateEntity.id = item.Value.id;stateEntity.info = $"{item.Value.id}_{item.Value.info}";states.Add(stateEntity);}}
- 工作逻辑:
遍历配置表(
dct
)所有条目检查每个ID是否已存在于资源列表(
states
)若不存在则创建新条目
实现功能:自动添加策划在配置表中新增的状态
删除多余项List<StateEntity> remove = new List<StateEntity>(); foreach (var item in states) {if (!dct.ContainsKey(item.id)) {remove.Add(item);} } foreach (var item in remove) {states.Remove(item); }
- 反向遍历本地数据:标记所有在
dct
中不存在的本地条目- 批量删除:通过中间列表
remove
避免遍历时修改集合异常
举例说明:
假设此时配置表与资源列表不一致的情况如下:// 假设初始配置包含2个状态 配置表dct PlayerStateData.all = new Dictionary<int, PlayerStateEntity>{{1001, new PlayerStateEntity{ id=1001, info="IDLE" }},{1002, new PlayerStateEntity{ id=1002, info="WALK" }} };// 初始同步后包含2个状态实体 states = new List<StateEntity>{new StateEntity{ id=1001, info="1001_IDLE" },new StateEntity{ id=1002, info="1002_WALK" } };// 开发者新增一个状态配置 PlayerStateData.all.Add(1003, new PlayerStateEntity{ id=1003, info="RUN" });
代码执行流程:
if (dct.Count != states.Count) // 3 != 2 → 进入同步流程
foreach (var item in dct) {bool add = true;// 检查每个配置项是否存在于内存列表for (int i = 0; i < states.Count; i++) {if (states[i].id == item.Value.id) {add = false; // 存在则跳过break;//跑出循环}}// 新增状态ID=1003if (add) {states.Add(new StateEntity{StateEntity stateEntity = new StateEntity();stateEntity.id = item.Value.id;stateEntity.info = $"{item.Value.id}_{item.Value.info}";//生成1003__跑步states.Add(stateEntity);});} }
得出结果如下:
states 现在包含3个元素: 1001_IDLE → 1002_WALK → 1003_RUN
假设开发者删除了配置表中的ID=1002状态:
states 现在包含3个元素: 1001_IDLE → 1002_WALK → 1003_RUN
触发同步:
if (dct.Count != states.Count) // 2 != 3 → 进入同步流程
List<StateEntity> remove = new List<StateEntity>(); foreach (var item in states) {if (!dct.ContainsKey(item.id)) {remove.Add(item);//将要删除的元素添加到} } foreach (var item in remove) {states.Remove(item); }
将要删除的多个元素存入remove表中,遍历该表进行删除。
Obj_State类 (可视化面板操控物体 )
public class Obj_State
{[Header("注释说明")]public string info;[Header("触发点")]public float trigger;[Header("需要操作的物体对象")]public string[] obj_id;[Header("打钩激活/反之则隐藏")]public bool act;[Header("状态提前结束,是否也强制执行该配置")]public bool force;[Header("循环执行(循环动作)")]public bool loop;
}
这是对物体某些属性进行可视化操作
StateEntity类
动态创建并初始化一个状态实体(
StateEntity
),主要用于将外部数据源(PlayerStateEntity
)中的状态信息转换为当前脚本可管理的配置实体(StateEntity
),并生成显示标识
[System.Serializable]
public class StateEntity
{public int id;public string info;[Header("是否忽略与单位的碰撞")]public bool ignor_collision;[Header("物理位移配置")]public List<PhysicsConfig> physicsConfig;
}
info.id
是该状态的唯一数字标识(例如1
、2
等),用于程序逻辑中唯一识别状态。info.info
是该状态的描述性文本(例如"跳跃中"
、"受伤"
等),用于人工阅读或编辑器显示。
PhysicsConfig类
public class PhysicsConfig
{[Header("触发点")]public float trigger;[Header("结束点")]public float time;//结束点[Header("位移距离")]public Vector3 force;[Header("曲线配置")]public AnimationCurve cure = AnimationCurve.Constant(0, 1, 1);[Header("是否忽略重力")]public bool ignore_gravity;[Header("检测到单位后停下")]public float stop_dst;
}
PhysicsConfig作为配置文件表的一个子属性组件:
观察动画设置触发点和位移
物理逻辑服务类:PhysicsService
using System.Collections;
using System.Collections.Generic;
using UnityEngine;public class PhysicsService : FSMServiceBase
{public override void Init(FSM fsm){base.Init(fsm);}public override void OnAnimationEnd(PlayerState state){base.OnAnimationEnd(state);ReSetAllExcuted();}public override void OnBegin(PlayerState state){base.OnBegin(state);ReSetAllExcuted();//把所有元素标记为未执行的状态}public override void OnDisable(PlayerState state){base.OnDisable(state);}public override void OnEnd(PlayerState state){base.OnEnd(state);ReSetAllExcuted();Stop();}public override void OnUpdate(float normalizedTime, PlayerState state){base.OnUpdate(normalizedTime, state);var e = state.stateEntity.physicsConfig;if (e != null && e.Count > 0){for (int i = 0; i < e.Count; i++){var entity = e[i];if (normalizedTime >= entity.trigger && GetExcuted(i) == false){Do(entity, state);SetExcuted(i);}}}if (begin){//动作进度 小于 配置的结束点if (normalizedTime <= currentEntity.time){if (currentEntity.time > 0){Debug.Log("6");//已经执行的时间 (当前进度-当前事件触发点)/ 需要执行的时间var f = (normalizedTime - currentEntity.trigger) / (currentEntity.time - currentEntity.trigger);//用于做插值运动float lerpTime = currentEntity.cure.Evaluate(f);var speed = Vector3.Lerp(Vector3.zero, force, lerpTime);player.AddForce(speed, currentEntity.ignore_gravity);if (currentEntity.stop_dst > 0){Debug.Log("7");var begin = player._transform.position + Vector3.up;var result = Physics.Linecast(begin, begin + player._transform.forward * currentEntity.stop_dst, player.GetEnemyLayerMask());if (result){Stop();}}}}else{Stop();}}}private void Stop()//强制停止当前位移逻辑{if (begin){begin = false;player.RemoveForce();}}bool begin = false;PhysicsConfig currentEntity;Vector3 force;public void Do(PhysicsConfig entity, PlayerState state){//执行这个配置所需要花费的时间float t = state.clipLength * ((entity.time - entity.trigger) / 1);if (t <= 0){begin = false;}else{currentEntity = entity;if (entity.time > 0){force = currentEntity.force / t;}else{force = currentEntity.force;}begin = true;}}public override void ReLoop(PlayerState state){base.ReLoop(state);}public override void ReStart(PlayerState state){base.ReStart(state);}
}
OnUpdate方法
public override void OnUpdate(float normalizedTime, PlayerState state){base.OnUpdate(normalizedTime, state);var e = state.stateEntity.physicsConfig;if (e != null && e.Count > 0){for (int i = 0; i < e.Count; i++){var entity = e[i];if (normalizedTime >= entity.trigger && GetExcuted(i) == false){Do(entity, state);SetExcuted(i);}}}if (begin){//动作进度 小于 配置的结束点if (normalizedTime <= currentEntity.time){if (currentEntity.time > 0){//已经执行的时间 (当前进度-当前事件触发点)/ 需要执行的时间var f = (normalizedTime - currentEntity.trigger) / (currentEntity.time - currentEntity.trigger);//用于做插值运动float lerpTime = currentEntity.cure.Evaluate(f);var speed = Vector3.Lerp(Vector3.zero, force, lerpTime);player.AddForce(speed, currentEntity.ignore_gravity);if (currentEntity.stop_dst > 0){var begin = player._transform.position + Vector3.up;var result = Physics.Linecast(begin, begin + player._transform.forward * currentEntity.stop_dst, player.GetEnemyLayerMask());if (result){Stop();}}}}else{Stop();}}}
1. 物理效果触发 (
OnUpdate
)public override void OnUpdate(float normalizedTime, PlayerState state) {// 遍历所有物理配置for (int i = 0; i < e.Count; i++) {// 当动画进度到达触发点,且未执行过时if (normalizedTime >= entity.trigger && GetExcuted(i) == false) {Do(entity, state); // 执行物理效果SetExcuted(i); // 标记为已执行(防止重复触发)}} }
normalizedTime >= entity.trigger
- 功能:判断动画播放进度是否达到预设的触发时间点
- 参数说明:
normalizedTime
:动画归一化时间(0~1),表示当前动画播放进度(如0.8表示播放到80%)entity.trigger
:物理效果的触发阈值(如0.5表示动画播放到一半时触发)- 触发场景:
- 角色跳跃动画到达最高点时触发落地特效
- 武器挥舞动画到攻击判定时段时生成伤害区域
!GetExcuted(i)
- 功能:检查该物理配置是否已被执行过
- 实现机制:
GetExcuted(i)
:返回第i
个物理配置的执行状态(true
=已执行,false
=未执行)
2.判断机制的处理
if (begin){//动作进度 小于 配置的结束点if (normalizedTime <= currentEntity.time){if (currentEntity.time > 0){
条件1:
begin
为真
只有当物理效果启动时才执行后续逻辑条件2:
normalizedTime <= currentEntity.time
判断是否在配置的时间窗口内(即当前播放进度时间不能超过总时间长度)条件3:
currentEntity.time > 0
区分瞬时效果(time=0
)和持续效果(time>0
)
3.插值力计算
var f = (normalizedTime - currentEntity.trigger) / (currentEntity.time - currentEntity.trigger); float lerpTime = currentEntity.cure.Evaluate(f); var speed = Vector3.Lerp(Vector3.zero, force, lerpTime);
动画插值计算f
目的:计算当前物理运动阶段的完成比例
参数:
normalizedTime
:动画当前进度(范围在0.0~1.0)
currentEntity.trigger
:物理运动开始点(如动画30%处)
currentEntity.time
:物理运动结束点(如动画80%处)计算示例:如果运动区间是0.3~0.8当动画进度到0.55时:f = (0.55 - 0.3) / (0.8 - 0.3) = 0.25 / 0.5 = 0.5 表示运动已完成50%
lerpTime的赋值
通过
currentEntity.cure
(可能为动画曲线或插值器)的Evaluate
方法,将归一化时间进度f
转换为实际的插值时间lerpTime
。在技能释放动画中,
f
与lerpTime
的计算关系由 动画曲线(如贝塞尔曲线)的非线性映射 决定。cure.Evaluate(f):若 cure 是动画曲线(如 Unity 的 AnimationCurve),则根据 f 的值在曲线上采样,返回对应的插值时间。通过曲线调整 lerpTime,可让速度变化速率随时间动态变化(如前半段加速平缓,后半段加速剧烈)。
var speed
的生成
在
Vector3.zero
(初始速度)和force
(目标速度)之间进行线性插值,生成当前速度speed
。
- 核心作用:根据
lerpTime
动态调整速度,实现平滑过渡Vector3.Lerp 的数学原理 speed.x = Vector3.zero.x + (force.x - Vector3.zero.x) * lerpTime; speed.y = Vector3.zero.y + (force.y - Vector3.zero.y) * lerpTime; speed.z = Vector3.zero.z + (force.z - Vector3.zero.z) * lerpTime;
4. 施加物理力和检测障碍后移除// 1. 施加物理力 player.AddForce(speed, currentEntity.ignore_gravity);// 2. 障碍物检测(仅当配置了有效检测距离时才执行障碍检测) if (currentEntity.stop_dst > 0) {// 3. 计算检测起点(角色位置上方1单位)var begin = player._transform.position + Vector3.up;// 4. 计算检测终点(角色前方指定距离)var end = begin + player._transform.forward * currentEntity.stop_dst;// 5. 执行线性检测(射线检测)var result = Physics.Linecast(begin, end, player.GetEnemyLayerMask());// 6. 检测到障碍物时中断运动if (result){Stop();} }
射线检测的函数:csharp var result = Physics.Linecast(begin, end, player.GetEnemyLayerMask());
方法:
Physics.Linecast
Unity的物理检测方法
检测两点之间的碰撞体
关键参数:
player.GetEnemyLayerMask()
:层级过滤
只检测特定层级(如"Wall"、"Obstacle")
忽略无关层级(如"Player"、"Trigger")
返回值:
true
:检测到障碍物
false
:无障碍物
Do方法
public void Do(PhysicsConfig entity, PlayerState state)
{// 计算物理效果持续时间(秒)float t = state.clipLength * ((entity.time - entity.trigger) / 1);if (t <= 0){begin = false; // 无效时间窗口,禁用效果}else{currentEntity = entity; // 绑定当前物理配置if (entity.time > 0){force = currentEntity.force / t;}else{force = currentEntity.force;}begin = true; // 标记物理效果启动}
}
1. 时间差计算
float t = state.clipLength * (entity.time - entity.trigger);
- 功能:计算物理效果的 实际作用时间窗口
- 公式推导:
entity.time - entity.trigger
:触发时间与生效时间的差值(归一化时间)- 乘以
clipLength
将归一化时间转换为实际秒数- 示例:
- 若动画总长2秒,触发时间设为0.3,生效时间设为0.1 →
t = 2*(0.3-0.1) = 0.4秒
2.力分配策略
if (t <= 0){begin = false; // 无效时间窗口,禁用效果}else{currentEntity = entity; // 绑定当前物理配置// 根据时间配置计算力值force = (entity.time > 0) ? currentEntity.force / t // 均分力到时间窗口: currentEntity.force; // 直接使用原始力值begin = true; // 标记物理效果启动} }
增加对
entity.time
的显式判断
- 当
entity.time <= 0(结束点比0小)
:立即施加原始力值(瞬时效果)- 当
entity.time > 0
(结束点大于0):将总力均匀分配到时间窗口(持续效果)
物体逻辑服务类 ObjService
using System.Collections;
using System.Collections.Generic;
using UnityEngine;public class ObjService : FSMServiceBase
{public override void OnAnimationEnd(PlayerState state){base.OnAnimationEnd(state);}public override void OnBegin(PlayerState state){base.OnBegin(state);ReSetAllExcuted();}public override void OnDisable(PlayerState state){base.OnDisable(state);}public override void OnEnd(PlayerState state){base.OnEnd(state);//var os = state.stateEntity.obj_States;if (os != null){for (int i = 0; i < os.Count; i++){var item = os[i];//强制执行该条配置 是否未执行过if (item.force && GetExcuted(i) == false){DO(item);}}}ReSetAllExcuted();}private void DO(Obj_State item){if (item.obj_id != null){foreach (var o_id in item.obj_id){var obj = player.GetHangPoint(o_id);if (obj != null){obj.SetActive(item.act);}}}}public override void OnUpdate(float normalizedTime, PlayerState state){base.OnUpdate(normalizedTime, state);var os = state.stateEntity.obj_States;if (os != null){for (int i = 0; i < os.Count; i++){var item = os[i];//强制执行该条配置 是否未执行过if (normalizedTime >= item.trigger && GetExcuted(i) == false){SetExcuted(i);DO(item);}}}}//技能点升级的时候 比如20-50动作需要重新循环播放五六次public override void ReLoop(PlayerState state){base.ReLoop(state);Item_ResetExcuted(state);}public override void ReStart(PlayerState state){base.ReStart(state);Item_ResetExcuted(state);}private void Item_ResetExcuted(PlayerState state){var os = state.stateEntity.obj_States;if (os != null){for (int i = 0; i < os.Count; i++){var item = os[i];//强制执行该条配置 是否未执行过if (item.loop){ReSetExcuted(i);}}}}}
OnUpdate方法()
public override void OnUpdate(float normalizedTime, PlayerState state){base.OnUpdate(normalizedTime, state);var os = state.stateEntity.obj_States;//物体特效配置表赋值(可以创建多个配置表进行控制特效的控制和生成)if (os != null){for (int i = 0; i < os.Count; i++){//对配置表进行遍历,如果达到触发点且未被标记过var item = os[i];if (normalizedTime >= item.trigger && GetExcuted(i) == false){SetExcuted(i);DO(item);}}}}
DO方法
private void DO(Obj_State item){if (item.obj_id != null){foreach (var o_id in item.obj_id){//找到当前特效——利用特效名字字符串作为特效的键var obj = player.GetHangPoint(o_id);if (obj != null){obj.SetActive(item.act);}}}}
对当前的特效物体配置表进行找到对应特效,并且激活,以下这段代码就是控制着打钩激活物体特效的功能
obj.SetActive(item.act);
OnEnd方法
public override void OnEnd(PlayerState state){base.OnEnd(state);//var os = state.stateEntity.obj_States;if (os != null){for (int i = 0; i < os.Count; i++){var item = os[i];//利用force判断是否需要强制执行该条配置 GetExcuted(i) == false是否未执行过if (item.force && GetExcuted(i) == false){DO(item);//也是在DO方法中对特效进行隐藏}}}ReSetAllExcuted();}
动画逻辑服务类 AnimationService
using System.Collections;
using System.Collections.Generic;
using UnityEngine;public class AnimationService : FSMServiceBase
{public float normalizedTime;//当前动作播放进度public string now_play_id;public override void Init(FSM fsm){base.Init(fsm);}public override void OnAnimationEnd(PlayerState state){base.OnAnimationEnd(state);}void Play(PlayerState state){normalizedTime = 0;this.now_play_id = state.excel_config.anm_name;player._animator.Play(state.excel_config.anm_name);//字段对应角色状态中表的字段名player._animator.Update(0);}public override void OnBegin(PlayerState state){base.OnBegin(state);Play(state);}public override void OnDisable(PlayerState state){base.OnDisable(state);}public override void OnEnd(PlayerState state){base.OnEnd(state);}public override void OnUpdate(float normaizedTime, PlayerState state){base.OnUpdate(normaizedTime, state);if (!string.IsNullOrEmpty(now_play_id)){var info = player._animator.GetCurrentAnimatorStateInfo(0);if (info.IsName(now_play_id)){//0—1 表示动作0%-100%的进度this.normalizedTime = info.normalizedTime;if (normalizedTime >= 1){//UDebug.LogError($"{transform.gameObject.name}:当前动画:{_anmID} 进度是:{normalizedTime} // {info.normalizedTime}");this.normalizedTime = 1;player.AnimationOnPlayEnd();//判定是结尾时,调用动画结束时的判断接口}}else{this.normalizedTime = 0;}//判定播放动作是否与配置一致?}}public override void ReLoop(PlayerState state){base.ReLoop(state);}public override void ReStart(PlayerState state){base.ReStart(state);}
}
Play方法
void Play(PlayerState state)
{normalizedTime = 0; // 重置动画标准化时间为起始点this.now_play_id = state.excel_config.anm_name; // 记录当前播放的动画IDplayer._animator.Play(state.excel_config.anm_name); // 播放指定动画player._animator.Update(0); // 强制立即更新动画状态
}
代码逐行解析:
normalizedTime = 0
将动画的标准化时间重置为0(动画起始位置)
normalizedTime
是动画进度值(0=开始,1=结束)
this.now_play_id = state.excel_config.anm_name
从配置数据中获取动画名称,并记录到当前播放ID
说明:
state.excel_config
是从Excel表读取的配置数据
player._animator.Play(...)
调用Unity的Animator组件播放指定动画
通过
state.excel_config.anm_name
动态获取动画名称(如"run","jump")
player._animator.Update(0)
关键操作:强制动画器立即更新(跳过本帧等待)
参数
0
表示不推进动画时间,但立即应用状态变化解决:避免动画播放延迟1帧的问题
OnUpdate方法
public override void OnUpdate(float normaizedTime, PlayerState state)
{base.OnUpdate(normaizedTime, state); // 调用基类更新逻辑if (!string.IsNullOrEmpty(now_play_id)) // 检查当前是否有有效动画ID{var info = player._animator.GetCurrentAnimatorStateInfo(0); // 获取动画器当前状态信息if (info.IsName(now_play_id)) // 检查当前播放的动画是否与记录一致{// 更新动画进度 (0-1表示0%-100%)this.normalizedTime = info.normalizedTime; if (normalizedTime >= 1) // 动画播放完成检测{this.normalizedTime = 1; // 确保进度不超过1player.AnimationOnPlayEnd(); // 触发动画结束回调}}else // 当前播放动画与预期不一致{this.normalizedTime = 0; // 重置进度}}
}
这里的now_play_id是当前的动画id名字如下:
var info = player._animator.GetCurrentAnimatorStateInfo(0);
该方法返回一个
AnimatorStateInfo
结构体,包含 当前动画层(Layer) 的状态数据:基础层(Base Layer):仅指索引为0的主层,包含角色的核心动画逻辑(如截图中的内容)。
当方法的参数或局部变量与类的成员变量同名时,
this
用于消除歧义,明确表示操作的是当前对象的成员变量。public override void OnUpdate(float normaizedTime, PlayerState state) {this.normalizedTime = info.normalizedTime; }
若省略
this
,左侧的normalizedTime
会被视为参数,导致左侧的实例变量未被正确赋值。
Override类的基类调用
base.OnUpdate(normaizedTime, state); // 调用基类更新逻辑
作用是执行基类原有逻辑
如果父类的OnUpdate
方法包含与动画状态、时间轴同步或其他基础功能相关的代码(例如:更新全局计时器、处理状态机基础逻辑、触发事件等),调用base.OnUpdate
可以确保这些逻辑在子类重写的方法中仍然生效
受击逻辑服务类:HitService
using System;
using System.Collections;
using System.Collections.Generic;
using System.Resources;
using UnityEngine;public class HitService : FSMServiceBase
{public override void OnAnimationEnd(PlayerState state){base.OnAnimationEnd(state);}public override void OnBegin(PlayerState state){base.OnBegin(state);ReSetAllExcuted();hit_target.Clear();last_end = Vector3.zero;}public override void OnEnd(PlayerState state){base.OnEnd(state);ReSetAllExcuted();}public override void OnUpdate(float normalizedTime, PlayerState state){base.OnUpdate(normalizedTime, state);var configs = state.stateEntity.hitConfigs;if (configs != null && configs.Count > 0){for (int i = 0; i < configs.Count; i++){var e = configs[i];if (normalizedTime >= e.trigger && normalizedTime <= e.end){DO(e, state);}}}}Vector3 last_end;private void DO(HitConfig config, PlayerState state){var obj = player.GetHangPoint(config.begin);Vector3 begin = obj.transform.position;if (config.type == 0){Vector3 end = begin + obj.transform.forward * config.length;if (last_end == Vector3.zero){Linecast(begin, end, config, state);}else{var _crn_id = player.currentState.id;for (int i = 0; i < 10; i++){Vector3 end2 = Vector3.Lerp(last_end, end, i / 10f);Linecast(begin, end2, config, state);if (_crn_id != player.currentState.id){return;}}}last_end = end;}else if (config.type == 1){BoxCast(obj.transform, config, state);}}List<int> hit_target = new List<int>();//记录哪些单位被击中过,避免多计算了伤害public bool Linecast(Vector3 begin, Vector3 end, HitConfig config, PlayerState state){Debug.DrawLine(begin, end, Color.red, 0.2f);//Physics.RaycastNonAllocvar result = Physics.Linecast(begin, end, out var hitInfo, player.GetEnemyLayerMask(), QueryTriggerInteraction.Collide);if (result){//处于格挡状态if (hitInfo.transform.CompareTag(GameDefine.WeaponTag)){OnBlock(hitInfo);}else{OnHit(begin, config, state, hitInfo);}return true;}return false;}private void OnBlock(RaycastHit hitInfo){//格挡方var fsm = hitInfo.transform.GetComponentInParent<FSM>();if (fsm != null && hit_target.Contains(fsm.instance_id) == false){hit_target.Add(fsm.instance_id);//1.生成格挡时特效/*var blockEffect = ResourcesManager.Instance.Create_Hit_Effect(CombatConfig.Instance.Config().block_effect);if (blockEffect != null){blockEffect.transform.position = hitInfo.point;blockEffect.transform.forward = hitInfo.normal;}*//*//镜头模糊控制 GameEvent.DORadialBlur?.Invoke(CombatConfig.Instance.Config().block_radialBlur);*//*//顿帧GameEvent.DOHitlag?.Invoke(CombatConfig.Instance.Config().block_hitlag.frame,CombatConfig.Instance.Config().block_hitlag.lerp);//放格挡成功的音效AudioController.Instance.Play(CombatConfig.Instance.Config().block_audio, hitInfo.point);//2.攻击方要进入弹反状态player.BeBlock(fsm);//3.格挡方要进入格挡成功的状态fsm.OnBlockSucces(player);//6.更新下血条
*/}}private void OnHit(Vector3 begin, HitConfig config, PlayerState state, RaycastHit hitInfo){//表示击中单位var fsm = hitInfo.transform.GetComponent<FSM>();if (fsm != null){if (hit_target.Contains(fsm.instance_id) == false){hit_target.Add(fsm.instance_id);//1.生成命中特效var hitObject = ResourcesManager.Instance.Create_Hit_Effect(config.hitObj);hitObject.SetActive(true);//2.设置特效的位置 朝向if (hitObject != null){hitObject.transform.position = hitInfo.point;hitObject.transform.forward = hitInfo.normal;}/*//3.计算 扣掉血量var damage = AttHelper.Instance.Damage(this.player, state, fsm);fsm.UpdateHP_OnHit(damage);//4.通知对方进入受击 死亡的动作var fb = fsm._transform.ForwardOrBack(begin) > 0 ? 0 : 1;if (fsm.att_crn.hp > 0){fsm.OnHit(fb, this.player);}else{fsm.OnDeath(fb);}//命中时的顿帧this.player.Attack_Hitlag(state);//6.命中的音效AudioController.Instance.Play(CombatConfig.Instance.Config().hit_enemy_audio, hitInfo.point);*/}}}public override void ReLoop(PlayerState state){base.ReLoop(state);}public override void ReStart(PlayerState state){base.ReStart(state);}RaycastHit[] raycastHits = null;public bool BoxCast(Transform begin, HitConfig config, PlayerState state){if (raycastHits == null){raycastHits = new RaycastHit[30];}//命中的数量var count = Physics.BoxCastNonAlloc(begin.position + begin.transform.TransformDirection(config.box_center), config.box_size,begin.forward, raycastHits, begin.rotation, config.length, player.GetEnemyLayerMask(),QueryTriggerInteraction.Collide);if (count > 0){int _crn_id = state.id;for (int i = 0; i < count; i++){var hitInfo = raycastHits[i];if (hitInfo.transform.CompareTag(GameDefine.WeaponTag)){OnBlock(hitInfo);}else{OnHit(begin.position, config, state, hitInfo);}if (_crn_id != player.currentState.id){break;}}return true;}return false;}
}
受击更新检测OnUpdate
public override void OnUpdate(float normalizedTime, PlayerState state){base.OnUpdate(normalizedTime, state);//将受击配置表的信息存储到configs中var configs = state.stateEntity.hitConfigs;//受击配置表不为空且配置表数量不为0if (configs != null && configs.Count > 0){for (int i = 0; i < configs.Count; i++){//选中当前的受击配置表,如果当前进度在触发点和结束点范围之内,则触发DO方法var e = configs[i];if (normalizedTime >= e.trigger && normalizedTime <= e.end){DO(e, state);}}}}
根据FSM类中的OnUpdate方法,触发受击服务体的每帧检测,检测是否能执行受击逻辑的执行(DO方法)
DO方法
Vector3 last_end;//在OnBegin方法中赋值为last_end = Vector3.zero;private void DO(HitConfig config, PlayerState state){//找到当前(config.begin)路径的特效及位置,(在可视化面板中设置的)var obj = player.GetHangPoint(config.begin); Vector3 begin = obj.transform.position;//config.type 是用来检测命中范围类型(0为射线,1为盒子) if (config.type == 0){//begin是物体点的起始位置//obj.transform.forward * config.length当前物体局部Z轴方向x射线长度(射线长度也是可视化面板设置)Vector3 end = begin + obj.transform.forward * config.length;if (last_end == Vector3.zero)//首次检测时{Linecast(begin, end, config, state);//射线检测起点到终点}else{var _crn_id = player.currentState.id;//记录当前状态for (int i = 0; i < 10; i++){//a与b向量中生成十条射线并依次进行检测Vector3 end2 = Vector3.Lerp(last_end, end, i / 10f);Linecast(begin, end2, config, state);//状态切换时强制返回结束if (_crn_id != player.currentState.id){return;}}}last_end = end;//记录本次检测的终点位置,作为下一帧检测的"历史位置"}else if (config.type == 1)//config.type 是用来检测命中范围类型(0为射线,1为盒子){BoxCast(obj.transform, config, state);//走盒子检测方法}}
//obj.transform.forward * config.length当前物体局部Z轴方向x射线长度(射线长度也是可视化面板设置)Vector3 end = begin + obj.transform.forward * config.length;
在Unity引擎中,
transform.forward
表示物体自身的正前方方向(即物体局部坐标系中的 Z轴正方向)。它返回物体局部 Z 轴正方向在世界坐标系中的方向向量(单位向量,长度为1)
last_end
变量
last_end
变量用于实现连续射线检测时的插值过渡,确保在物体快速移动或旋转时不会漏掉中间区域的碰撞检测
记录上一次射线检测的终点位置
在首次检测时初始化为
Vector3.zero
(特殊标记值)每次射线检测后更新为当前终点位置
解决快速移动导致的检测遗漏问题
当物体高速运动或旋转时,如果直接从旧位置跳到新位置,中间区域可能漏检
通过插值在
last_end(上次终点)
和end(本次终点)
之间生成10个中间点对每个中间点执行射线检测(类似"补帧"检测)
更新终点记录的目的
下一帧开始检测:
csharpelse // last_end 不是 zero {// 使用 last_end (上一帧终点) 和当前 end 进行插值Vector3 end2 = Vector3.Lerp(last_end, end, i/10f); }
如果没有这个更新:
所有后续检测都会使用首次的终点位置
插值计算完全错误
检测区域无法跟随物体运动
// 首次检测(没有历史数据) if (last_end == Vector3.zero) {// 直接检测从起点到终点的射线Linecast(begin, end, config, state); }// 后续检测(有历史数据) else {// 生成10个过渡点(从上次终点向本次终点渐变)for (int i = 0; i < 10; i++){// 计算插值点:从last_end到end的10%位置Vector3 end2 = Vector3.Lerp(last_end, end, i / 10f);// 检测从固定起点到移动终点的射线Linecast(begin, end2, config, state);// 状态变化时提前终止(如角色死亡)if (player.currentState.id != originalState) return;} }// 更新终点记录 last_end = end;
插值计算点
for (int i = 0; i < 10; i++){// 计算插值点:从last_end到end的10%位置Vector3 end2 = Vector3.Lerp(last_end, end, i / 10f);}
Vector3.Lerp(a, b, t)
是Unity引擎中Vector3
类型的静态方法:用于计算两个向量之间的线性插值:当 t = 0 时,返回向量 a(即 last_end) 当 t = 1 时,返回向量 b(即 end) 当 t = 0.5 时,返回 a 和 b 的中点(t可以理解为从起点a到终点b的进度百分比(0%到100%)) 公式:result = a + (b - a) * t
在这里是循环插值生成向量
射线检测Linecast
public bool Linecast(Vector3 begin, Vector3 end, HitConfig config, PlayerState state){Debug.DrawLine(begin, end, Color.red, 0.2f);//用红色0.2f粗的线条,绘制出射线//射线检测(起始点,结束点,碰撞信息,检测层级,碰撞器交互效果)var result = Physics.Linecast(begin, end, out var hitInfo, player.GetEnemyLayerMask(), QueryTriggerInteraction.Collide);if (result)//如果有射线碰撞到信息{//处于格挡状态激活格挡方法if (hitInfo.transform.CompareTag(GameDefine.WeaponTag)){OnBlock(hitInfo);}else//没被格挡就执行受击方法{OnHit(begin, config, state, hitInfo);}return true;}return false;}
result
的含义
Physics.Linecast()
方法返回一个 布尔值 (bool)
true
:表示射线检测到了碰撞(命中了碰撞体或触发器)
false
:表示射线没有检测到任何碰撞(没有命中任何物体)
if (result)
的作用这个条件判断的意思是:只有当射线检测到碰撞时,才执行内部的命中处理逻辑。
OnHit方法
List<int> hit_target = new List<int>();//记录哪些单位被击中过,避免多计算了伤害private void OnHit(Vector3 begin, HitConfig config, PlayerState state, RaycastHit hitInfo){//表示击中单位并且获取该单位的FSM组件var fsm = hitInfo.transform.GetComponent<FSM>();if (fsm != null){//判断hit_target是否记录着当前实例idif (hit_target.Contains(fsm.instance_id) == false){hit_target.Add(fsm.instance_id);//没有记录则添加该实例//1.生成命中特效var hitObject = ResourcesManager.Instance.Create_Hit_Effect(config.hitObj);hitObject.SetActive(true);//2.设置特效的位置 朝向if (hitObject != null){hitObject.transform.position = hitInfo.point;hitObject.transform.forward = hitInfo.normal;}//3.计算 扣掉血量var damage = AttHelper.Instance.Damage(this.player, state, fsm);fsm.UpdateHP_OnHit(damage);//4.通知对方进入受击 死亡的动作var fb = fsm._transform.ForwardOrBack(begin) > 0 ? 0 : 1;if (fsm.att_crn.hp > 0){fsm.OnHit(fb, this.player);}else{fsm.OnDeath(fb);}//命中时的顿帧this.player.Attack_Hitlag(state);//6.命中的音效AudioController.Instance.Play(CombatConfig.Instance.Config().hit_enemy_audio, hitInfo.point);}}}
1、获取击中目标的FSM组件
var fsm = hitInfo.transform.GetComponent<FSM>();
hitInfo.transform
这是从射线检测结果中获取的被命中物体的Transform组件
hitInfo
是RaycastHit
结构体,包含碰撞信息
transform
属性指向被命中游戏对象的Transform
GetComponent<FSM>()
从被命中的游戏对象上获取
FSM
组件var fsm = hitInfo.transform.GetComponent<FSM>(); 不能直接替换为var fsm = hitInfo.GetComponent<FSM>(); 因为RaycastHit 类本身没有 GetComponent<T>() 方法
2、防止多次命中同一目标if (hit_target.Contains(fsm.instance_id) == false) //防止同一攻击动作多次命中同一目标(如武器挥动过程中多次检测到同一个敌人)//该变量在FSM类中 instance_id = _gameObject.GetInstanceID();
GetInstanceID()
这是 Unity 的 Object 类提供的方法
返回一个唯一的整数标识符,代表该对象在本次游戏运行中的实例
3、特效生成的位置和朝向
if (hitObject != null){hitObject.transform.position = hitInfo.point;hitObject.transform.forward = hitInfo.normal;}
将命中特效(
hitObject
)的位置设置为碰撞点(hitInfo.point
)
hitInfo.point
是射线检测得到的精确碰撞位置(世界坐标系)将特效的正面(Z轴正方向)设置为碰撞表面的法线方向
hitInfo.normal
是碰撞表面的垂直方向向量(单位向量)
顿帧服务逻辑类:HitlagService
using System.Collections;
using System.Collections.Generic;
using UnityEngine;public class HitlagService : FSMServiceBase
{public override void Init(FSM fsm){base.Init(fsm);}public override void OnAnimationEnd(PlayerState state){base.OnAnimationEnd(state);}public override void OnBegin(PlayerState state){base.OnBegin(state);ReSetAllExcuted();}public override void OnEnd(PlayerState state){base.OnEnd(state);ReSetAllExcuted();}public override void OnDisable(PlayerState state){base.OnDisable(state);}public override void OnUpdate(float normalizedTime, PlayerState state){base.OnUpdate(normalizedTime, state);if (state.stateEntity.hitlagConfig != null && state.stateEntity.hitlagConfig.Count > 0){for (int i = 0; i < state.stateEntity.hitlagConfig.Count; i++){var x = state.stateEntity.hitlagConfig[i];if (x.triggerType == 0 && normalizedTime >= x.trigger && GetExcuted(i) == false){SetExcuted(i);GameEvent.DOHitlag?.Invoke(x.frame, x.lerp);}}}}public override void ReLoop(PlayerState state){base.ReLoop(state);}public override void ReStart(PlayerState state){base.ReStart(state);}public void DOHitlag_OnAttack(float normalizedTime, PlayerState state){if (state.stateEntity.hitlagConfig != null && state.stateEntity.hitlagConfig.Count > 0){for (int i = 0; i < state.stateEntity.hitlagConfig.Count; i++){var x = state.stateEntity.hitlagConfig[i];if (x.triggerType == 1 && normalizedTime >= x.trigger && normalizedTime <= x.trigger2){if (GetExcuted(i) == false){SetExcuted(i);GameEvent.DOHitlag?.Invoke(x.frame, x.lerp);}}}}}}
顿帧更新方法 OnUpdate
public override void OnUpdate(float normalizedTime, PlayerState state){base.OnUpdate(normalizedTime, state);//如果当前顿帧配置表不为空且数量不为0if (state.stateEntity.hitlagConfig != null && state.stateEntity.hitlagConfig.Count > 0){//遍历所有顿帧配置表for (int i = 0; i < state.stateEntity.hitlagConfig.Count; i++){//对当前遍历选中的配置表进行赋值var x = state.stateEntity.hitlagConfig[i];//如果触发类型是0(直接触发)且在触发点,且未被标记if (x.triggerType == 0 && normalizedTime >= x.trigger && GetExcuted(i) == false){SetExcuted(i);GameEvent.DOHitlag?.Invoke(x.frame, x.lerp);}}}}
GameEvent.DOHitlag?.Invoke(x.frame, x.lerp);
作用:当满足特定条件时,触发全局的"受击停顿"(Hitlag)效果,用于实现游戏中的"子弹时间"或打击感强化效果
触发受击停顿:
当攻击命中目标时,调用此代码使游戏进入短暂慢动作状态。
x.frame
:控制慢放持续多少帧
x.lerp
:决定是否使用渐变过渡(否则直接暂停)全局事件调度:
通过静态事件系统
GameEvent.DOHitlag
将触发指令传递到游戏核心系统
语法结构:1. 空条件运算符
?.
csharpGameEvent.DOHitlag?.Invoke()
等效逻辑:
csharpif (GameEvent.DOHitlag != null) {GameEvent.DOHitlag.Invoke(...); }
2. 事件委托
Invoke()
csharp.Invoke(x.frame, x.lerp)
作用:触发所有绑定到
DOHitlag
的事件处理器参数传递:
x.frame
→ 传递给Main.DOHitlag(int frame, bool lerp)
的frame
参数
x.lerp
→ 传递给lerp
参数
顿帧攻击方法: DOHitlag_OnAttack
这段代码用于在攻击动作的特定时间点触发击中停顿(Hitlag)效果。
public void DOHitlag_OnAttack(float normalizedTime, PlayerState state){if (state.stateEntity.hitlagConfig != null && state.stateEntity.hitlagConfig.Count > 0){for (int i = 0; i < state.stateEntity.hitlagConfig.Count; i++){var x = state.stateEntity.hitlagConfig[i];if (x.triggerType == 1 && normalizedTime >= x.trigger && normalizedTime <= x.trigger2){if (GetExcuted(i) == false){SetExcuted(i);GameEvent.DOHitlag?.Invoke(x.frame, x.lerp);}}}}}
条件检查:
csharpif (state.stateEntity.hitlagConfig != null && state.stateEntity.hitlagConfig.Count > 0)
检查玩家状态(
state
)中是否存在有效的击中停顿配置(hitlagConfig
列表非空)。
遍历配置列表:
csharpfor (int i = 0; i < state.stateEntity.hitlagConfig.Count; i++)
遍历所有预先配置的Hitlag触发条件。
触发条件判断:
csharpvar x = state.stateEntity.hitlagConfig[i]; //triggerType == 1意味着命中单位触发,且动画进度在两个触发点内 if (x.triggerType == 1 && normalizedTime >= x.trigger && normalizedTime <= x.trigger2)
triggerType == 1
:特定类型的触发条件(例如攻击动作)。当动画进度处于配置的区间
[x.trigger, x.trigger2]
时,满足触发条件
防止重复触发并执行hitlag效果
csharpif (GetExcuted(i) == false) {SetExcuted(i);// 触发事件GameEvent.DOHitlag?.Invoke(x.frame, x.lerp); }
GetExcuted/SetExcuted
:确保同一配置只触发一次(避免同一动画帧内重复触发)发布事件,传递参数:
x.frame
:停顿持续的帧数(控制卡顿时长)。
x.lerp
:插值参数(可能用于控制停顿的平滑度或强度)
注意事项:这里普通攻击的触发点大于0.15时,此时攻击动画会穿过目标,所以不再执行顿帧。
两种触发方法的特性
触发类型 触发条件 职责归属 Type 0 基于时间点触发
(normalizedTime >= x.trigger
)动画系统
(在OnUpdate
中处理动画时间推进)Type 1 基于时间范围触发
(normalizedTime ∈ [x.trigger, x.trigger2]
)战斗系统
(在DOHitlag_OnAttack
中处理攻击命中)
AudioController类
using System.Collections;
using System.Collections.Generic;
using UnityEngine;public class AudioController
{//所有音频操作通过AudioController.Instance调用,避免多个实例导致的资源冲突static AudioController instance = new AudioController();//确保全局唯一访问点public static AudioController Instance => instance;//Instance 属性提供全局访问入口Dictionary<string, Stack<AudioSource>> pool = new Dictionary<string, Stack<AudioSource>>();public void Play(string path, Vector3 point, bool loop = false, float volume = 1, float spactialBlend = 1){AudioSource audio = null;if (pool.ContainsKey(path) && pool[path].Count > 0){audio = pool[path].Pop();audio.gameObject.SetActive(true);}else{GameObject go = new GameObject("audio");audio = go.AddComponent<AudioSource>();}audio.transform.position = point;audio.clip = ResourcesManager.Instance.Load<AudioClip>(path);audio.loop = loop;audio.volume = volume;audio.spatialBlend = spactialBlend;audio.Play();}public void Stop(string path, AudioSource audioSource){if (pool.ContainsKey(path) == false){pool[path] = new Stack<AudioSource>();}audioSource.Stop();audioSource.gameObject.SetActive(false);pool[path].Push(audioSource);}
}
初始化属性
//所有音频操作通过AudioController.Instance调用,避免多个实例导致的资源冲突static AudioController instance = new AudioController();//确保全局唯一访问点public static AudioController Instance => instance;//Instance 属性提供全局访问入口Dictionary<string, Stack<AudioSource>> pool = new Dictionary<string, Stack<AudioSource>>();
对象池系统:
对象池属于创建型设计模式,通过预初始化对象集合,实现对象的重复利用而非频繁创建/销毁,这里对象池系统的存储结构是Dictionary。
Play方法
public void Play(string path, Vector3 point, bool loop = false, float volume = 1, float spactialBlend = 1){AudioSource audio = null;if (pool.ContainsKey(path) && pool[path].Count > 0){audio = pool[path].Pop();audio.gameObject.SetActive(true);}else{GameObject go = new GameObject("audio");audio = go.AddComponent<AudioSource>();}audio.transform.position = point;audio.clip = ResourcesManager.Instance.Load<AudioClip>(path);audio.loop = loop;audio.volume = volume;audio.spatialBlend = spactialBlend;audio.Play();}
1、声明属性的作用
public void Play(string path, Vector3 point, bool loop = false, float volume = 1, float spactialBlend = 1)
path
:音频资源在Resources
目录下的路径(如"Audio/SFX/jump"
)point
:音频播放的3D世界坐标loop
:是否循环播放(默认关闭)volume
:音量(0~1,默认最大)spatialBlend
:3D/2D混合比例(0为纯2D,1为纯3D,默认全3D
2、初始化为null
AudioSource audio = null;
对于引用类型的局部变量,使用`= null`进行初始化是一种常见的做法,特别是当变量将在后续的条件分支中被赋值时。
Q1:不能直接用new函数进行初始化吗A1:
- 每次调用Play方法都会创建一个新的AudioSource实例。
- 即使对象池中有可用的实例,也会被这个新创建的实例覆盖,导致对象池失去意义。
- 同时,这个新创建的AudioSource并没有附加到任何GameObject上(因为AudioSource是组件,必须依附于GameObject),所以这样写本身也是错误的
3、检验对象池——有则取出激活,无则新建添加入池if (pool.ContainsKey(path) && pool[path].Count > 0) // 检查对象池 {audio = pool[path].Pop(); // 从池中取出audio.gameObject.SetActive(true); // 激活对象 } else // 池中没有可用对象 {GameObject go = new GameObject("audio"); // 创建新游戏对象audio = go.AddComponent<AudioSource>(); // 添加音频组件 }
优先复用:通过路径查找对象池中闲置的 AudioSource
按需创建:无可用对象时新建 GameObject + AudioSource
pool[path]
字典索引器,访问 pool
字典中键为path
的Stack<AudioSource>
实例.Pop()
调用 Stack<T>
的Pop()
方法,移除并返回栈顶元素
4、音频参数配置并启动播放
audio.transform.position = point; // 设置声源位置(3D音效关键) audio.clip = ResourcesManager.Instance.Load<AudioClip>(path); // 加载音频资源 audio.loop = loop; // 设置是否循环播放 audio.volume = volume; // 设置音量(0.0-1.0) audio.spatialBlend = spactialBlend; // 设置3D/2D混合(0.0=纯2D, 1.0=纯3D)audio.Play(); // 开始播放音频
Stop方法
这段代码是 AudioController
类中的 Stop
方法,主要功能是停止音频播放并回收 AudioSource 到对象池
public void Stop(string path, AudioSource audioSource){if (pool.ContainsKey(path) == false){pool[path] = new Stack<AudioSource>();}audioSource.Stop();audioSource.gameObject.SetActive(false);pool[path].Push(audioSource);}
确保对象池存在:
csharpif (pool.ContainsKey(path) == false) {pool[path] = new Stack<AudioSource>(); }
检查该音频路径对应的对象池是否存在
不存在则创建新的 Stack(按音频路径分类的对象池)
停止音频播放:
csharpaudioSource.Stop(); // 立即停止音频播放
禁用游戏对象:
csharpaudioSource.gameObject.SetActive(false);
将关联的 GameObject 设为非激活状态
避免在场景中显示(虽然音频对象通常不可见)
准备对象复用
回收资源到对象池:
csharppool[path].Push(audioSource);
将 AudioSource 压入对应路径的栈中
标记为可复用状态
Q1:为什么Stop方法中要检测音频路径对应的对象池是否存在?
A1:需要注意的是,在当前的
AudioController
设计中,Play()
方法不会创建对象池
Play()
职责:获取/创建 AudioSource 并播放音频
Stop()
职责:回收资源并管理对象池对象池创建时机如下:
对象池只在首次回收资源时创建
播放时不需要池结构,只需要获取资源
如果 Play() 中创建池,且对于导致大量零元素的空栈占用内存,对于只播放一次的音频是纯粹浪费
CombatConfig类
using System.Collections;
using System.Collections.Generic;
using UnityEngine;public class CombatConfig
{static CombatConfig instance = new CombatConfig();public static CombatConfig Instance => instance;GlobalCombatConfig config;public void Init(){config = ResourcesManager.Instance.Load<GlobalCombatConfig>("GlobalConfig/Combat");}public GlobalCombatConfig Config(){return config;}}
static CombatConfig instance = new CombatConfig();//在类加载时创建唯一静态实例public static CombatConfig Instance => instance;//通过属性公开该实例
单例模式实现
效果:全局任何地方都可通过
CombatConfig.Instance
访问同一个配置管理器
public void Init() {// 从资源管理器加载配置config = ResourcesManager.Instance.Load<GlobalCombatConfig>("GlobalConfig/Combat"); }
资源路径:
"GlobalConfig/Combat"
(Unity的Resources路径)加载方式:通过自定义的
ResourcesManager
加载泛型资源加载时机:需显式调用
Init()
初始化(通常在游戏启动时)注意事项:加载资源的路径名要相同
ResourcesManager类
using System.Collections;
using System.Collections.Generic;
using UnityEngine;public class ResourcesManager
{static ResourcesManager instance = new ResourcesManager();public static ResourcesManager Instance => instance;public T Load<T>(string path) where T : Object{return Resources.Load<T>(path);}public T Instantiate<T>(string path) where T : Object{var r = Load<T>(path);if (r != null){return Object.Instantiate(r);}return null;}public void Destroy(GameObject go){Object.Destroy(go);}Stack<GameObject> hit_effect = new Stack<GameObject>(50);public GameObject Create_Hit_Effect(string path){if (hit_effect.Count > 0){var go = hit_effect.Pop();//go.SetActive(true);return go;}else{var obj = Instantiate<GameObject>(path);Object.DontDestroyOnLoad(obj);//切场景不进行销毁return obj;}}public void Destroy_Hit_Effect(GameObject go){if (go != null){go.SetActive(false);hit_effect.Push(go);}}}
资源的加载和实例化
1. 单例模式 (Singleton)
csharpstatic ResourcesManager instance = new ResourcesManager(); public static ResourcesManager Instance => instance;
作用:确保全局只有一个资源管理器实例,通过
ResourcesManager.Instance
全局访问。2.资源管理——加载资源和生成实例
public T Load<T>(string path) where T : Object {return Resources.Load<T>(path); }
功能:从
Resources
文件夹加载资源(如预制体、纹理等)示例:
Load<GameObject>("Prefabs/Effect")
加载特效预制体。
public T Instantiate<T>(string path) where T : Object{var r = Load<T>(path);if (r != null){return Object.Instantiate(r);}return null;}
功能:加载并实例化资源(如生成游戏对象)。
示例:
Instantiate<GameObject>("Effects/Explosion")
创建爆炸效果
3.销毁对象
csharppublic void Destroy(GameObject go) {Object.Destroy(go); }
功能:销毁游戏对象(直接调用 Unity 的
Destroy
)
4. 对象池系统 (重点)
(1) 对象池容器
csharpStack<GameObject> hit_effect = new Stack<GameObject>(50);
作用:用栈存储可重用的击中效果对象(初始化容量 50)。
(2) 获取击中效果
csharppublic GameObject Create_Hit_Effect(string path) {if (hit_effect.Count > 0) {var go = hit_effect.Pop(); // 从池中取出// go.SetActive(true); // 需取消注释以激活对象return go;}else {var obj = Instantiate<GameObject>(path);Object.DontDestroyOnLoad(obj); // 跨场景不销毁return obj;} }
逻辑:
池中有对象 → 直接取出复用(需手动激活对象,当前代码注释了
SetActive(true)
)。池为空 → 创建新对象并标记为跨场景不销毁。
(3) 回收击中效果
csharppublic void Destroy_Hit_Effect(GameObject go) {if (go != null) {go.SetActive(false); // 隐藏而非销毁hit_effect.Push(go); // 压入栈中备用} }
逻辑:将当前击中特效对象设为非激活状态并存入对象池,避免重复实例化。
Q1:资源加载和生成实例化有什么区别(Load<T>与Instantiate<T>的功能区别)
A1:
Load<T>
:资源加载(获取引用)csharppublic T Load<T>(string path) where T : Object {return Resources.Load<T>(path); }
核心作用:从磁盘加载资源到内存(获取资源引用)
返回结果:资源的原始文件引用(未实例化)
类比:从仓库取出设计图纸(图纸本身不能直接使用)
Instantiate<T>
:实例化(创建对象)csharppublic T Instantiate<T>(string path) where T : Object {var r = Load<T>(path);if (r != null) return Object.Instantiate(r);return null; }
核心作用:创建资源的场景实例(生成游戏对象)
返回结果:在场景中新创建的实例对象
类比:根据图纸生产具体产品(产品可放入场景使用
Create_Hit_Effect方法
Stack<GameObject> hit_effect = new Stack<GameObject>(50);//为特效物体声明一个栈进行存储public GameObject Create_Hit_Effect(string path){if (hit_effect.Count > 0)//当存储特效物体栈不为0{var go = hit_effect.Pop();取出栈中的物体赋值给go//go.SetActive(true);return go;//把go的值返回}else{var obj = Instantiate<GameObject>(path);//通过path路径初始化生成特效物体赋值给objObject.DontDestroyOnLoad(obj);//切场景不进行销毁return obj;//把obj的值返回}}
伤害计算辅助:AttHelper类
using System.Collections;
using System.Collections.Generic;
using Game.Config;
using UnityEngine;public class AttHelper
{static AttHelper instance = new AttHelper();public static AttHelper Instance => instance;//创建实例避免外部修改/// <summary>/// 通过ID获取属性配置表对应的实体/// </summary>/// <param name="id"></param>/// <returns></returns>public UnitAttEntity Creat(int id){var a = UnitAttData.Get(id);if (a == null) return null;UnitAttEntity b = new UnitAttEntity();b.id = a.id;b.hp = a.hp;b.phy_atk = a.phy_atk;b.magic_atk = a.magic_atk;b.phy_def = a.phy_def;b.magic_def = a.magic_def;b.critical_hit_rate = a.critical_hit_rate;b.critical_hit_multiple = a.critical_hit_multiple;b.skill_speed = a.skill_speed;return b;}public UnitAttEntity Creat(UnitAttEntity a){UnitAttEntity b = new UnitAttEntity();b.id = a.id;b.hp = a.hp;b.phy_atk = a.phy_atk;b.magic_atk = a.magic_atk;b.phy_def = a.phy_def;b.magic_def = a.magic_def;b.critical_hit_rate = a.critical_hit_rate;b.critical_hit_multiple = a.critical_hit_multiple;b.skill_speed = a.skill_speed;return b;}public int Damage(FSM atk, PlayerState state, FSM hit){int damage = 0;var critical = UnityEngine.Random.Range(0, 101f) <= atk.att_crn.critical_hit_rate;//没有暴击的情况if (critical == false){damage = (int)((atk.att_crn.phy_atk - hit.att_crn.phy_def + state.skill.phy_damage));}else{ //暴击damage = (int)((atk.att_crn.phy_atk - hit.att_crn.phy_def + state.skill.phy_damage)* atk.att_crn.critical_hit_multiple);}return damage;}}
两种创建UnitAttEntity类型表格的方式
public UnitAttEntity Creat(int id) 根据int类型生成UnitAttEntity类型表格{var a = UnitAttData.Get(id);if (a == null) return null;//如果a是空表,退出当前方法,也不会执行另一个生成表格方式UnitAttEntity b = new UnitAttEntity();b.id = a.id;b.hp = a.hp;b.phy_atk = a.phy_atk;b.magic_atk = a.magic_atk;b.phy_def = a.phy_def;b.magic_def = a.magic_def;b.critical_hit_rate = a.critical_hit_rate;b.critical_hit_multiple = a.critical_hit_multiple;b.skill_speed = a.skill_speed;return b;}public UnitAttEntity Creat(UnitAttEntity a)//通过现有实体创建(克隆){UnitAttEntity b = new UnitAttEntity();b.id = a.id;b.hp = a.hp;b.phy_atk = a.phy_atk;b.magic_atk = a.magic_atk;b.phy_def = a.phy_def;b.magic_def = a.magic_def;b.critical_hit_rate = a.critical_hit_rate;b.critical_hit_multiple = a.critical_hit_multiple;b.skill_speed = a.skill_speed;return b;}
Damage方法
public int Damage(FSM atk, PlayerState state, FSM hit){int damage = 0;//生成一个 0-100的随机浮点数,与攻击方的暴击率比较//随机数 ≤ 暴击率 → 触发暴击 (critical=true)var critical = UnityEngine.Random.Range(0, 101f) <= atk.att_crn.critical_hit_rate;//没有暴击的情况if (critical == false){damage = (int)((atk.att_crn.phy_atk - hit.att_crn.phy_def + state.skill.phy_damage));//攻击方物理攻击力 - 防御方物理防御力 + 技能附加伤害=基础伤害}else{ //暴击damage = (int)((atk.att_crn.phy_atk - hit.att_crn.phy_def + state.skill.phy_damage)* atk.att_crn.critical_hit_multiple);//基础伤害*暴击倍数=暴击伤害}return damage;}}
1、随机生成数进行判断比较
var critical = UnityEngine.Random.Range(0, 101f) <= atk.att_crn.critical_hit_rate;
UnityEngine.Random.Range(0, 101f)
- 功能:生成一个 0(包含)到 101(包含) 之间的随机浮点数。
- 参数说明:
- 第一个参数是下限(
minInclusive
),第二个参数是上限(maxInclusive
)。
<= atk.att_crn.critical_hit_rate
- 比较运算符:判断生成的随机数是否 小于等于 攻击方的暴击率(
critical_hit_rate
)。
- 所有比较运算符(如
==
、!=
、>
、<
、>=
、<=
) 的运算结果均为 布尔值(bool
)- 暴击率含义:例如若
critical_hit_rate = 10
,表示 10% 的暴击概率(由表格中的值所设置确定)。赋值给
var critical
- 变量类型推断:
var
自动推导为bool
类型,结果为true
(暴击)或false
(未暴击)。