配置表格
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-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/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)
csharp
if (!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中已经填写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)
作用
用于在创建类的实例(对象)时初始化对象的属性 。当调用
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方法(状态初始化)
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);
}
}
}
加载状态配置资源
csStateScriptableObject 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);
}
}
}
遍历
stateData
foreach (var item in stateData)
stateData
是一个Dictionary<int, PlayerState>
类型的数据结构,其中:
- Key 是状态 ID(int)
- Value 是某种包含 Excel 配置的对象(比如
StateConfig
)
检查
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 和事件类型上
检查
do_move == 1
if (item.Value.excel_config.do_move == 1)
{
Addlinstenr(item.Key, StateEventType.update, PlayerMove);
}
- 如果
do_move
的值为 1,则添加另一个更新事件监听器PlayerMove
检查
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
{
//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;
}
状态存在性检查:
csharp if (stateData.ContainsKey(Next)) // Next = 1001
- 检查字典中是否存在ID为1001的状态配置
日志输出:
csharp if (currentPlayerstate != null) { Debug.Log($"{this.gameObject.name}:切换状态:{stateData[Next].Info()}..."); } else { Debug.Log($"{this.gameObject.name}: 切换状态:{stateData[Next].Info()} "); }
由于是首次初始化,
currentPlayerstate
为null
,执行else分支输出类似:"PlayerObject: 切换状态:1001_待机状态"
结束旧状态处理:
csharp if (currentPlayerstate != null) { DOStateEvent(currentPlayerstate.id, StateEventType.end); ServicesOnEnd(); }
- 当前无旧状态,跳过此段代码
设置新状态:
csharp currentPlayerstate = stateData[Next]; // 获取ID=1001的状态对象 currentPlayerstate.SetBeginTime(); // 记录状态开始时间
触发新状态开始事件:
csharp DOStateEvent(currentPlayerstate.id, StateEventType.begin);
执行所有注册到状态1001的
begin
类型事件通过
Addlinstenr
添加的事件处理函数会被触发服务系统初始化:
csharp ServiceOnBegin();
调用所有服务的
OnBegin
方法(如AnimationService)服务系统开始为当前状态工作
返回结果:
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();
}
}
}
}
检查状态是否存在:(action数据请查看AddListener方法)
csharp if (actions.TryGetValue(id, out var v)) //查找当前状态1001的所有事件类型如Begin/Update
在全局事件字典
actions
中查找指定状态ID如果存在,将事件字典赋值给变量
v
检查事件类型是否存在:
csharp if (v.TryGetValue(t, out var lst)) //查找当前状态1001当前事件类型(Begin)的对应方法(待机回血/播放待机语音)
在状态的事件字典中查找特定事件类型(begin/update等)
如果存在,将事件列表赋值给
lst
执行所有注册的方法:
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]
的实际类型:
多态机制 :
由于
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);
}
}
输入方向归一化
csharp
Vector3 inputDirection = new Vector3(x, 0f, z).normalized;
数学逻辑 :将原始输入值
(x, z)
转换为单位向量(长度为1)。目的:消除不同输入强度(如轻推摇杆 vs 全推摇杆)对移动速度的影响,确保移动方向准确。
计算目标旋转角度
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)
平滑旋转插值
float rotation = Mathf.SmoothDampAngle(
_transform.eulerAngles.y, // 当前角度
_targetRotation, // 目标角度
ref _rotationVelocity, // 当前角速度(引用传递)
RotationSmoothTime // 平滑时间
);
_transform.rotation = Quaternion.Euler(0.0f, rotation, 0.0f);
计算世界空间移动方向
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. 坐标系转换(可选)
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)
重力处理
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._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 单位
执行移动
csharp
characterController.Move(d2);
调用Unity的CharacterController组件执行实际移动
自动处理碰撞检测和物理响应
地面检测标记
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
一致。若不一致,说明在
DOStateEvent
或ServicesOnAnimationEnd
中触发了状态切换(如跳转到新状态),此时直接退出,避免执行无效操作。
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
csprivate void EnableCollider() { characterController.excludeLayers = 0;// 设置为0表示不排除任何层 }
作用 :启用与所有层的碰撞检测。
行为 :将
excludeLayers
设为0
(二进制全0),表示角色控制器不再忽略任何碰撞层,可以与场景中所有物体发生碰撞。使用场景:通常用于需要恢复完整碰撞时(如角色结束无敌状态、恢复正常交互时)。
csprivate 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
- 全局初始化的中心节点
作用 :作为游戏启动时的核心初始化入口(在
Awake
中调用SystemInit()
)。必要性 :
Unity需要场景中的激活GameObject 挂载脚本才能执行
Awake
/Start
。空物体作为轻量级载体,确保初始化代码在场景加载时自动运行。优势 :
避免将初始化逻辑分散到多个物体上,集中管理游戏启动流程(如配置加载、事件绑定)。
- 关键系统依赖的宿主
时间缩放控制(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
实例)必须存在物体必须处于激活状态(否则协程不会执行)
- 全局单例的稳定访问
示例 :
CombatConfig.Instance.Init()
空物体保证初始化代码在场景中最早执行,避免其他脚本访问未初始化的单例。
- 相机等关键引用托管
代码 :
GameDefine._Camera = GameObject.Find("Camera").transform
通过空物体集中获取并存储场景中的关键对象(如主摄像机),供全局访问。
- 时间管理的统一入口
代码 :
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;
}
}
系统初始化 (
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
- 语法结构
- 属性 :
Cursor.lockState
- 类型 :枚举(
CursorLockMode
)- 赋值 :
CursorLockMode.Locked
- 功能
- 锁定光标到屏幕中心:光标会被固定在游戏窗口中心,无法移动。
- 隐藏光标 :无论
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);
}
}
}
鼠标输入处理
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
的当前值限制在-30
到80
之间。
- 若
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
:避免摄像机距离过远导致视野过小
摄像机位置计算
csharp
Quaternion 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
列表以匹配配置表变化与传统代码状态机相比:
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`列表中。
cspublic 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
对象- 通过
id
和info
拼接生成唯一标识(如"1001_待机"
)- states.Add(entity);
- 通过
states.Add(entity)
将实体添加到states
列表中
当states
列表非空时
cselse//当列表为非空时 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
)
csforeach (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
)若不存在则创建新条目
实现功能:自动添加策划在配置表中新增的状态
删除多余项
csList<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" });
代码执行流程:
csif (dct.Count != states.Count) // 3 != 2 → 进入同步流程
csforeach (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); }); } }
得出结果如下:
csstates 现在包含3个元素: 1001_IDLE → 1002_WALK → 1003_RUN
假设开发者删除了配置表中的ID=1002状态:
csstates 现在包含3个元素: 1001_IDLE → 1002_WALK → 1003_RUN
触发同步:
csif (dct.Count != states.Count) // 2 != 3 → 进入同步流程
csList<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方法
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();
}
}
}
物理效果触发 (
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;
施加物理力和检测障碍后移除
// 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; // 标记物理效果启动
}
}
时间差计算
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);
}
}
}
}
对当前的特效物体配置表进行找到对应特效,并且激活,以下这段代码就是控制着打钩激活物体特效的功能
csobj.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); // 强制立即更新动画状态
}
代码逐行解析:
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; // 确保进度不超过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
变量用于实现连续射线检测时的插值过渡,确保在物体快速移动或旋转时不会漏掉中间区域的碰撞检测
记录上一次射线检测的终点位置
在首次检测时初始化为
Vector3.zero
(特殊标记值)每次射线检测后更新为当前终点位置
解决快速移动导致的检测遗漏问题
当物体高速运动或旋转时,如果直接从旧位置跳到新位置,中间区域可能漏检
通过插值在
last_end(上次终点)
和end(本次终点)
之间生成10个中间点对每个中间点执行射线检测(类似"补帧"检测)
更新终点记录的目的
下一帧开始检测:
cscsharp 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;
插值计算点
csfor (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>();
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、防止多次命中同一目标
csif (hit_target.Contains(fsm.instance_id) == false) //防止同一攻击动作多次命中同一目标(如武器挥动过程中多次检测到同一个敌人) //该变量在FSM类中 instance_id = _gameObject.GetInstanceID();
GetInstanceID()
这是 Unity 的 Object 类提供的方法
返回一个唯一的整数标识符,代表该对象在本次游戏运行中的实例
3、特效生成的位置和朝向
csif (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
}
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);
}
}
}
}
csGameEvent.DOHitlag?.Invoke(x.frame, x.lerp);
作用:当满足特定条件时,触发全局的"受击停顿"(Hitlag)效果,用于实现游戏中的"子弹时间"或打击感强化效果
触发受击停顿:
当攻击命中目标时,调用此代码使游戏进入短暂慢动作状态。
x.frame
:控制慢放持续多少帧
x.lerp
:决定是否使用渐变过渡(否则直接暂停)全局事件调度:
- 通过静态事件系统
GameEvent.DOHitlag
将触发指令传递到游戏核心系统语法结构:
- 空条件运算符
?.
cscsharp GameEvent.DOHitlag?.Invoke()
等效逻辑:
cscsharp if (GameEvent.DOHitlag != null) { GameEvent.DOHitlag.Invoke(...); }
- 事件委托
Invoke()
cscsharp .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);
}
}
}
}
}
条件检查:
csharp if (state.stateEntity.hitlagConfig != null && state.stateEntity.hitlagConfig.Count > 0)
- 检查玩家状态(
state
)中是否存在有效的击中停顿配置(hitlagConfig
列表非空)。遍历配置列表:
csharp for (int i = 0; i < state.stateEntity.hitlagConfig.Count; i++)
- 遍历所有预先配置的Hitlag触发条件。
触发条件判断:
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]
时,满足触发条件防止重复触发并执行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
字典中键为path
的Stack<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);
}
确保对象池存在:
csharp if (pool.ContainsKey(path) == false) { pool[path] = new Stack<AudioSource>(); }
检查该音频路径对应的对象池是否存在
不存在则创建新的 Stack(按音频路径分类的对象池)
停止音频播放:
csharp audioSource.Stop(); // 立即停止音频播放
禁用游戏对象:
csharp audioSource.gameObject.SetActive(false);
将关联的 GameObject 设为非激活状态
避免在场景中显示(虽然音频对象通常不可见)
准备对象复用
回收资源到对象池:
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;//通过属性公开该实例
单例模式实现
- 效果 :全局任何地方都可通过
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;
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
(未暴击)。
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;
}
}
点积计算:
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,表示目标在正侧方