抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

银河战士like开发日志

终于把毕设忙完了,开始填坑了,首先就是这个银河战士like的游戏,说实话我已经很长时间没做过Gameplay开发了,上来就做这种高难度的动作射击,感觉有些蠢。不过多亏了Mix and Jam开源了一个教程,少走不少弯路。为了自我监督,把开发日志记录在案。说实话我GP水平很差,这个日志应该会踩不少坑

Unity 2021.3.22 URP Windows11,使用内置的Plastic SCM做版本管理(Unity对Plastic SCM支持较好,尽管我更喜欢Git和P4V)

1. 添加角色模型

使用Package Manager添加插件Input System,Player Input组件和Input Action文件均属于该插件

Mixamo找一个人物模型,放入场景中

添加组件Character Controller(该组件会自带一个胶囊碰撞体,可以调整尺寸以适配角色)

添加组件Player Input

添加一个脚本PlayerController.cs

mixamo

characterController

2. 配置Input Action文件

创建一个Input Action,添加一个Vector2类型的移动,一个跳跃按钮

inputAction

将该文件拖入Player Input组件的Actions上

3. 编写移动脚本

public class Player : MonoBehaviour
{
private PlayerInput input;
private CharacterController controller;

[Header("Movement Settings")]
[SerializeField, Tooltip("水平移动速度")]
private float movementSpeed = 6f;

private Vector2 moveInput;

private void Start()
{
// 获取角色身上的组件
input = GetComponent<PlayerInput>();
controller = GetComponent<CharacterController>();
}

private void Update()
{
// 读按键输入
moveInput = input.actions["Move"].ReadValue<Vector2>();
// 若输入不为空,则使用CharacterController进行移动
bool isMoving = Mathf.Abs(moveInput.normalized.x) > 0.01f;
if (isMoving)
{
float hDir = moveInput.x > 0 ? 1 : -1;
controller.Move(Vector3.right * hDir * Mathf.Abs(moveInput.normalized.x * movementSpeed) * Time.deltaTime);
}
}

}

进入游戏后按AD键可以实现左右移动(注意坐标系,Unity的2D游戏通常会以x轴正方向为右,y轴正方向为上)

4. 移动动画

去mixamo找几段动画(动画存放在fbx文件中,将fbx导入unity后可以将动画片段抽离、切分),并创建一个Animation Controller

动画

双击打开Animation Controller后,右键创建一个BlendTree,将该BlendTree与Entry相连,并修改BlendTree的参数名为MoveInput

animator

双击进入BlendTree,按右键添加多个Motion,并调整他们的Threshold信息

blendtree

在角色上添加Animator组件,并将上面创建的Animation Controller拖到上面

人物动画

修改PlayerController.cs脚本,根据输入设置动画参数,并处理转向

private PlayerInput input;
private CharacterController controller;
+private Animator animator;

private void Start()
{
input = GetComponent<PlayerInput>();
controller = GetComponent<CharacterController>();
+ animator = GetComponent<Animator>();
}

private void Update()
{
moveInput = input.actions["Move"].ReadValue<Vector2>();

bool isMoving = Mathf.Abs(moveInput.normalized.x) > 0.01f;
if (isMoving)
{
float hDir = moveInput.x > 0 ? 1 : -1;
controller.Move(Vector3.right * hDir * Mathf.Abs(moveInput.normalized.x * movementSpeed) * Time.deltaTime);
+ transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(moveInput.x != 0 ? Vector3.right * moveInput.normalized.x : Vector3.right * hDir), 0.1f);

}

+ animator.SetFloat("MoveInput", Mathf.Abs(moveInput.normalized.x * movementSpeed), 0.2f, Time.deltaTime);

}

效果如下,感觉运动停止时有点粘滞感,还需要细调

左右移动

5. 跳跃

跳跃是该游戏的核心,这只是我估摸着做的第一版,有一说一,手感稀烂

碰撞状态检测

创建脚本CollisionState.cs,并绑定到角色身上。该脚本用于判断角色和场景的关系,目前只做了和水平地表的检测

nearGround的作用是为了连跳

public class CollisionState : MonoBehaviour
{
[SerializeField, Tooltip("地表的碰撞层")] private LayerMask groundLayer;
[SerializeField, Tooltip("在地表上的检测距离")] private float testDistance = 0f;
[SerializeField, Tooltip("靠近地表的检测距离")] private float nearDistance = 0.2f;

public bool onGround;
public bool nearGround;

private float offset = 0.05f;

private void Update()
{
onGround = Physics.Raycast(transform.position + Vector3.up * offset, Vector3.down, testDistance + offset, groundLayer);
nearGround = Physics.Raycast(transform.position + Vector3.up * offset, Vector3.down, nearDistance + offset, groundLayer);
}
}

环境设置

添加Ground碰撞层,并将场景物体设置为Ground(场景物体要有碰撞体)

GroundLayer

注意设置角色身上的Ground Layer类型

GroundLayer2

角色跳跃

下面修改PlayerController.cs,添加跳跃逻辑

