1. 项目概述:从零到一构建一个可玩的TPS Demo
最近在整理硬盘,翻出来一个几年前做的Unity第三人称射击游戏(TPS)的完整项目演示。这个项目麻雀虽小,五脏俱全,涵盖了从场景搭建、角色控制、武器系统到敌人AI等核心模块。当时做这个Demo,主要是为了验证一套自己总结的、适合中小型团队快速开发TPS的框架思路。今天正好借着这个机会,把这个项目的核心设计、实现细节以及过程中踩过的坑,系统地梳理一遍,希望能给正在或打算涉足TPS开发的你一些直接的参考。
这个Demo的目标很明确: 构建一个手感流畅、反馈清晰、具备基本可玩性的第三人称射击游戏原型 。它不是一个教学性质的“Hello World”,而是一个力求在有限资源下,达到商业Demo水准的实践项目。因此,在技术选型和实现上,我会更侧重于 性能、可扩展性和实战手感 ,而不是单纯的功能堆砌。无论你是Unity的初学者,想了解一个完整游戏项目是如何串联起来的;还是有一定经验的开发者,希望优化自己的TPS架构,这篇内容应该都能给你带来一些启发。
2. 核心系统设计与架构思路拆解
2.1 为什么选择“组件化状态机”作为核心架构?
在开始敲代码之前,最重要的就是确定核心架构。对于动作射击游戏,角色的状态管理是重中之重。我放弃了简单的
if-else
或
switch-case
来管理状态,也摒弃了过于重量级的行为树(对于这个Demo的敌人AI复杂度来说有点杀鸡用牛刀),最终选择了
基于枚举的状态机(FSM)结合组件化设计
。
这么做的理由很直接:清晰、高效、易调试。
一个TPS角色无非就是“闲置”、“移动”、“瞄准”、“射击”、“换弹”、“受伤”、“死亡”这几个核心状态。用状态机可以清晰地定义状态间的转换条件和逻辑,避免状态混乱导致的Bug。而“组件化”是指,我不在一个巨大的
PlayerController
脚本里写所有逻辑,而是将不同功能拆分成独立的组件,例如:
-
InputHandler: 负责处理所有输入,并将其转化为事件或直接的数据(如移动向量、视角偏移)。 -
MovementComponent: 负责根据输入和当前状态(如是否在瞄准)计算角色的移动、旋转和重力应用。 -
AimComponent: 处理瞄准逻辑,包括摄像机偏移、角色上半身骨骼的旋转(用于实现腰射和机瞄的平滑过渡)。 -
WeaponManager: 武器管理组件,负责武器的切换、当前武器实例的引用。 -
HealthComponent: 生命值管理,处理伤害接收、死亡事件。
状态机(比如一个
StateMachine
类)作为协调者,它持有当前状态枚举,并根据规则切换状态。当状态改变时,它会通知各个相关组件:“嘿,我现在进入瞄准状态了”或“我现在开始换弹了”。各组件监听这些状态事件,并执行相应的行为。比如
MovementComponent
在收到“进入瞄准状态”事件时,会将移动速度降低,并改变旋转响应模式。
实操心得: 在状态机设计初期,一定要在白板或文档里画出完整的状态转换图。明确每个状态可以切换到哪些状态,触发条件是什么(例如:从“闲置”到“移动”的条件是“有移动输入”;从“射击”到“换弹”的条件是“弹药为0且按下换弹键”)。这能极大避免后期逻辑纠缠。我习惯用一个
ScriptableObject来配置状态转换表,这样非程序员也能理解和调整。
2.2 输入系统的现代化选择:Input System vs Legacy Input
Unity的新Input System包绝对是现代项目的首选,在这个Demo里我也毫不犹豫地使用了它。原因有三:
- 设备无关性 :一套逻辑处理键鼠、手柄甚至触屏输入,无需写多套判断代码。这对于希望跨平台(PC、主机)的TPS来说至关重要。
- 输入处理更精细 :可以轻松处理组合键、长按、双击,并且能直接获取手柄扳机的模拟量(用于控制赛车油门或步行速度很实用,虽然TPS里我们更多是作为二元开关,但未来可扩展)。
-
性能与可配置性
:新的Input System效率更高,并且可以通过
.inputactions资产文件可视化地配置所有输入映射,修改起来非常方便,也便于本地化。
具体实现上
,我创建了一个
PlayerInputActions
资产。在里面定义了几个Action Maps,比如
Gameplay
(游戏时)和
UI
(打开菜单时)。在
Gameplay
下,定义了
Move
(Composite 2D Vector,绑定WASD和手柄左摇杆)、
Look
(Delta,绑定鼠标Delta和手柄右摇杆)、
Fire
(Button,绑定鼠标左键和手柄RT)、
Aim
(Button,绑定鼠标右键和手柄LT)、
Reload
(Button,绑定R键和手柄X键)等Actions。
在
InputHandler
组件中,我通过C#的
PlayerInput
组件或直接使用
InputSystem
的API来读取这些Action的值,并将其转化为自定义的事件(如
OnFirePressed
、
OnMoveInput
)广播给其他组件。这样,输入处理层和游戏逻辑层就完全解耦了。
踩坑记录: 新手在使用Input System时,最容易混淆的就是
Started、Performed、Canceled这三个回调阶段。对于射击游戏的Fire键,我们通常需要在Started(按下瞬间)就触发射击逻辑,以实现最快的响应。而Performed可能更适合需要蓄力的操作。务必根据你的游戏手感需求来选择合适的阶段。
2.3 摄像机与角色控制的“感觉”调校
TPS的“手感”大半来自于摄像机。我的目标是实现类似《战争机器》或《全境封锁》那种稳定、顺滑又有一定动态感的第三人称摄像机。
1. 摄像机跟随:
我没有简单地将摄像机设为角色的子物体,因为那样旋转会过于生硬。我使用了一个经典的“弹簧臂(Spring Arm)”模式,即一个空的
CameraRig
物体作为角色子物体,它只负责Y轴旋转(角色转向)。
CameraRig
下挂着一个
CameraPivot
,负责X轴旋转(上下看)。摄像机本身作为
CameraPivot
的子物体,并通过一个
Cinemachine Virtual Camera
或自己写的
CameraFollow
脚本来实现平滑跟随。
自己写
CameraFollow
的核心逻辑是:
// 伪代码逻辑
Vector3 targetPosition = cameraPivot.position - (cameraPivot.forward * desiredDistance) + (cameraPivot.up * heightOffset);
// 增加一个简单的弹簧物理或Lerp/Slerp平滑
cameraTransform.position = Vector3.Slerp(cameraTransform.position, targetPosition, followSharpness * Time.deltaTime);
// 摄像机始终看向一个在角色前方的“LookAtTarget”空物体,这个目标点可以根据瞄准状态进行偏移
cameraTransform.LookAt(lookAtTarget.position);
desiredDistance
(期望距离)和
heightOffset
(高度偏移)会根据状态变化(跑步时拉远、瞄准时拉近)。
2. 角色旋转: 角色身体的旋转需要和摄像机方向解耦又关联。通常规则是:
- 自由移动时 :角色朝向与移动输入方向一致(相对于摄像机),这需要将摄像机的平面旋转应用到移动向量上。
-
瞄准时
:角色上半身(通过动画层或骨骼IK)朝向瞄准方向,下半身可以有一定延迟地跟随,形成自然的扭身效果。这里我使用了动画层(Layer)和
Animator.SetLookAtWeight结合脚本来控制头部和脊柱的IK看向目标。
3. 碰撞与遮挡处理:
当摄像机和角色之间出现墙壁时,不能让它穿墙。我的处理方法是,从角色肩部(
CameraPivot
)向摄像机目标位置发射一条射线。如果检测到碰撞,就将摄像机位置拉近到碰撞点前方一点的位置。同时,为了平滑,可以逐渐改变摄像机的近裁剪面,并淡出遮挡物(如果美术资源支持)。
手感调校技巧: 所有涉及移动、旋转、摄像机跟随的平滑参数(如
followSharpness、rotationSpeed),都不要在代码里写死。将它们暴露为[SerializeField]的公共变量或放在一个ScriptableObject(如PlayerSettings)配置文件中。在Unity编辑器里运行时,实时调整这些参数并感受变化,是调出手感的唯一捷径。我通常会为移动、瞄准分别配置两套速度、加速度和阻尼参数。
3. 武器系统的模块化实现
3.1 武器数据与逻辑分离:ScriptableObject的完美应用
武器系统是最适合使用
ScriptableObject
(SO)的地方。我为武器创建了一个基类
WeaponData_SO
,里面定义了所有武器的通用属性:
[CreateAssetMenu(fileName = “NewWeaponData”, menuName = “Game/Weapon Data”)]
public class WeaponData_SO : ScriptableObject
{
public string weaponName;
public GameObject modelPrefab; // 武器模型预制件
public AudioClip fireSound;
public AudioClip reloadSound;
public float damage;
public float fireRate; // 每秒发射数
public int magazineSize;
public float reloadTime;
public float spread; // 子弹散布
public float range;
// ... 其他如后坐力模式、瞄准镜参数等
}
然后,为步枪、手枪、霰弹枪等创建具体的SO资产。这样,策划或开发者可以在不碰代码的情况下,配置出成百上千种武器变体。
逻辑部分
,有一个
Weapon
MonoBehaviour脚本挂载在武器模型预制件根节点上。它持有一个对
WeaponData_SO
的引用。
Weapon
脚本负责具体的开火逻辑(生成子弹或射线检测)、播放音效和动画、管理弹药库存(当前弹匣弹药和总备弹)。
WeaponManager
组件则管理玩家当前持有的所有
Weapon
实例,处理切换武器、装填等高层指令。
3.2 开火与伤害判定:射线检测 vs 弹道模拟
对于大多数现代TPS(尤其是写实风格),
射线检测(Raycast)
是首选。因为它即时、准确,且性能开销小。在
Weapon
的
Fire
方法里:
- 从摄像机中心(或枪口,但为了体验通常用摄像机)发射一条射线。
-
应用当前武器的
spread(散布)值,给射线的方向一个随机偏移。 -
进行射线检测,如果命中物体,判断其是否有
HealthComponent,有则调用其TakeDamage方法。
void FireWeapon() {
Vector3 fireDirection = mainCamera.transform.forward;
// 计算散布
Vector3 spreadOffset = new Vector3(Random.Range(-spread, spread), Random.Range(-spread, spread), 0);
fireDirection = Quaternion.Euler(spreadOffset) * fireDirection;
RaycastHit hit;
if (Physics.Raycast(mainCamera.transform.position, fireDirection, out hit, range, hitLayerMask)) {
// 命中特效(如火花、弹孔Decal)
SpawnHitEffect(hit.point, hit.normal);
// 伤害判定
HealthComponent targetHealth = hit.collider.GetComponent<HealthComponent>();
if (targetHealth != null) {
targetHealth.TakeDamage(damage);
}
}
// 播放开火动画、音效、后坐力...
}
对于需要物理弹道下坠的武器(如狙击枪在极远距离),可以采用
物理模拟
,即生成一个
Rigidbody
的子弹预制件并给它一个初速度。但这会带来更多的性能开销和网络同步复杂度(如果是多人游戏)。在这个Demo中,我统一使用了射线检测。
3.3 后坐力与屏幕抖动:提升射击反馈
后坐力是射击手感的灵魂。我将其分为两部分:
-
武器模型后坐力
:开火时,让武器模型在本地坐标系下快速后移(Recoil)并上抬(Kick),然后缓慢恢复原位。这可以通过在
Weapon脚本里操作transform.localPosition和transform.localRotation,配合动画或Spring算法实现。 - 摄像机后坐力 :开火时,给摄像机一个轻微的随机旋转偏移(上下和左右),然后缓慢回中。这个偏移模式可以配置成固定的(每次上抬固定值)或随机的(在一个锥形范围内),后者手感更自然但更难控制。
屏幕抖动(Screen Shake)
是另一个重要反馈。当发射大口径武器、爆炸发生时,可以给摄像机一个短暂的、衰减的Perlin噪声位移和旋转,极大地增强冲击力。我通常会写一个全局的
ScreenShakeManager
单例,任何需要抖动的地方调用它的公共方法即可。
注意事项: 后坐力和屏幕抖动的所有参数(力度、恢复速度、随机范围)都必须可配置,并且最好做成曲线(AnimationCurve)来控制其随时间的变化。在调试时,频繁开火,感受后坐力模式是否令人满意、是否过于疲劳。一个好的后坐力应该是“有反馈但不失控”,帮助玩家感受武器威力,而不是纯粹地惩罚玩家。
4. 敌人AI:从寻路到行为逻辑
4.1 使用Unity NavMesh实现智能寻路
Unity内置的NavMesh导航系统对于中小型项目的AI寻路来说已经足够强大。实现步骤如下:
-
烘焙导航网格
:在场景中,将地面、台阶等可行走区域标记为
Navigation Static,然后在Window > AI > Navigation窗口中烘焙。烘焙时要注意Agent Radius(角色半径)、Max Slope(最大坡度)等参数的设置,这决定了AI能走到哪里。 -
AI控制器
:为敌人预制件添加
NavMeshAgent组件。这个组件会自动处理路径寻找和移动。 -
目标设置
:在敌人的AI逻辑脚本(如
EnemyAI)中,通过NavMeshAgent.SetDestination()方法来设置目标点(通常是玩家的当前位置)。
public class EnemyAI : MonoBehaviour {
private NavMeshAgent agent;
private Transform playerTarget;
void Start() {
agent = GetComponent<NavMeshAgent>();
playerTarget = GameObject.FindGameObjectWithTag(“Player”).transform; // 建议用更高效的方式获取
}
void Update() {
if (playerTarget != null) {
// 简单追逐:持续设置目标为玩家位置
agent.SetDestination(playerTarget.position);
}
}
}
为了性能,我不会每帧都设置目标。通常我会设置一个更新频率(比如每秒2-4次),或者只在玩家移动超过一定距离后才重新计算路径。
4.2 有限状态机(FSM)驱动AI行为
和玩家角色一样,敌人AI也适合用状态机。一个典型的敌人AI状态机包含:
- 巡逻(Patrol) :在预设的路径点之间移动。当进入玩家警戒范围时,切换到追击状态。
- 追击(Chase) :使用NavMeshAgent追逐玩家。当进入攻击范围且视野内无遮挡时,切换到攻击状态。当玩家脱离警戒范围一段时间后,返回巡逻状态。
-
攻击(Attack)
:停止移动,播放攻击动画(如举枪射击),并调用伤害逻辑。攻击间隔由武器的
fireRate控制。攻击一段时间后,若玩家脱离攻击范围,则切回追击状态。 - 受伤(Hurt) :被击中时播放受击动画,可能伴有短暂的硬直,然后根据剩余生命值决定返回战斗或逃跑。
- 死亡(Death) :播放死亡动画,禁用NavMeshAgent和碰撞体,一段时间后销毁或回收对象。
每个状态都是一个独立的脚本(如
PatrolState
、
ChaseState
),它们继承自一个
IState
接口,实现
OnEnter
、
OnUpdate
、
OnExit
方法。一个
StateMachine
类负责管理和切换这些状态。
4.3 视觉与听觉感知系统
敌人不能“全图透视”,需要感知系统。
-
视觉感知
:通常用锥形的射线检测(Physics.SphereCast或OverlapSphere + 角度判断)来实现。在
Update中,以敌人眼睛位置为起点,向玩家方向发射射线,如果射线命中玩家且中间无遮挡物,则判定为“看到”玩家。锥形的角度和距离决定了敌人的视野。 -
听觉感知
:当玩家开枪、跑步或制造其他噪音时,会在声源位置生成一个“声音事件”。敌人身上有一个
HearingSensor组件,它会定期检查周围一定半径内是否存在声音事件。如果存在,敌人可能会将最后一个听到声音的位置设为可疑目标,并前往调查。
感知系统的参数(视野角、视野距离、听力范围)都应该可配置,并且可以通过Unity的Gizmos在Scene视图中可视化绘制出来,这对于调试AI行为至关重要。
AI调试技巧: 给每个AI状态设计一个明显的视觉反馈。比如,敌人在巡逻时头顶显示一个“?”,追击时显示“!”,攻击时显示“骷髅头”。这能让你在游戏运行时一眼就看出AI当前处于什么状态,快速定位逻辑问题。同时,充分利用
Debug.DrawRay和Debug.DrawLine在Scene视图绘制出AI的视线、路径和感知范围。
5. 动画系统与角色表现
5.1 使用Animator Controller构建复杂的角色动画
Unity的Animator是处理角色动画的核心。我为玩家和敌人都建立了相对复杂的Animator Controller。
-
玩家动画
:包含基础移动(Idle, Walk, Run)、瞄准移动(Aim Idle, Aim Walk)、射击、换弹、受伤、死亡等状态。通过参数(
Speed,IsAiming,IsGrounded,VerticalVelocity等)来控制状态转换。 这里的关键是使用混合树(Blend Tree) 来平滑处理从静止到奔跑的速度过渡,以及不同方向(前、后、左、右)的移动动画融合。 -
动画层(Layers)
:这是实现上半身独立动作的关键。我创建了两个层:
- Base Layer (权重1.0) :控制下半身和全身的基础移动、跳跃、落地等动画。
- Upper Body Layer (权重1.0, 遮罩为上半身骨骼) :专门控制瞄准、射击、换弹等上半身动作。通过调整该层的权重,可以实现从“腰射”到“机瞄”时上半身动作的混合。
在代码中,通过
Animator.SetFloat(“Speed”, currentSpeed)
、
Animator.SetBool(“IsAiming”, isAiming)
来驱动这些状态变化。
5.2 动画事件与代码联动
动画本身不能完成所有事情,比如在播放射击动画的某一帧精确生成子弹,或在换弹动画结束时补充弹药。这就需要
动画事件(Animation Events)
。
在Animation窗口,可以在动画剪辑的特定帧上添加事件。在事件上指定一个该GameObject上脚本的公有方法名。例如,在“Fire”动画的第5帧添加一个名为
OnFireAnimationEvent
的事件。在对应的脚本里实现这个方法,在里面执行生成射线、播放枪口火焰特效等逻辑。
// 挂在玩家或武器上的脚本
public void OnFireAnimationEvent() {
if (currentWeapon != null) {
currentWeapon.ProcessFire(); // 实际执行开火逻辑
}
}
这种方式确保了视觉(动画)和逻辑(伤害计算)的精确同步。
5.3 根运动与程序化移动的取舍
对于TPS,我通常
禁用根运动(Root Motion)
。因为根运动是由动画本身驱动角色位移,这很难与基于物理或NavMesh的程序化移动精确配合,容易导致滑步或控制失灵。我们的移动完全由
MovementComponent
根据输入和速度计算得出,动画只负责表现。
在Animator组件上,确保
Apply Root Motion
复选框未被勾选。所有位移通过代码控制
CharacterController
或
Rigidbody
来实现,动画使用“原地(In Place)”版本的剪辑。
6. 性能优化与项目构建
6.1 对象池:管理子弹、特效与敌人
频繁实例化(Instantiate)和销毁(Destroy)物体是性能杀手。对于子弹命中特效、弹壳、甚至敌人,都必须使用
对象池(Object Pooling)
。
我实现了一个通用的
SimpleObjectPool
类。它预加载一定数量的对象(如子弹特效预制件)到一个队列(Queue)中。当需要生成特效时,从池中取出一个并激活它;当特效播放完毕,不是Destroy它,而是将其失活并放回池中。
public class SimpleObjectPool : MonoBehaviour {
public GameObject prefab;
public int initialSize = 10;
private Queue<GameObject> pool = new Queue<GameObject>();
void Start() {
for (int i = 0; i < initialSize; i++) {
GameObject obj = Instantiate(prefab);
obj.SetActive(false);
pool.Enqueue(obj);
}
}
public GameObject Get() {
if (pool.Count > 0) {
GameObject obj = pool.Dequeue();
obj.SetActive(true);
return obj;
} else {
// 池空了,动态创建一个(或选择不创建)
return Instantiate(prefab);
}
}
public void Return(GameObject obj) {
obj.SetActive(false);
pool.Enqueue(obj);
}
}
对于敌人,可以使用更复杂的池,在敌人死亡时将其重置(恢复生命值、清除状态)并放回池中,而不是销毁,下一波敌人出现时直接从池中取出复用。
6.2 渲染与Draw Call优化
对于移动端或低配PC目标,渲染优化必不可少。
-
静态合批(Static Batching)
:将场景中不会移动的静态物体(如建筑、地形)标记为
Static,Unity会在构建时自动将它们合并,减少Draw Call。注意,这可能会增加内存占用和构建时间。 - 动态合批(Dynamic Batching) :Unity会自动尝试合批小型、简单的动态网格。确保使用相同材质的物体满足合批条件(顶点数少于300等)。
- 纹理图集(Texture Atlas) :将多个小纹理合并成一张大图,让多个物体共享同一个材质球,这是减少Draw Call最有效的手段之一。可以使用Sprite Packer(对于UI/2D)或第三方工具(如TexturePacker)来制作图集。
- LOD(Level of Detail) :为复杂的模型(如主要建筑、复杂敌人)制作多个细节层次的模型。距离摄像机远的物体使用面数少的模型。Unity的LOD Group组件可以方便地管理这个。
- 遮挡剔除(Occlusion Culling) :烘焙场景的遮挡数据,当物体被其他物体完全挡住时,Unity不会渲染它。这对于室内或结构复杂的场景提升巨大。
6.3 构建与发布设置要点
当项目完成后,在Build Settings中需要注意:
- 场景列表 :确保所有需要打包的场景都按顺序添加进来了。
- 目标平台 :根据需求选择PC(Windows/macOS/Linux)、WebGL或移动端(Android/iOS)。切换平台后,Texture、Model等资源的导入设置可能需要重新调整。
-
Player Settings
:
- Company和Product Name :务必填写。
- 分辨率与展示 :设置默认分辨率,是否全屏,以及窗口模式。
- 图标 :设置各平台所需的图标。
-
脚本后端
:对于较新版本的Unity,
.NET Standard / .NET Framework
的选择很重要。如果使用了较新的C#特性,可能需要选择
.NET Standard 2.1或.NET Framework。 IL2CPP 通常能提供更好的性能和安全性,是发布版本的推荐选择。 -
代码优化
:发布时选择
Master模式,启用代码裁剪(Stripping),但要注意这可能会误剪掉通过反射调用的代码,需要添加link.xml文件来保护必要的代码。
-
WebGL特别注意事项
:如果发布WebGL,内存管理是关键。在Player Settings > Publishing Settings中,可以调整
WebGL Memory Size。如果游戏内容较多,可能需要增大这个值(如512MB),否则可能在加载时因内存不足而崩溃。另外,WebGL的初始化时间(即你提到的“unity webgl初始化很久”)与游戏体积和复杂度正相关,优化资源大小、使用AssetBundle异步加载是主要优化方向。
7. 开发中遇到的典型问题与解决方案
7.1 角色移动“滑冰”或抖动
问题描述 :角色移动起来感觉像是在冰面上滑动,或者移动时有细微抖动。
- 原因1:在Update中直接修改Transform.position 。这忽略了物理引擎的插值计算,可能导致抖动。
-
解决方案
:对于需要物理交互的角色,使用
CharacterController或Rigidbody来控制移动。使用CharacterController.Move()或Rigidbody.MovePosition()。确保移动计算放在FixedUpdate中,而不是Update中,以保持与物理更新的同步。 -
原因2:动画速度与移动速度不匹配
。
Animator中的Speed参数没有正确根据实际移动速度设置。 -
解决方案
:确保在每帧将计算出的实际速度(通常是
Velocity.magnitude)传递给Animator。如果使用了NavMeshAgent,可以使用agent.velocity.magnitude。
7.2 射击射线检测不准(从枪口而非屏幕中心)
问题描述 :子弹看起来是从枪口射出,但实际命中点却很奇怪,或者感觉没打中该打中的地方。
- 原因 :为了真实感,我们可能希望从枪口发射射线。但第三人称摄像机与角色模型是分离的,从枪口发射的射线,其方向可能并没有指向屏幕中心准星所指的世界坐标。
- 解决方案 :对于需要精确命中反馈的射击游戏(尤其是带准星的), 射线必须从摄像机中心发射 。这是行业通用做法。你可以同时从枪口发射一个视觉上的子弹轨迹特效,但伤害判定一定要用摄像机射线。这样能保证“所见即所得”,避免玩家因视角差而产生的挫败感。
7.3 敌人AI“卡住”或行为异常
问题描述 :敌人有时会在某个角落卡住不动,或者对着墙壁不停射击。
- 原因1:导航网格(NavMesh)烘焙不完整或有缝隙 。Agent走到了未烘焙或不可行走的区域。
-
解决方案
:在Scene视图的Navigation显示模式下,仔细检查蓝色的导航网格是否覆盖了所有你想让AI行走的区域。确保静态障碍物都正确标记为
Navigation Static。可以适当增大烘焙参数中的Agent Radius或降低Step Height,让网格更保守。 - 原因2:状态转换条件有重叠或漏洞 。例如,追击状态和攻击状态的转换距离阈值设置得太接近,导致AI在两个状态间高频切换。
-
解决方案
:为状态转换引入“滞后”或“缓冲”。例如,从追击进入攻击的距离是10米,但从攻击退回追击的距离可以设为12米。这样可以避免在边界处反复横跳。使用
Debug.Log输出AI的当前状态和关键变量(如与玩家的距离),是调试状态机逻辑的最直接方法。
7.4 动画过渡生硬或“跳帧”
问题描述 :从一个动画切换到另一个时,过渡不自然,或者角色姿态突然“跳”一下。
-
原因1:动画过渡没有设置合适的融合时间
。在Animator Controller中,状态之间的箭头(过渡)可以设置
Transition Duration(融合时间)和Transition Offset。 -
解决方案
:为大多数移动相关的过渡设置一个短暂的融合时间(如0.1-0.2秒)。利用
Has Exit Time选项,对于需要即时响应的动作(如受击),取消勾选此选项,让动画可以立即中断当前状态进入新状态。 - 原因2:动画剪辑的起始帧和结束帧姿态不一致 。例如,Idle动画的结束姿态和Walk动画的起始姿态不同,即使有融合,也会出现滑动。
-
解决方案
:在3D建模或动画软件中,确保循环动画(Idle, Walk, Run)的首尾帧完全一致。在Unity中导入动画时,可以尝试开启
Loop Time并检查Cycle Offset,确保循环平滑。
这个TPS Demo项目虽然不大,但几乎触及了一个商业TPS游戏所有最核心的系统模块。从架构设计到具体实现,从手感调校到性能优化,每一步都需要仔细权衡和反复打磨。我个人最深的体会是, 前期花在架构设计和原型验证上的时间,后期会加倍地省回来 。一个清晰的状态机和组件化设计,能让后续添加新武器、新敌人、新技能变得异常轻松。最后,不要闭门造车,多找朋友来试玩,他们的第一手反馈对于调整手感、难度和发现致命Bug来说,是无价的。

342

被折叠的 条评论
为什么被折叠?



