目录
前言
本文讲解部分第三人称游戏的实现和演示。
一、机制探索
玩法实现-3C相关(移动、跑、跳跃、攻击和连招、防御、闪避) |
玩法实现-敌人AI(移动攻击等) |
玩法实现-攻击判定、生命值等 |
系统实现-背包和拾取/取出 |
商店系统和物品使用 |
关卡设计 |
二、基础的第三人称控制
使用官方的第三人称模块包,可以节省一部分功夫。
然后我们需要用其他模型代替原本的模型,我在网上找来一些免费模型,替换后如下:
由于该模块使用了一个过时的摄像头组件,为了后续开发将其替换,ThirdPersonCamera作为玩家的子组件负责第三人称视角:
做完这些,玩家可以在第三人称下进行移动,冲刺和跳跃。
Unity实验自制第三人称小游戏_哔哩哔哩_bilibili
三、更多控制-攻击和连招、防御、闪避
为了实现攻击和连击,防御、闪避的操作,在玩家对象下搭载下面的脚本(脚本比较多,是为了实现后续的功能):
PlayerAction.cs专门用来实现玩家的拓展操作,并结合动画机实现(代码都放在仓库了):
以防御举例,处理玩家输入执行对应的处理函数:
要结合动画机,使得玩家做出动作:
Unity实验自制第三人称小游戏_哔哩哔哩_bilibili
四、敌人AI(漫步,追逐、攻击)
敌人的模型可以找一些网上免费的预制体
EnemyMoving.cs负责漫步,追逐还有物理重力。
EnemyAttack.cs负责攻击。
先看动画机部分:
让我们看攻击部分,设计上敌人每隔一段时间就会主动攻击,不过结合动画机必须要满足特定的条件比如不处于巡逻、死亡、受击和奔跑状态:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class EnemyAttack : MonoBehaviour
{
private Animator animator; // 动画控制器
private bool isAttacking = false; // 是否正在攻击
private float lastAttackTime; // 上次攻击时间
private float attackCooldown = 0.3f; // 攻击冷却时间
private float attackDuration = 0.5f; // 攻击动画持续时间
private bool canAttack = true; // 是否可以进行攻击
private bool AttackingCheck = false;
public float attackRange = 2f; // 攻击范围
public Transform player; // 玩家 Transform
private void Start()
{
animator = GetComponent<Animator>(); // 获取敌人的动画控制器
}
private void Update()
{
if (!isAttacking && canAttack)
{
if (Vector3.Distance(transform.position, player.position) <= attackRange && !isAttacking)
{
if (Time.time - lastAttackTime >= attackCooldown)
{
Debug.Log("EnemyAttack Trying");
Attack();
}
}
}
}
private void Attack()
{
// 确保敌人只会进行一次攻击
Debug.Log("EnemyAttack isAttacking:"+ isAttacking);
if (isAttacking) return;
Debug.Log("EnemyAttack Attacking!");
isAttacking = true; // 攻击开始
AttackingCheck = true;
lastAttackTime = Time.time; // 更新上次攻击时间
animator.SetTrigger("Attack"); // 触发攻击动画
// 在动画播放结束后可以恢复攻击状态
StartCoroutine(AttackCooldown());
}
private IEnumerator AttackCooldown()
{
// 等待攻击动画结束
yield return new WaitForSeconds(attackDuration);
isAttacking = false;
}
// 公开方法,用于让其他脚本访问攻击状态
public bool IsAttacking()
{
if (AttackingCheck)
{
AttackingCheck = false;
return true;
}
return false;
}
}
EnemyMoving要复杂一点,它需要处理敌人的重力和与地面碰撞的情况,然后正常状态下巡逻漫步,如果玩家出现在范围内,则触发追击玩家行动(这里部分代码可以使用官方模块的,但是巡逻和追击的逻辑要自己实现)。
using System.Collections;
using UnityEngine;
public class EnemyMoving : MonoBehaviour
{
[Header("Grounded Settings")]
public bool isGrounded;
[Tooltip("Useful for rough ground")]
public float GroundedOffset = -0.14f;
[Tooltip("The radius of the grounded check. Should match the radius of the Rigidbody or Collider")]
public float GroundedRadius = 0.28f;
[Tooltip("What layers the character uses as ground")]
public LayerMask GroundLayers;
[Header("Movement Settings")]
public float walkSpeed = 2f; // 随机行走速度
public float chaseSpeed = 4f; // 追逐玩家速度
public float detectionRange = 50f; // 感知玩家的范围
public float randomDirectionChangeTime = 3f; // 随机方向改变间隔
public Vector3 randomDirection; // 随机方向
[Header("Gravity Settings")]
public float gravity = -9.8f; // 重力值
private Vector3 velocity; // 垂直速度
[Header("References")]
public Transform player; // 玩家 Transform
private Rigidbody rb; // 敌人刚体
private bool isChasing = false; // 是否正在追逐玩家
private EnemyAttack enemyAttack;
private Animator animator;
private void Start()
{
rb = GetComponent<Rigidbody>();
if (player == null)
{
Debug.LogError("Player reference not set in EnemyMoving!");
}
rb.useGravity = false;
enemyAttack = GetComponent<EnemyAttack>();
animator = GetComponent<Animator>(); // 获取 Animator
StartCoroutine(RandomDirectionChange());
}
private void FixedUpdate()
{
// 应用重力
GroundedCheck();
ApplyGravity();
// 检测玩家是否在范围内
float distanceToPlayer = Vector3.Distance(transform.position, player.position);
if (distanceToPlayer <= detectionRange)
{
if (distanceToPlayer > enemyAttack.attackRange)
{
isChasing = true;
ChasePlayer(); // 玩家在范围内但未进入攻击范围
}
else
{
Debug.Log("EnemyMoving Attacking!");
// 进入攻击范围,停止移动,触发攻击
animator.SetBool("isWalking", false);
animator.SetBool("isRunning", false);
}
}
else
{
// 进行随机行走
isChasing = false;
RandomWalk();
}
}
private void RandomWalk()
{
// 更新动画状态
animator.SetBool("isWalking", true);
animator.SetBool("isRunning", false);
// 移动敌人
Vector3 move = randomDirection * walkSpeed * Time.fixedDeltaTime;
rb.MovePosition(rb.position + move);
// 旋转敌人朝向移动方向
Quaternion targetRotation = Quaternion.LookRotation(move);
rb.rotation = Quaternion.Slerp(rb.rotation, targetRotation, Time.fixedDeltaTime * 5f); // 5f 为旋转速度,可以调整
}
private void ChasePlayer()
{
// 更新动画状态
animator.SetBool("isWalking", false);
animator.SetBool("isRunning", true);
// 计算朝向玩家的方向
Vector3 directionToPlayer = (player.position - transform.position).normalized;
Vector3 move = directionToPlayer * chaseSpeed * Time.fixedDeltaTime;
// 移动敌人
rb.MovePosition(rb.position + move);
// 朝向玩家
if (directionToPlayer != Vector3.zero)
{
Quaternion targetRotation = Quaternion.LookRotation(new Vector3(directionToPlayer.x, 0, directionToPlayer.z));
rb.MoveRotation(Quaternion.Slerp(rb.rotation, targetRotation, 5f * Time.fixedDeltaTime));
}
}
private void GroundedCheck()
{
// 计算检测球体的位置
Vector3 spherePosition = new Vector3(transform.position.x, transform.position.y + GroundedOffset, transform.position.z);
isGrounded = Physics.CheckSphere(spherePosition, GroundedRadius, GroundLayers, QueryTriggerInteraction.Ignore);
//Debug.Log($"Grounded: {isGrounded}, Position: {transform.position}");
}
private void ApplyGravity()
{
if (!isGrounded)
{
// 如果未接触地面,施加重力
velocity.y += gravity * Time.fixedDeltaTime;
}
else
{
// 如果接触地面,重置垂直速度
velocity.y = 0f;
}
// 更新刚体位置
rb.MovePosition(rb.position + new Vector3(0, velocity.y * Time.fixedDeltaTime, 0));
}
private IEnumerator RandomDirectionChange()
{
while (true)
{
// 随机生成一个水平移动方向
float randomX = Random.Range(-1f, 1f);
float randomZ = Random.Range(-1f, 1f);
randomDirection = new Vector3(randomX, 0, randomZ).normalized;
//Debug.Log("New random direction: " + randomDirection); // 调试输出,确保方向更新
yield return new WaitForSeconds(randomDirectionChangeTime);
}
}
}
Unity实验自制第三人称小游戏_哔哩哔哩_bilibili
五、攻击判定、生命值系统
玩家攻击敌人会受伤,敌人攻击玩家也会受伤,两者也都需要实现生命值的显示。
玩家如何造成伤害,一个简单的想法是如果武器碰撞到敌人的同时是攻击状态就判断为伤害:
如果碰撞到敌人,调用玩家的攻击脚本判断是否造成伤害,如果为真,调用敌人的生命相关模块使其减少生命:
using UnityEngine;
public class WeaponDamage : MonoBehaviour
{
public PlayerAction action;
private void OnTriggerEnter(Collider other)
{
if (other.CompareTag("Enemy"))
{
EnemyController enemy = other.GetComponent<EnemyController>();
if (enemy != null)
{
Debug.Log("Enemy hit!");
if (action.IsAttacking())
{
Debug.Log("Player hit the enemy!");
enemy.TakeDamage(action.damage);
}
}
}
}
}
敌人生命的相关逻辑写在这里:
多个敌人的血条显示是比较复杂的,而且要管理他它们的显示状态,我的解决办法是写一个血条管理器管理血条预制体的产生,每个敌人会不断更新血条显示,确保血条随着敌人移动。这部分代码还是比较复杂,也是耗费较多时间的部分。
血条显示在管理器绑定的canvas上面:
除此之外,该脚本提供让敌人实例受伤的函数和死亡的函数:
好了,玩家的血量显示并没有敌人血量显示复杂,是显示在canvas固定位置,不过有更详细的血量信息:
更新:
看一下效果:
Unity实验自制第三人称小游戏_哔哩哔哩_bilibili
六、系统实现-背包和拾取/取出
背包系统通常也被称为“库存系统”,为了实现它最后有一个全局的管理器记录库存的物品信息,还要用一个UI可以显示库存,还要美观一点,需要一个IconManager为全局提供物品的图标,避免零散化。
库存管理系统Inventory,库存UI的InventoryUI,测试库存有效性并方便后台给予玩家物品的InventoryTest,还有在游戏中显示库存UI的Panel
库存的功能:增加物品、移除物品、将物品丢弃在地、使用物品、获得某个物品的数量方便外部使用:
IconManager脚本存储了游戏需要用到的图标。并且提供公有函数供外部获取,同时它还是单例,方便全局使用:
库存UI的功能:更新UI和玩家选中某个格子也要显示出来,关于背包的格子提前做好预制体使用。
库存的背包格子:
玩家要有显示背包的能力,也要有丢弃和使用物品的能力:
让玩家搭载这个脚本即可:
物品的使用放在下一节说,在这一节集中描述如何拾取和丢弃物品。
我们来看游戏中的物品需要搭载什么脚本:
GravityMoving.cs是模拟重力,GameThing.cs是为了方便管理赋予游戏对象物品的功能和属性,它最重要的功能就是可以被玩家吸附,而且碰撞到玩家就会被销毁同时玩家的背包该物品数量加一:
增加库存很简单,因为前面我们已经实现了部分库存系统,直接调用。
如何丢弃物品:
因为丢弃物品和使用物品肯定涉及重复的代码,并且需要调用大量的其他的脚本辅助实现,这些功能都抽离出来,写在ItemMaster.cs负责物品的统一丢弃和使用,其他地方只需要调用它就好了。
红线画出的对象,就是游戏需要用到的全部物品,要绑定它们的预制体,因为丢掉物品就要在游戏世界产生它们。
函数根据输入的物品名称执行对应操作:
丢掉的物品不能在玩家原地,不然会被直接吸附,导致物品回到库存:
Unity实验自制第三人称小游戏_哔哩哔哩_bilibili
七、商店系统和物品使用
接着上文,说物品的使用,这个实现也是在ItemMaster.cs,根据物品名称执行不同效果:
“potion”调用玩家生命控制脚本,+10上限并恢复满血:
“gem”是货币,不可以被使用:
其他也是类似的,可以看仓库代码。
现在说说商店系统:
商店的UI其实和库存UI的实现差别不大,前面我实现的库存系统复用性是不错的,没有多少新东西,加上购买按钮和价格显示就行了。
那物品要如何购买呢,首先得有商人,写一个shoper.cs带着商人上面,玩家靠近商人可以通过“B”键激活商店系统:
商店面板UI:只要是panel就成
商店库存控制:
价格表是设定好的,也可以通过检查器在外面修改,因为购买物品涉及玩家库存和商店库存,成员变量得引用它们:
// 玩家购买物品函数
public void OnButtonClicked()
{
Debug.Log("Buy Button clicked!");
// 检查是否选择了物品
if (SelectedItemName == "")
{
Debug.Log("You should select something.");
return;
}
// 检查价格表中是否有该物品的价格
if (!priceTable.ContainsKey(SelectedItemName))
{
Debug.Log("Item not found in the price table.");
return;
}
int price = priceTable[SelectedItemName];
// 检查玩家是否有足够的"gem"作为货币
int playerGems = playerInventory.GetItemQuantity("gem");
if (playerGems < price)
{
Debug.Log("Not enough gems to buy this item.");
return;
}
// 检查商店库存是否有该物品
int shopItemStock = shoperInventory.GetItemQuantity(SelectedItemName);
if (shopItemStock <= 0)
{
Debug.Log("The shop is out of stock for this item.");
return;
}
// 执行购买流程
bool gemRemoved = playerInventory.RemoveItem("gem", price); // 扣除玩家的"gem"
bool playerAdded = playerInventory.AddItem(SelectedItemName, 1, null); // 增加玩家库存
bool shopRemoved = shoperInventory.RemoveItem(SelectedItemName, 0); // 减少商店库存
if (gemRemoved && playerAdded && shopRemoved)
{
Debug.Log($"Successfully purchased {SelectedItemName} for {price} gems.");
}
else
{
Debug.LogError("Failed to complete the purchase transaction.");
}
}
原本的设计会减少商店库存,不过在游戏过程中的后期玩家的货币激增,会把商人的货物买完,所以修改为不减少商店库存。若要调整,将0变为1或别的数字:
Unity实验自制第三人称小游戏_哔哩哔哩_bilibili
八、关卡设计
现在完成了游戏的全部基本元素,可以思考关卡要如何设计了。
首先,难度要从简单到难,越来越有挑战性,同时玩家要可以不断提升实力,这些都是获得游戏快感的常用设计,然后我还希望游戏可以一直进行下去直到玩家死亡。
然后,思考到关卡要能一直玩下去,那只能是由系统生成,并不断提升难度等级,很自然地想到每提升一级,敌人数量加一。
为了让玩家提升实力,商人是必备的,放置在每一个关卡。
所以,先完成一个关卡元件,然后玩家战胜一波敌人就生成下一个关卡元件。
如果关卡超过4个,删除最老的关卡。
左右要有空气墙,避免玩家掉在外面。
AreneManager.cs负责关卡的生成销毁,以及初始化内部成员。
EnemyFactory.cs使用对象池管理敌人的产生:
Unity实验自制第三人称小游戏_哔哩哔哩_bilibili
九、游戏视频演示
Unity实验自制第三人称小游戏_哔哩哔哩_bilibili