首先获取CollisionState脚本,以判断是否能进行跳跃

...
private Animator animator;
+private CollisionState collisionState;

...
private void Start()
{
...
animator = GetComponent<Animator>();
+ collisionState = GetComponent<CollisionState>();
}

添加一些跳跃要用到的参数

[Header("Movement Settings")] 
[SerializeField, Tooltip("水平移动速度")]
private float movementSpeed = 6f;
+[SerializeField, Tooltip("重力")]
+private float gravity = -9.8f;
+[SerializeField, Tooltip("大跳的跳跃高度")]
+private float jumpHeight = 5f;
+[SerializeField, Tooltip("开始加速下落时的速度")]
+private float fallingSpeedup = 10f;

private Vector2 moveInput;
+private bool jumpPressed = false;
+private bool jumpPressDown = false;
+private float verticalVelocity;
+private bool moveAfterJump = false; // 移动中跳跃,还是跳跃后移动
+private float herizontalDirection = 1f;
+private bool keepJump = false; // 保持滞空

跳跃,包含起跳、滞空、落地三个过程。

  • nearGround比真实地表要高一点,于是可以在接近落地时跳跃,优化连跳手感

  • 若按下跳跃键后不放,重力会比直接松开小,因而跳的更高,拥有更长的滞空时间。此外若向下掉落的速度小于fallingSpeedup,仍受较小的重力。

  • 当向下落地时遇到地面,速度为0

private void Update()
{
moveInput = input.actions["Move"].ReadValue<Vector2>();
+ jumpPressDown = input.actions["Jump"].WasPressedThisFrame();
+ jumpPressed = input.actions["Jump"].IsPressed();

+ #region 跳跃

+ // 起跳
+ if (jumpPressDown && collisionState.nearGround)
+ {
+ verticalVelocity = Mathf.Sqrt(2 * Mathf.Abs(gravity) * jumpHeight);
+ moveAfterJump = Mathf.Abs(moveInput.normalized.x) < 0.3f; // 判断起跳时移动速度
+ keepJump = true;
+ }

+ // 滞空
+ if (keepJump && verticalVelocity > -fallingSpeedup)
+ {
+ verticalVelocity += gravity * Time.deltaTime;
+ }
+ else if(!collisionState.onGround)
+ {
+ verticalVelocity += 3 * gravity * Time.deltaTime;
+ }

+ if (!jumpPressed)
+ {
+ keepJump = false; // 松开后不能重新滞空
+ }

+ // 落地
+ if (verticalVelocity < 0 && collisionState.onGround)
+ {
+ verticalVelocity = 0;
+ moveAfterJump = false;
+ keepJump = false;
+ }

+ // 运动
+ controller.Move(Vector3.up * verticalVelocity * Time.deltaTime);


+ #endregion
...
}

为了更真实,我希望角色原地起跳时更难左右移动,于是在这种情况下水平移动速度减半

+    #region 水平移动

bool isMoving = Mathf.Abs(moveInput.normalized.x) > 0.01f;
if (isMoving)
{
+ float herizontalSpeed = Mathf.Abs(moveInput.normalized.x * movementSpeed);

float hDir = moveInput.x > 0 ? 1 : -1;
+ if (moveAfterJump)
+ {
+ herizontalSpeed /= 2; // 原地起跳,在空中动能很少,按理说应该走不动
+ }
+ controller.Move(Vector3.right * hDir * herizontalSpeed * Time.deltaTime);
transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(moveInput.x != 0 ? Vector3.right * moveInput.normalized.x : Vector3.right * hDir), 0.1f);

}
animator.SetFloat("MoveInput", Mathf.Abs(moveInput.normalized.x * movementSpeed), 0.2f, Time.deltaTime);
+ #endregion

跳跃第一版

6. 跳跃动画

动画状态机比我想象中要麻烦好多。。。我也不知道我这样做对不对。。。

由于没找到合适的动画资源,这一节不少动画是我自己做的,花了相当长时间,结果效果也不太行

按下图所示在Any State后连接跳跃状态,并添加Jump、FallLand、FastJump、FastFallLand四个动画Trigger

Animator2

当开始跳跃时会进入JumpStart/RollStart(仅播放一次),播放完后进入滞空动画Jumping/Rolling(这段动画会循环播放),当落地时会进入JumpEnd/RollEnd动画

// 起跳
if (jumpPressDown && collisionState.nearGround)
{
verticalVelocity = Mathf.Sqrt(2 * Mathf.Abs(gravity) * jumpHeight);
moveAfterJump = Mathf.Abs(moveInput.normalized.x) < 0.3f; // 判断起跳时移动速度
keepJump = true;
herizontalDirection = moveInput.x > 0 ? 1 : -1;
+ if (moveAfterJump)
+ {
+ animator.SetTrigger("Jump");
+ }
+ else
+ {
+ animator.SetTrigger("FastJump");
+ }

}
...
// 落地
if (verticalVelocity < 0 && collisionState.onGround)
{
+ if (moveAfterJump)
+ {
+ animator.SetTrigger("FallLand");
+ }
+ else
+ {
+ animator.SetTrigger("FastFallLand");
+ }
verticalVelocity = 0;
moveAfterJump = false;
keepJump = false;
}

跳跃动画

7. 相机

使用插件Cinemachine,现在只做一个简易的版本

在Hierarchy面板按右键——Cinemachine——Virtual Camera

设置虚拟相机,让其Follow角色,并设置死区

cinemachine

cinemachine2

效果如下(额,这跳跃的边缘检测还是有问题)

相机

8. 爬墙

有的平台我们差一点点就能跳上去,我希望在这个边缘向左右拉动摇杆,可以实现爬墙的功能,暂时吸附在墙体上

在角色前添加一个空对象ClimbWallPoint(图中蓝色),在前上添加一个空对象PassablePoint(图中红色),这两个点将用于发射碰撞检测射线

爬墙点

修改CollisionState.cs脚本,添加爬墙检测逻辑

[SerializeField, Tooltip("靠近地表的检测距离")] private float nearDistance = 0.2f;
+[SerializeField, Tooltip("爬墙检测点")] private Transform climbWallPoint;
+[SerializeField, Tooltip("爬墙检测距离")] private float climbTestDistance = 0.2f;
+[SerializeField, Tooltip("可通行检测点")] private Transform passablePoint;
+[SerializeField, Tooltip("通行检测距离")] private float passableTestDistance = 1f;

private float offset = 0.05f;
+private float lookRight;

private void Update()
{
onGround = Physics.Raycast(transform.position + Vector3.up * offset, Vector3.down, testDistance + offset, groundLayer);
nearGround = Physics.Raycast(transform.position + Vector3.up * offset, Vector3.down, nearDistance + offset, groundLayer);
+ lookRight = (climbWallPoint.position.x - transform.position.x) > 0 ? 1 : -1;
+ climbWall = Physics.Raycast(climbWallPoint.position, Vector3.right * lookRight, climbTestDistance, groundLayer);
}

+public bool GetPassable()
+{
+ bool passable = !Physics.Raycast(passablePoint.position, Vector3.right * lookRight, passableTestDistance, groundLayer);
+ return passable;
+}

修改PlayerController.cs脚本,感觉角色状态越来越复杂,有些难以管理了,之后要做一个角色状态机

private bool keepJump = false;  // 保持滞空
+private bool jumping = false; // 处于跳跃状态
+private bool climbing = false; // TODO: 角色状态机

private void Update(){
...
// 起跳
if (jumpPressDown && collisionState.nearGround)
{
...
keepJump = true;
+ jumping = true;
...
}
...
if (!jumpPressed)
{
keepJump = false; // 松开后不能重新滞空
}

+ // 爬墙
+ if (jumping && collisionState.climbWall && collisionState.GetPassable() && Mathf.Abs(moveInput.normalized.x) > 0.7)
+ {
+ climbing = true;
+ verticalVelocity = 0;
+ }
+ else
+ {
+ climbing = false;
+ }

// 落地
if (verticalVelocity < 0 && collisionState.onGround)
{
...
keepJump = false;
+ jumping = false;
}

bool isMoving = Mathf.Abs(moveInput.normalized.x) > 0.01f;
+ if (isMoving && !climbing)
{
float herizontalSpeed = Mathf.Abs(moveInput.normalized.x * movementSpeed);
...
}
}

效果如下(论动画的重要性)

爬墙

9. 抓墙动画

我继续沿用老办法做抓墙动画,结果却发现手很难抓住墙壁,于是我改用Animation Rigging的方式实现

抓墙第一版

骨骼的本质是矩阵树,运行时变化矩阵从根节点开始正向改变网格顶点(蒙皮),变化矩阵包含位移旋转缩放信息,Animation Rigging可以让部分骨骼不受动画影响,而是被反向动力学等方式驱动

安装插件

首先通过包管理器安装Animation Rigging,我们发现窗口上方出现了Animation Rigging栏

配置Rig

找到并选中角色后,点击Animation Rigging——Bone Renderer Setup,我们发现角色身上多了一个Bone Renderer组件,骨骼也被绘制出来

boneRender boneRender2

选中角色后点击Animation Rigging——Rig Setup,发现角色出现了一个子物体:Rig1

将Rig重命名为RigHand,并创建子节点RightHand,为RightHand添加组件Two Bone IK Constraint

再RightHand下面创建空的子节点,名叫Target,为了容易观察,让其拥有球形Shape

Target

找到骨骼树中想要被反向驱动的三根骨骼,拖至Two Bone IK Constraint组件上,并将Target也拖到该组件上。现在播放动画,我们发现右手不会被动画驱动,而是被Target反向驱动

rig

通常状态不需要Rig,于是我们将Rig的权重设为0

脚本设置手部落地

修改脚本CollisionState.cs,添加手与墙壁的相交位置

public bool GetPassable()
{
bool passable = !Physics.Raycast(passablePoint.position, Vector3.right * lookRight, passableTestDistance, groundLayer);
return passable;
}

+public Vector3 GetClimbTouchPosition()
+{
+ Ray ray = new Ray(climbWallPoint.position, Vector3.right * lookRight);
+ Physics.Raycast(ray, out RaycastHit hit);
+ return hit.point;
+}

修改脚本PlayerController.cs,实现贴墙时修改Target的位置和Rig权重

[SerializeField, Tooltip("开始加速下落时的速度")] 
private float fallingSpeedup = 10f;

+[SerializeField] private Transform rightHandPosition;
+[SerializeField] private Rig rightHandRig;

private Vector2 moveInput;
...
private void Update()
{
...
// 爬墙
if (jumping && collisionState.climbWall && collisionState.GetPassable() && Mathf.Abs(moveInput.normalized.x) > 0.7)
{
climbing = true;
verticalVelocity = 0;
+ rightHandPosition.position = collisionState.GetClimbTouchPosition();
+ rightHandPosition.rotation = Quaternion.LookRotation(moveInput.x > 0 ? Vector3.right : Vector3.left);
+ rightHandRig.weight = 1;
}
else
{
climbing = false;
+ rightHandRig.weight = 0;
}
...
}

抓墙第二版

尽管还存在臂力惊人单手悬空的问题,但相比上一版已经好了不少

10. 上平台

抓墙的目的自然是为了上平台,修改PlayerController.cs脚本

[SerializeField, Tooltip("开始加速下落时的速度")] 
private float fallingSpeedup = 10f;
+[SerializeField, Tooltip("爬墙时向上跳的初速度")]
+private float climbingJumpSpeed = 10f;
...
private bool climbing = false; // TODO: 角色状态机
+private bool climbingJump = false; // 抓墙时跳跃
private void Update()
{
...
// 起跳
if (jumpPressDown && collisionState.nearGround)
{
...
jumping = true;
+ climbingJump = false;
if (moveAfterJump)
{
animator.SetTrigger("Jump");
}
...
}
// 爬墙
+ if (jumping && collisionState.climbWall && collisionState.GetPassable() && Mathf.Abs(moveInput.normalized.x) > 0.7 && !climbingJump)
{
climbing = true;
verticalVelocity = 0;
rightHandPosition.position = collisionState.GetClimbTouchPosition();
rightHandPosition.rotation = Quaternion.LookRotation(moveInput.x > 0 ? Vector3.right : Vector3.left);
rightHandRig.weight = 1;
+ animator.SetBool("Climbing", climbing);
+ if (jumpPressDown)
+ {
+ rightHandRig.weight = 0;
+ verticalVelocity = climbingJumpSpeed;
+ animator.SetTrigger("ClimbingUp");
+ climbingJump = true;
+ climbing = false;
+ }
}
else
{
climbing = false;
rightHandRig.weight = 0;
+ animator.SetBool("Climbing", climbing);
}
// 落地
if (verticalVelocity < 0 && collisionState.onGround)
{
...
jumping = false;
+ climbingJump = false;
}
...

感觉状态已经麻烦到难以管理了,是时候重构状态机了

爬墙上平台

11. 瞄准射击

建模

首先打开blender

  1. 创建一个圆柱1,按tab进入编辑模式,选中上表面,按s将上表面进行缩放,变成一个类似“圆锥”的东西
  2. 在圆锥上侧创建一个球,下侧创建一个圆柱2
  3. 使用布尔操作,球布尔Difference,圆柱2布尔Union(注意操作后隐藏球和圆柱)

布尔建模2

于是得到了枪的建模(相当粗糙)

布尔建模

你可以为模型添加材质

模型材质

将模型导出至Unity后

Gun

持枪

将枪放在左臂骨骼处,于是实现了左手和枪的绑死,播放动画时枪也跟着动,不过做的有些穿模

持枪2

枪能随着手部正常运动

持枪动画

瞄准

  1. 动画状态机添加瞄准动画
  2. Input System绑定左肩键为Aiming
InputBumping
  1. 为持枪的手臂添加Rig
枪rig
  1. 修改脚本PlayerController.cs,按下左肩键后手臂水平前举,根据左摇杆输入以角色左肩膀为圆心画圆,此外我希望瞄准时角色不要移动
+[SerializeField] private Transform gunHandPosition;
+[SerializeField] private Rig gunHandRig;
+[SerializeField] private Transform gunAimCenter;
+[SerializeField, Tooltip("枪距离角色肩膀的距离")] private float gunToShoulderDistance = 2f;

...
private bool jumpPressDown = false;
+private bool aiming = false;
...
private void Update()
{
...
jumpPressed = input.actions["Jump"].IsPressed();
+ aiming = input.actions["Aim"].IsPressed();
...
#region 水平移动

bool isMoving = Mathf.Abs(moveInput.normalized.x) > 0.01f;
if (isMoving && !climbing)
{
float herizontalSpeed = Mathf.Abs(moveInput.normalized.x * movementSpeed);

float hDir = moveInput.x > 0 ? 1 : -1;
if (moveAfterJump)
{
herizontalSpeed /= 2; // 原地起跳,在空中动能很少,按理说应该走不动
}
+ if (!aiming)
+ {
controller.Move(Vector3.right * hDir * herizontalSpeed * Time.deltaTime);
+ }
transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(moveInput.x != 0 ? Vector3.right * moveInput.normalized.x : Vector3.right * hDir), 0.1f);

}
animator.SetFloat("MoveInput", Mathf.Abs(moveInput.normalized.x * movementSpeed), 0.2f, Time.deltaTime);
#endregion

+ #region 瞄准射击

+ if (aiming)
+ {
+ Vector3 targetPos;
+ if (moveInput.magnitude < 0.1f)
+ {
+ float hDir = controller.transform.forward.x > 0 ? 1 : -1;
+ targetPos = new Vector3(gunAimCenter.position.x + gunToShoulderDistance * hDir, gunAimCenter.position.y, gunAimCenter.position.z);
+ gunHandPosition.position = targetPos;
+ gunHandPosition.rotation = Quaternion.LookRotation(new Vector3(1,-1,0));
+ }
+ else
+ {
+ float hDir = controller.transform.forward.x > 0 ? 1 : -1;
+ targetPos = new Vector3(gunAimCenter.position.x + gunToShoulderDistance * moveInput.normalized.x, gunAimCenter.position.y + gunToShoulderDistance * moveInput.normalized.y, gunAimCenter.position.z);
+ gunHandPosition.position = targetPos;
+ gunHandPosition.rotation = Quaternion.LookRotation(targetPos - gunAimCenter.position);
+ gunHandPosition.Rotate(90f, 0f, 0f);
+ animator.SetBool("Aiming", true);
+ }
+ }
+ else
+ {
+ gunHandRig.weight = 0;
+ animator.SetBool("Aiming", false);
+ }

+ #endregion
}

瞄准动画

稍微有些生硬,不过另一只手之后要拿近战武器,所以还好。

瞄准辅助线

在角色身上添加LineRenderer组件,设置组件的线Width、材质

线渲染器

修改脚本PlayerController.cs

private CollisionState collisionState;
+private LineRenderer lineRenderer;
...
private void Start()
{
...
collisionState = GetComponent<CollisionState>();
+ lineRenderer = GetComponent<LineRenderer>();
}
private void Update()
{
if (aiming)
{
Vector3 targetPos;
+ Vector3 deltaPos;
if (moveInput.magnitude < 0.1f)
{
float hDir = controller.transform.forward.x > 0 ? 1 : -1;
+ deltaPos = new Vector3(gunToShoulderDistance * hDir, 0, 0);
+ targetPos = gunAimCenter.position + deltaPos;
gunHandPosition.position = targetPos;
gunHandPosition.rotation = Quaternion.LookRotation(new Vector3(1,-1,0));
}
else
{
float hDir = controller.transform.forward.x > 0 ? 1 : -1;
+ deltaPos = new Vector3(gunToShoulderDistance * moveInput.normalized.x, gunToShoulderDistance * moveInput.normalized.y, 0);
+ targetPos = gunAimCenter.position + deltaPos;
gunHandPosition.position = targetPos;
gunHandPosition.rotation = Quaternion.LookRotation(targetPos - gunAimCenter.position);
gunHandPosition.Rotate(90f, 0f, 0f);
}
+ lineRenderer.SetPositions(new []{targetPos, targetPos + deltaPos * 12});
+ lineRenderer.enabled = true;
animator.SetBool("Aiming", true);
gunHandRig.weight = 1;
}
else
{
+ lineRenderer.enabled = false;
gunHandRig.weight = 0;
animator.SetBool("Aiming", false);
}
}
激光

发射子弹

  1. Input System将手柄X键绑定为Fire开火键

  2. 使用Sphere创建子弹预制体,注意关闭阴影、重力,材质我选用了一个自发光的半透明材质,并为子弹添加了一个碰撞层Bullet,同时设置其Tag为Bullet,碰撞体采用Trigger

  3. 编写子弹脚本Bullet.cs,子弹在碰撞到物体时会被销毁,超过一段时间后也会被销毁(具体的碰撞逻辑之后再写)

public class Bullet : MonoBehaviour
{
[SerializeField, Tooltip("子弹销毁时间")] private float lifeTime = 5f;

private float remainingTime;
void Start()
{
remainingTime = lifeTime;
}

void Update()
{
remainingTime -= Time.deltaTime;
if (remainingTime <= 0)
{
Destroy(gameObject);
}
}

private void OnTriggerEnter(Collider other)
{
Destroy(gameObject);
}
}
  1. 编写子弹发射脚本FireManager.cs
public class FireManager : MonoBehaviour
{
[SerializeField, Tooltip("子弹速度")] private float bulletSpeed = 10f;
[SerializeField, Tooltip("普通子弹")] private GameObject commonBullet;

public void FireCommonBullet(Vector3 initPosition, Quaternion initRotation)
{
GameObject bullet = Instantiate(commonBullet, initPosition, initRotation);
bullet.GetComponent<Rigidbody>().AddForce(bullet.transform.forward * bulletSpeed);
}
}
  1. 修改PlayerController.cs
private LineRenderer lineRenderer;
+private FireManager fireManager;
...
private bool aiming = false;
+private bool firePressDown = false;
...
private void Start()
{
...
lineRenderer = GetComponent<LineRenderer>();
+ fireManager = GetComponent<FireManager>();
}

private void Update()
{
...
aiming = input.actions["Aim"].IsPressed();
+ firePressDown = input.actions["Fire"].WasPressedThisFrame();
if (aiming)
{
#region 瞄准动作

Vector3 targetPos;
Vector3 deltaPos;
if (moveInput.magnitude < 0.1f)
{
...
}
else
{
...
}
...

#endregion

+ #region 射击
+ if (firePressDown)
+ {
+ fireManager.FireCommonBullet(targetPos, Quaternion.LookRotation(targetPos - gunAimCenter.position));
+ }
+ #endregion

}
else{
...
}
...
}

GIF的帧率太低了。。。

发射子弹

注意,现在角色跳跃是也能射击,爬墙时也能射击,但是抓墙时不能自由射击,因为反向后角色会掉下来。我在考虑是否在跳跃时射击添加时停,抓墙时添加按左肩键可以自由射击,不会掉下来。

银河战士抓墙后不需要一直按左摇杆,可以自由射击,但银河战士是可以变成一格高的球的,有些狭小的管道正常姿势无法射击,于是设计了抓墙射击。而我不打算做变成球,我所有的通道都至少两格宽,很纠结

12. 可被射击破环的方块

方块预制体

首先创建可被射击的方块预制体,拥有碰撞盒、VFX和一个脚本

可被射击方块

创建脚本InteractiveCube.cs,我希望当方块被子弹击中时会隐藏渲染和碰撞,并播放粒子效果,一段时间后再复原

public class InteractiveCube : MonoBehaviour
{
[SerializeField] private float recoverTime = 20f;

private MeshRenderer meshRenderer;
private Collider boxcollider;
private VisualEffect visualEffect;

private float currentDeadTime;

private void Start()
{
meshRenderer = GetComponent<MeshRenderer>();
boxcollider = GetComponent<Collider>();
visualEffect = GetComponent<VisualEffect>();
visualEffect.Stop();
}

private void OnTriggerEnter(Collision other)
{
if (other.gameObject.CompareTag("Bullet"))
{
meshRenderer.enabled = false;
boxcollider.enabled = false;
visualEffect.Play();
currentDeadTime = recoverTime;
}
}

private void Update()
{
if (currentDeadTime >= 0)
{
currentDeadTime -= Time.deltaTime;
if (currentDeadTime <= 0)
{
meshRenderer.enabled = true;
boxcollider.enabled = true;
}
}
}
}

特效

现在的特效是随便做的,之后要重写,我希望方块被射击会有破碎效果,复原时会有溶解效果

破碎效果使用Unity VFX插件

首先去Package Manager安装Visual Effect Graph

右键Visual Effect——Visual Effect Graph,创建一个特效,双击后进入面板,配置为单次发射(Single Burst)、设置发射方向、粒子生命周期、添加重力

粒子特效

有点绷不住了

射击方块

13. 近战攻击

近战攻击相当复杂,我问了几个动作策划,给了我好几种方法:

  • 在武器上绑定胶囊体碰撞盒
  • 在武器附近射线检测
  • 在打击区域绘制方形碰撞盒
  • 逐帧修改判定区域
  • 怪物读玩家指令
动作碰撞盒

在银河战士里,近战多用于反击远程单位(我的游戏也是这样设计的),感觉做成读指令会更方便。但我又想

Timeline

Timeline是Unity的时间线工具,常用于制作过场动画、相机蒙太奇,我们这里用来制作打击动作

打开Timeline面板

timeline

为角色创建Timeline(此时角色身上会添加PlayableDirector组件),将近战动画clip拖入时间线中,并设置动画的Ease In/Out,这样可以使得Timeline动画和动画状态机的动画混合

easeInOut

注意要关掉Timeline的Play On Awake

我们在角色面前创建一个方形碰撞区域(有着Box Collider和锁死旋转和位移的Rigidbody),添加Tag为Hit,并默认设为unactive。

创建碰撞区域 碰撞区域

在Timeline中创建Control Track,在片段期间该物体将设为活跃,结束后恢复隐藏

timeline动作

点击播放键可以预览效果

控制

修改脚本PlayerController.cs

private FireManager fireManager;
+private PlayableDirector director;
...
private bool firePressDown = false;
+private bool hitPressDown = false;
private void Start()
{
...
+ director = GetComponent<PlayableDirector>();
}
private void Update()
{
...
+ hitPressDown = input.actions["Hit"].WasPressedThisFrame();
...
+ if (hitPressDown && collisionState.onGround)
+ {
+ director.Play();
+ }
}

近战

14. 可被近战破坏的方块

修改脚本InteractiveCube.cs

+public enum CubeType
+{
+ Shoot,
+ Hit
+}
public class InteractibleCube : MonoBehaviour
{
[SerializeField] private float recoverTime = 20f;
+ [SerializeField] private CubeType cubeType;
...
+ private string TagName
+ {
+ get
+ {
+ if(cubeType == CubeType.Shoot)
+ return "Bullet";
+ else
+ return "Hit";
+ }
+ }
...
private void OnTriggerEnter(Collision other)
{
+ if (other.gameObject.CompareTag(TagName))
{
...
}
}
...
}

近战方块

15. 零美术成本更换角色模型

适合完全不会绑骨刷权重K帧的非美术,mmd的模型骨骼一般都十分复杂,并且和mixamo不一致

去模之屋等网站获取mmd模型,导入blender中(详情可见blender mmd tools),个人建议选择短发角色

将角色姿态设为T Pose(你可以进入姿态模式,用手掰,可以放一个水平盒子当辅助线),T Pose很重要!

导出仅含网格的fbx模型

TPose

打开mixamo,选择小红人,点击UPLOAD CHARACTOR(导入模型),按步骤完成

选择小红人

即可得到自动绑骨刷权重的角色模型,并能够完美使用mixamo的动画

mixamo导入模型

我这里除了更换角色模型外,还使用了卡渲Shader和后效,网上关于卡渲的项目和教程相当多,这里就按下不表了

更换角色模型

16. 地面敌人AI

使用插件Behavior Trees,Asset Store 45刀,参考空洞骑士BOSS战

敌人移动混用了DoTween插件和Charactor Controller

感觉这一节用文字讲好乏力。。。

创建角色单例

创建脚本PlayerInfo.cs,并将其绑定到Player上,该脚本控制了角色血量和是否处于无敌状态

public class PlayerInfo : MonoBehaviour
{
#region 单例

private static PlayerInfo instance;
// 私有构造函数
private PlayerInfo(){}

public float Health { get; private set; } = 100f;

#endregion

#region Prop

[SerializeField, Tooltip("处于无敌状态")]
private bool invincible = false;

[SerializeField, Tooltip("无敌时间")]
private float invincibleTime = 2f;

[SerializeField, Tooltip("无敌时闪烁间隔时间")]
private float blinkTime = 0.1f;

[SerializeField, Tooltip("角色渲染")]
private SkinnedMeshRenderer playerRenderer;

#endregion

#region Member

// 无敌还剩多长时间
private float currentInvincibleTime = 0f;
// 闪烁控制时间
private float currentBlinkTime = 0.1f;
// 闪烁控制
private bool blinkVisible = true;

#endregion


public static PlayerInfo Instance
{
get
{
if (instance == null)
{
instance = FindObjectOfType<PlayerInfo>();
}

return instance;
}
}

public Vector3 PlayerPosition
{
get
{
return transform.position;
}
}

public void HitPlayer(float damage, Vector3 enemyPosition)
{
if (invincible)
{
return;
}
Vector3 moveDirection = new Vector3(0, 1, 0);
if(enemyPosition.x > transform.position.x)
{
moveDirection.x = -1;
}
else
{
moveDirection.x = 1;
}
gameObject.GetComponent<CharacterController>().Move(moveDirection);
Health -= damage;
currentInvincibleTime = invincibleTime;
currentBlinkTime = blinkTime;
}

private void Update()
{
if(currentInvincibleTime > 0)
{
invincible = true;
currentInvincibleTime -= Time.deltaTime;
currentBlinkTime -= Time.deltaTime;
if(currentBlinkTime <= 0)
{
currentBlinkTime = blinkTime;
blinkVisible = !blinkVisible;
playerRenderer.enabled = blinkVisible;
}
}
else
{
invincible = false;
playerRenderer.enabled = true;
}
}
}

创建敌人

创建脚本EnemyInfo.cs,并挂到敌人身上,此外敌人身上同时挂在Character Controller、Animator、Behaviour(这个稍后再讲)、BoxCollider(这个碰撞盒是用于被攻击的)

添加碰撞层Enemy,并将敌人设为Enemy(在项目设置中,设置Enemy和玩家不发生碰撞)

敌人信息2 敌人信息
public class EnemyInfo : MonoBehaviour
{
public float maxHealth = 30.0f;
public float currentHealth;
public float findPlayerDistance = 10.0f;

private void Start()
{
currentHealth = maxHealth;
}

private void OnDrawGizmos()
{
Gizmos.color = Color.red;
Gizmos.DrawWireSphere(transform.position, findPlayerDistance);
}

private void OnTriggerEnter(Collider other)
{
if (other.tag == "Bullet")
{
currentHealth -= other.GetComponent<Bullet>().damage;
}
}
}

创建敌人攻击碰撞球

我们将敌人受攻击(Box)、敌人和场景的碰撞(CC)、敌人攻击玩家的碰撞(Sphere)分开了

为敌人创建一个子物体叫HitSphere,拥有一个球形碰撞盒和一个碰撞处理脚本,该碰撞球用于攻击玩家

添加碰撞层EnemyHit,该碰撞层仅和玩家碰撞

HitSphere

创建脚本EnemyHitCube.cs

public class EnemyHitCube : MonoBehaviour
{
private void OnTriggerEnter(Collider other)
{
var playerInfo = other.GetComponent<PlayerInfo>();
if (playerInfo != null)
{
playerInfo.HitPlayer(10, transform.position);
}
}
}

创建行为树

寻找敌人后打开Behaviour Designer面板,添加任意行为后敌人身上会出现一个Behaviour Tree组件

行为树

行为树是由一个个节点组成,当这些节点被执行时,会向其父节点返回执行成功、失败、正在执行等结果

  • Repeater表示循环执行(我开启了无限循环)

  • Selector是一个或组件,其子节点从左向右开始执行,只要有一个为成功,则返回成功

  • Sequence表示顺序执行,其子节点从左向右执行,直到一个节点返回失败

我为怪物添加了三个行为:

  1. 血量小于0时IsHealthUnder,播放死亡动画,并销毁GameObject
  2. 否则
    1. 若敌人周围有玩家IsFindPlayer,则追逐玩家EnemyPursuit
    2. 否则在周围徘徊EnemyMove

创建敌人行为和条件基类

创建脚本EnemyAction.cs

public class EnemyAction : Action
{
protected Animator animator;
protected EnemyInfo info;
protected CharacterController controller;

public override void OnAwake()
{
animator = GetComponent<Animator>();
info = GetComponent<EnemyInfo>();
controller = GetComponent<CharacterController>();
}
}

创建脚本EnemyConditional.cs

public class EnemyConditional: Conditional
{
protected EnemyInfo info;
protected Animator animator;

public override void OnAwake()
{
info = GetComponent<EnemyInfo>();
animator = GetComponent<Animator>();
}
}

创建角色移动、追击、死亡行为

创建脚本EnemyMove.cs,该脚本能使得敌人左右徘徊

public class EnemyMove : EnemyAction
{
public SharedBool lookRight;
public SharedFloat currentMoveTime;
public float moveSpeed = 1.0f;
public float moveUseTime = 2.0f;
public float rotateUseTime = 0.5f;

private Tween rotateTween;

public override void OnStart()
{
}

public override TaskStatus OnUpdate()
{
float direction = lookRight.Value ? 1 : -1;
currentMoveTime.Value += Time.deltaTime;
if (currentMoveTime.Value > moveUseTime)
{
lookRight.Value = !lookRight.Value;
currentMoveTime.Value = 0;
rotateTween = DOVirtual.DelayedCall(0, () => transform.DORotateQuaternion(Quaternion.LookRotation(Vector3.right * -direction), rotateUseTime), false);
}

controller.Move(Vector3.right * direction * moveSpeed * Time.deltaTime);
return TaskStatus.Success;
}
}

创建脚本EnemyPursuit.cs,该脚本能使得敌人向玩家追击

public class EnemyPursuit : EnemyAction
{
public float moveSpeed = 1.0f;
public float rotateUseTime = 0.5f;


public override TaskStatus OnUpdate()
{
float direction = PlayerInfo.Instance.PlayerPosition.x > transform.position.x ? 1 : -1;
DOVirtual.DelayedCall(0, () => transform.DORotateQuaternion(Quaternion.LookRotation(Vector3.right * direction), rotateUseTime), false);

controller.Move(Vector3.right * direction * moveSpeed * Time.deltaTime);
return TaskStatus.Success;
}
}

创建脚本EnemyDead.cs,该脚本能使敌人死亡

public class EnemyDead : EnemyAction
{
public float deadDelayTime = 1.0f;

private bool isDeadFinish = false;

public override void OnStart()
{
DOVirtual.DelayedCall(deadDelayTime, () =>
{
Object.Destroy(gameObject);
isDeadFinish = true;
});
}

public override TaskStatus OnUpdate()
{
return isDeadFinish ? TaskStatus.Success : TaskStatus.Running;
}
}

创建敌人行为条件

创建脚本IsFindPlayer.cs,该脚本用于判断敌人周围是否有角色,如果是刚刚发现,会出发一段动画

public class IsFindPlayer : EnemyConditional
{
public SharedBool firstFindPlayer = true;

public override TaskStatus OnUpdate()
{
if ((transform.position - PlayerInfo.Instance.PlayerPosition).magnitude < info.findPlayerDistance)
{
if (firstFindPlayer.Value)
{
animator.SetTrigger("FindPlayer");
}
firstFindPlayer.Value = false;
return TaskStatus.Success;
}
else
{
firstFindPlayer.Value = true;
return TaskStatus.Failure;
}
}
}

创建脚本IsHealthUnder.cs,该脚本用于判断角色血量是否低于某个数值

public class IsHealthUnder : EnemyConditional
{
public float underNumber = 0f;

public override TaskStatus OnUpdate()
{
if (info.currentHealth <= underNumber)
{
return TaskStatus.Success;
}
else
{
return TaskStatus.Failure;
}
}
}

效果

怪物AI

评论