基于Unity引擎的RPG3D项目开发笔录

RPG游戏开发笔录

文章目录

  • RPG游戏开发笔录
    • 1.将普通3D项目升级为RPG渲染管线
    • 2.导入素材(人物,场景,天空盒)
    • 3.第三人称自由视角与移动
    • 4.切换鼠标指针
    • 5.遮挡剔除实现
    • 6.敌人的创建,站岗,追逐
    • 7.人物基本数值实现
    • 8.攻击功能的实现(重难)
    • 9.泛型单例模式以及怪物获胜通知
    • 10.模板生成更多Enemy
    • 11.拓展方法实现怪物的攻击范围限制
    • 12.血条UI的设计
    • 13.玩家升级系统
    • 14.玩家的血条UI
    • 15.传送门(切换关卡)
    • 16.保存数据
    • 17.主菜单的制作
    • 18.场景转场

1.将普通3D项目升级为RPG渲染管线

  • 1.Package Manager 搜索 Universal RP进行安装
  • 2.创建通用渲染管线 Rendering--->URP Assets(with Universal Renderer)
  • 3.进入Project Settings--->Graphics,选中刚创建的渲染管线
    基于Unity引擎的RPG3D项目开发笔录_第1张图片
  • 4.进入Quality中同样上述操作
  • 5.进入渲染管线,修改Shadows-->Max Distance,以减小渲染时带给显卡的压力
  • 6.修改默认渲染方式为GPU,并可修改GPU类型和烘焙渲染:
    基于Unity引擎的RPG3D项目开发笔录_第2张图片

2.导入素材(人物,场景,天空盒)

更新渲染:
Windows--->Rendering-->Render Pipeline Converter--->Built-in to URP--->全部勾选--->Initialize Converters--->Convert Assets

3.第三人称自由视角与移动

  • 1.Input Manager–>复制粘贴 Horizontal和Vertical 并修改其为第四,第五坐标轴,命名为 Camera Rate X & Camera Rate Y
  • 2.给摄像机设置父节点 Photographer,使其能绕着父物体移动

相机脚本

public class Photographer : MonoBehaviour
{
    //相机抬升(绕X轴)
    public float Pitch { get; private set; }
    //相机水平角度(绕Y轴)
    public float Yaw { get; private set; }

    //鼠标灵敏度
    public float mouseSensitivity = 5;
    //摄像机旋转速度
    public float cameraRotatingSpeed = 80;
    public float cameraYSpeed = 5;

    //相机跟随目标
    private Transform followTarget;


    public void InitCamera(Transform target)
    {
        followTarget = target;
        transform.position = target.position;
    }

    private void Update()
    {
        UpdateRotation();
        UpdatePosition();
    }

    private void UpdateRotation()
    {
        Yaw += Input.GetAxis("Mouse X") * mouseSensitivity;
        Yaw += Input.GetAxis("CameraRateX") * cameraRotatingSpeed * Time.deltaTime ;
        //Debug.Log(Yaw);
        Pitch += Input.GetAxis("Mouse Y") * mouseSensitivity;
        Pitch += Input.GetAxis("CameraRateY") * cameraRotatingSpeed * Time.deltaTime;
        //限制抬升角度
        Pitch = Mathf.Clamp(Pitch, -90, 90);
        //Debug.Log(Pitch);

        transform.rotation = Quaternion.Euler(Pitch, Yaw, 0);
    }

    private void UpdatePosition()
    {
        Vector3 position = followTarget.position;
        float newY = Mathf.Lerp(transform.position.y, position.y, Time.deltaTime * cameraYSpeed);
        transform.position = new Vector3(position.x,newY,position.z);
    }
}

人物移动脚本(通用)

[RequireComponent(typeof(Rigidbody))]
public class CharacterMove : MonoBehaviour
{
    //刚体组件
    [Header("组件")]
    private Rigidbody rb;
    
    public Vector3 CurrentInput { get; private set; }

    public float MaxWalkSpeed = 5;


    void Awake()
    {
        rb = GetComponent<Rigidbody>();
    }


    private void FixedUpdate()
    {
        rb.MovePosition(rb.position + CurrentInput * MaxWalkSpeed * Time.fixedDeltaTime);
        //rb.MoveRotation(Quaternion.LookRotation(CurrentInput * MaxWalkSpeed * Time.fixedDeltaTime));
        //rb.rotation = Quaternion.LookRotation(CurrentInput * MaxWalkSpeed * Time.fixedDeltaTime);
        //目标旋转角度
        Quaternion quaternion = Quaternion.LookRotation(CurrentInput * MaxWalkSpeed * Time.fixedDeltaTime);
        //平滑过渡,deltaTime为每帧渲染的时间
        transform.localRotation = Quaternion.Lerp(transform.localRotation, quaternion, Time.deltaTime * 10f);

    }

    public void SetMovementInput(Vector3 input)
    {
        
        CurrentInput = Vector3.ClampMagnitude(input, 1);
    }

玩家移动脚本

[RequireComponent(typeof(Rigidbody))]
public class PlayerLogic : MonoBehaviour
{
    [Header("组件")]
    //刚体组件
    private Rigidbody rb;
    //动画组件
    private Animator anim;
    //移动组件
    private CharacterMove characterMove;
    //摄像机组件
    public Photographer photographer;

    //摄像机的根位置
    public Transform followTarget;

    //附加项
    public float jumpForce = 10f;


    [Header("人物信息")]
    //人物移动方向
    private bool isForward, isBack, isLeft, isRight,isFall;


    void Awake()
    {
        rb = GetComponent<Rigidbody>();
        anim = GetComponent<Animator>();
        characterMove = GetComponent<CharacterMove>();
        photographer.InitCamera(followTarget);
    }
    
    void Update()
    {
        UpdateMovementInput();
        //Jump();
        AttackAnim();
        SwitchAnim();
    }

    //动画变量同步
    private void SwitchAnim()
    {
        anim.SetBool("Forward", isForward);
        anim.SetBool("Back", isBack);
        anim.SetBool("Left", isLeft);
        anim.SetBool("Right", isRight);
        anim.SetBool("Fall", isFall);
    }

    #region 玩家移动
    //人物移动函数
    private void UpdateMovementInput()
    {
        /*
         * TODO:添加人物死亡条件限制
         */
        float ad = Input.GetAxis("Horizontal");
        //Debug.Log("ad值为:" + ad);
        float ws = Input.GetAxis("Vertical");
        //Debug.Log("ws值为:" + ws);

        Quaternion rot = Quaternion.Euler(0, photographer.Yaw, 0);
        characterMove.SetMovementInput(rot * Vector3.forward * ws + 
                                       rot * Vector3.right * ad);

        if (ad != 0 || ws != 0)
        {
            /*//目标旋转角度
            Quaternion quaternion = Quaternion.LookRotation(new Vector3(ad, 0, ws));
            //平滑过渡,deltaTime为每帧渲染的时间
            transform.localRotation = Quaternion.Lerp(transform.localRotation, quaternion, Time.deltaTime * 10f);*/


            //动画状态切换
            if (ad > 0.1)
            {
                isRight = true;

            }else if (ad < -0.1)
            {
                isLeft = true;
            }else
            {
                isRight = false;
                isLeft = false;
            }

            if (ws > 0.1)
            {
                isForward = true;
            }
            else if (ws < -0.1)
            {
                isBack = true;
            }
            else
            {
                isForward = false;
                isBack = false;
            }
        }

        //添加速度
        //rb.velocity = new Vector3(ad * moveSpeed, 0, ws * moveSpeed);
    }
    #endregion 

    #region 玩家跳跃
    //跳跃函数
    private void Jump()
    {
        if (Input.GetKeyDown(KeyCode.Space) && isFall)
        {
            anim.SetTrigger("Jump");
            rb.velocity = new Vector3(rb.velocity.x, jumpForce,rb.velocity.z);
            isFall = false;
        }     
    }

    private void OnTriggerEnter(Collider other)
    {
        if (other.gameObject.CompareTag("Ground"))
        {
            Debug.Log("触发到地面了");
            isFall = true;
        }
    }

    #endregion

    #region 玩家攻击
    private void AttackAnim()
    {
        this.transform.LookAt(this.transform);
        if (Input.GetMouseButtonDown(0))
        {
            anim.SetTrigger("Attack");
        }else if (Input.GetMouseButtonDown(1))
        {
            anim.SetTrigger("Ability");
        }
    }

    #endregion
}

4.切换鼠标指针

切换鼠标指针

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;

[RequireComponent(typeof(Rigidbody))]
public class CharacterMove : MonoBehaviour
{    
    [Header("人物攻击模块")]
    //鼠标图标
    public Texture2D target, attack;
    RaycastHit hitInfo;


    void Awake()
    {
        rb = GetComponent<Rigidbody>();
    }

    void Update()
    {
        SetCursorTexture();  
    }
    
    //设置鼠标指针
    void SetCursorTexture()
    {
        Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);

        if (Physics.Raycast(ray,out hitInfo))
        {
            switch (hitInfo.collider.gameObject.tag)
            {
                case "Ground":
                    Cursor.SetCursor(target, new Vector2(16, 16), CursorMode.Auto);
                    break;
                case "Enemy":
                    Cursor.SetCursor(attack, new Vector2(16, 16), CursorMode.Auto);
                    break;
            }
        }
    }

5.遮挡剔除实现

  • 1.create-->Shader Graph--> URP--->Unit Shader Graph,并起名为 Occlusion Shader
  • 2.基于它创建材质Occlusion放回meterials文件夹
  • 编辑Occlusion Shader为如下参考:
    基于Unity引擎的RPG3D项目开发笔录_第3张图片
    材质参数参考:
    基于Unity引擎的RPG3D项目开发笔录_第4张图片
    URP参数参考:
    基于Unity引擎的RPG3D项目开发笔录_第5张图片
    遮挡剔除实现完成!

6.敌人的创建,站岗,追逐

  • 1.导入素材,设置基本状态信息(Idle,Chase,Guard,Dead)
  • 2.敌人的状态切换,追逐玩家以及切换动画:
using UnityEngine;
using UnityEngine.AI;

public enum EnemyStates { GUARD,PATROL,CHASE,DEAD }

[RequireComponent(typeof(NavMeshAgent))]
public class EnemyController : MonoBehaviour
{
    [Header("组件")]
    private NavMeshAgent agent;
    private Animator anim;

    [Header("状态变量")]
    private EnemyStates enemyStates;

    [Header("敌人基础设置")]
    //敌人可视范围
    public float sightRadius;
    //敌人的类型
    public bool isGuard;
    //敌人攻击目标
    private GameObject AttackTarget;
    //记录敌人初始站岗位置
    private Vector3 GuardPos;

    //敌人状态
    bool isWalk,isDead;

	//脱战观望时间
	public float remainLookatTime = 3f;
    //脱战停留计时器
    private float remainTimer;

    private void Awake()
    {
        agent = GetComponent<NavMeshAgent>();
        anim = GetComponent<Animator>();
    }

void Start()
    {
        //给敌人初始位置赋值
        GuardPos = transform.position;
    }
    
    void Update()
    {
        SwitchStates();
        SwitchAnimation();
    }

    //切换动画
    void SwitchAnimation()
    {
        anim.SetBool("Chase", isChase);
        anim.SetBool("Walk", isWalk);
        anim.SetBool("Dead", isDead);
    }

    void SwitchStates()
    {
        //如果发现玩家,切换到Chase
        if (FoundPlayer())
        {
            enemyStates = EnemyStates.CHASE;
            Debug.Log("发现玩家");
        }

        switch (enemyStates)
        {
            case EnemyStates.GUARD:
            	Guard();
                break;
            case EnemyStates.PATROL:
                break;
            case EnemyStates.CHASE:
                ChasePlayer();
                break;
            case EnemyStates.DEAD:
                break;
        }
    }

    //是否发现玩家
    bool FoundPlayer()
    {
        //拿到检测到对应范围内的所以碰撞体
        var hitColliders = Physics.OverlapSphere(this.transform.position, sightRadius);

        //检测其中是否存在Player
        foreach(var target in hitColliders)
        {
            if (target.gameObject.CompareTag("Player"))
            {
                AttackTarget = target.gameObject;
                return true;
            }
        }
        //脱离目标
        AttackTarget = null;
        return false;
    }

    //追逐玩家函数
    void ChasePlayer()
    {
        //脱战逻辑
        if (!FoundPlayer())
        {
            if (remainTimer > 0)
            {
                //观望战立状态
                agent.destination = transform.position;
                remainTimer -= Time.deltaTime;
                isWalk = false;
            }
            else
            {
                
                //回到上一个状态(巡逻或者站立)
                enemyStates = isGuard ? EnemyStates.GUARD : EnemyStates.PATROL;
            }

        }
        else
        {
            agent.isStopped = false;
            isWalk = true;
            //刷新观望时间计时器
            remainTimer = remainLookatTime;
            //当距离小于攻击距离时,开始攻击
            if (Vector3.Distance(transform.position, AttackTarget.transform.position) < characterStats.AttackData.attackRange)
            {
                isWalk = false;
                agent.isStopped = true;
                anim.SetTrigger("Attack");
            }
            else
            {
                
                //追击Player
                agent.destination = AttackTarget.transform.position;
            }

        }
        

    }
    //怪物返回出生点以及站岗逻辑
    void Guard()
    {
        //判断怪物是否返回出生点
        if (Vector3.Distance(transform.position,GuardPos) <= 3)
        {
            isWalk = false;
        }
        else
        {
            isWalk = true;
            agent.destination = GuardPos;
        }
        
    }
}

7.人物基本数值实现

  • 1.给文件夹分好类,分别创建 MonoBehaviorScriptableObject文件夹。
  • 2.创建第一个ScriptableObject脚本文件,命名为 CharacterData_SO
  • 3.编写人物应有的通用属性,并在专门的数据文件夹下创建出数据文件。

数据模板写法

[CreateAssetMenu(fileName ="New Data",menuName = "Character Stats/Data")]
public class CharacterData_SO : ScriptableObject
{
    [Header("Stats Info")]
    //最大生命值
    public int maxHealth;
    //当前生命值
    public int currentHealth;
    //基础防御力
    public int baseDefence;
    //当前防御力
    public int currentDefence;
}

数据操作写法

public class CharacterStats : MonoBehaviour
{
    public CharacterData_SO characterData;

    #region Read from Data_SO
    public int MaxHealth 
    {
        get
        {
            return characterData != null ? characterData.maxHealth : 0;
        }
        set
        {
            characterData.maxHealth = value;
        }
    }

    public int CurrentHealth
    {
        get
        {
            return characterData != null ? characterData.currentHealth : 0;
        }
        set
        {
            characterData.currentHealth = value;
        }
    }

    public int BaseDefence
    {
        get
        {
            return characterData != null ? characterData.baseDefence : 0;
        }
        set
        {
            characterData.baseDefence = value;
        }
    }

    public int CurrentDefence
    {
        get
        {
            return characterData != null ? characterData.currentDefence : 0;
        }
        set
        {
            characterData.currentDefence = value;
        }
    }
    #endregion
}

  • 4.将数据操作脚本 CharacterStats挂载到Player和Enemy上,并拖入对应数据文件。
  • 5.在玩家控制脚本中获取到数据文件,并获取到操作该数据的方法。
  • 6.同样方式实现攻击数值的基本书写:
[CreateAssetMenu(fileName = "New Attack",menuName = "Attack/Data")]
public class AttackData_SO : ScriptableObject
{
    //攻击距离
    public float attackRange;
    //技能距离
    public float skillRange;
    //冷却时间
    public float coolDown;
    //基础伤害范围
    public int minDamage;
    public int maxDamage;

    //暴击倍率(伤害)
    public float criticalMultiplier;
    //暴击率
    public float criticalChance;
}

8.攻击功能的实现(重难)

  • 1.创建攻击数据脚本:包括玩家和怪物
    给出参考:Player Attack Data_SO
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[CreateAssetMenu(fileName = "New Attack",menuName = "Attack/Data")]
public class AttackData_SO : ScriptableObject
{
    //攻击距离
    public float attackRange;
    //技能距离
    public float skillRange;
    //冷却时间
    public float coolDown;
    //基础伤害范围
    public int minDamage;
    public int maxDamage;

    //暴击倍率(伤害)
    public float criticalMultiplier;
    //暴击率
    public float criticalChance;
}

玩家攻击脚本面板参考:
基于Unity引擎的RPG3D项目开发笔录_第6张图片

  • 2.在角色数据操作脚本中引入攻击脚本:并写出伤害计算逻辑
public class CharacterStats : MonoBehaviour
{
    public CharacterData_SO characterData;

    public AttackData_SO AttackData;

    #region 攻击模块
    public void TakeDamage(CharacterStats attacker,CharacterStats defencer)
    {
        //计算一次攻击的伤害
        float coreDamage = UnityEngine.Random.Range(attacker.AttackData.minDamage, attacker.AttackData.maxDamage);

        //控制最低伤害为1
        int damage = Mathf.Max((int)coreDamage - defencer.CurrentDefence,1);
        
        //控制血量最低为0
        CurrentHealth = Mathf.Max(CurrentHealth - damage, 0);
        //更新 UI
        
    }
    #endregion
}

由于这里攻击函数是玩家和怪物通用的,所以后续只需要传入攻击者和受击者即可正常完成伤害计算并扣血。其次,怪物的进攻逻辑是AI操控,只需要在追踪玩家时,吧玩家对象传入怪物的攻击目标变量即可。但玩家的攻击目标选择却成了问题,这里采用类似怪物发现玩家的方式,采用一个圆形探测范围(该范围阈值即为玩家的攻击范围),当玩家进行攻击操作时,只需要检测怪物是否在范围检测距离内,如果在距离内,则正常调用TakeDamage(),如果不在,则视为空刀。缺点是实际攻击范围(以玩家为圆心,以攻击距离为半径的圆形内)与动画攻击范围(人物的前方扇形区域)不符合。后续有待改进…此外,暴击功能较为繁琐,目前未实现。以下为判断暴击的常用方法之一,仅供参考:

//设置全局变量,判断是否暴击
characterStats.isCritical = UnityEngine.Random.value < characterStats.attackData.criticalChance;
  • 3.怪物的攻击逻辑:(将其挂载到怪物攻击动画的某一帧上)
	//引入数值组件
	private CharacterStats characterStats;

	//给组件赋值
	private void Awake()
	{
		characterStats = GetComponent<CharacterStats>();
	}
	
	//每次游戏开始给怪物重置生命值
	void Start()
	{
		//刷新初始生命值
        characterStats.CurrentHealth = characterStats.MaxHealth;
	}

//怪物攻击的出伤害事件
    void Hit()
    {
        if (AttackTarget != null)
        {
            //拿到受害者的属性信息
            var targetStats = AttackTarget.GetComponent<CharacterStats>();
            //造成伤害
            targetStats.TakeDamage(characterStats, targetStats);
        }
    }
  • 4.玩家的攻击逻辑
//引入数值组件
//Awake初始化数值组件
//初始化生命值

#region 玩家攻击
    public void AttackAnim()
    {
        if (Input.GetMouseButtonDown(0))
        {
            anim.SetTrigger("Attack");
        }else if (Input.GetMouseButtonDown(1))
        {
            anim.SetTrigger("Ability");
        }
    }

    //范围检测
    private bool AttackRangeTest()
    {
        Debug.Log(characterStats.AttackData.attackRange);
        //拿到检测到对应范围内的所有碰撞体
        Collider[] hitColliders = Physics.OverlapSphere(transform.position, characterStats.AttackData.attackRange);
        foreach (Collider collider in hitColliders)
        {
            if (collider.gameObject.CompareTag("Enemy"))
            {
                AttackTarget = collider.gameObject;
                return true;
            }
        }
        return false;
    }

    //玩家的出伤害逻辑
    void Hit()
    {
        if (AttackRangeTest())
        {
            CharacterStats targetStats = AttackTarget.GetComponent<CharacterStats>();
            targetStats.TakeDamage(characterStats, targetStats);
        }
        else
        {
            Debug.Log("空刀!");
        }
    }
#endregion
  • 5.死亡的判断
    思路:死亡判断直接在Update()函数中逐帧判断当前生命值是否为0即可:
    玩家死亡:
//引入变量
	bool isDead;
	void Update()
    {
        if (characterStats.CurrentHealth != 0)
        {
            UpdateMovementInput();
            Jump();
            AttackAnim();
            
        }
        else
        {
            isDead = true;
            rb.constraints = RigidbodyConstraints.FreezePosition;
            rb.constraints = RigidbodyConstraints.FreezeRotation;
        }
        SwitchAnim();

    }

怪物死亡:

	void Update()
    {
        if (characterStats.CurrentHealth != 0)
        {
            SwitchStates();
        }
        else
        {
            isDead = true;
            enemyStates = EnemyStates.DEAD;
        }
        
        SwitchAnimation();
    }

9.泛型单例模式以及怪物获胜通知

  • 1.写一个管理单例模式的基类( Tools/Singleton ):
//管理类的基类
//泛型单例模式

public class Singleton<T> : MonoBehaviour where T:Singleton<T>
{
    private static T instance;

    //外部可访问的函数
    public static T Instance
    {
        get { return instance; }
    }

    //可继承可重写的Awake方法
    protected virtual void Awake()
    {
        if (instance != null)
        {
            Destroy(gameObject);
        }
        else
        {
            instance = (T)this;
        }
    }

    public static bool IsInitialized
    {
        get { return instance != null; }
    }

    protected virtual void OnDestroy() 
    { 
        if (instance == this)
        {
            instance = null;
        }    
    }
}

  • 2.写一个发放广播的接口 IEndGameObserver
public interface IEndGameObserver 
{
    void EndNotify();
}
  • 3.写一个游戏管理类继承管理基类 ( Manages/GameManager )
public class GameManager : Singleton<GameManager>
{
    public CharacterStats playerStats;


    //收集所有需要接收广播的对象
    List<IEndGameObserver> endGameObservers = new List<IEndGameObserver>();

	//在游戏开始时将玩家信息注册到管理类
    public void RigisterPlayer(CharacterStats player)
    {
        playerStats = player;
    }

	//在游戏开始时,将怪物信息(被通知者)录入通知对象
    public void AddObserver(IEndGameObserver observer)
    {
        endGameObservers.Add(observer);
    }

	//怪物被消灭时,清理通知对象
    public void RemoveObserver(IEndGameObserver observer)
    {
        endGameObservers.Remove(observer);
    }

	//通知逻辑
    public void NotifyObservers()
    {
        foreach(var observer in endGameObservers)
        {
            observer.EndNotify();
        }
    }
}
  • 4.在玩家逻辑中添加注册信息到管理类
    PlayerLogic:
void Start()
    {
        //利用单例模式调用注册玩家信息
        GameManager.Instance.RigisterPlayer(characterStats);
    }
  • 5.在怪物逻辑中添加 注册和销毁通知,以及通知内容的具体实现
    EnemyController:
//首先让其实现IEndGameObserver接口
public class EnemyController : MonoBehaviour,IEndGameObserver
{
	private void OnEnable()
    {
        GameManager.Instance.AddObserver(this);
    }

    private void OnDisable()
    {
    	if (!GameManager.IsInitialized) return;
        GameManager.Instance.RemoveObserver(this);
    }
}

	public void EndNotify()
    {
        //获胜动画
        //停止所有移动
        //停止Agent
        isWalk = false;
        AttackTarget = null;
        isVictory = true;
    }

报错总结:此处在GameManager的实例化阶段一直报 空引用异常,后来发现自己没有创建 Game Manager 的结点,并挂载GameManager的脚本。此外,还需注意 Onable函数是场景初始化时被创建,此处不涉及场景切换,故,需将其暂时写在Start方法中,后续再更改。

10.模板生成更多Enemy

  • 1.由于之前写法会使多个复制出来的怪物属性共享,这显然不是我们需要的功能,故我们需要创建一个属性模板,让其新生成的怪物以该模板生成对应的数值。
    Character Stats中:
public class CharacterStats : MonoBehaviour
{
	public CharacterData_SO templateData;

    public CharacterData_SO characterData;

	void Awake()
    {
        if (templateData != null)
        {
            characterData = Instantiate(templateData);
        }    
    }
}
  • 2.引入新怪物的美术模型资源,添加必要组件EnemyController,NavMesh Agent以及基本碰撞体,别忘了将其标签改为Enemy,并创建对应的数据文件。
  • 3.在重置新类型敌人的动画信息时,可直接创建一个 Override Animators,可十分便利的重置怪物动画。

11.拓展方法实现怪物的攻击范围限制

由于之前只要怪物发动攻击动画,玩家必掉血。这是极度不合理的,我们希望玩家有一定的闪避空间或容错,故我们采用 拓展方法 Extension Method 来对其攻击范围做一个限制。

  • 1.写一个 Extension Method脚本:
public static class ExtensionMethod 
{

    //确保怪物攻击在正前方[-60°,60°]之间范围攻击有效
    private const float dotThreshold = 0.5f;

    public static bool IsFacingTarget(this Transform transform, Transform target)
    {
        //计算出目标物对于攻击者正前方的相对位置并取单位向量
        var vectorToTarget = target.position - transform.position;
        vectorToTarget.Normalize();

        float dot = Vector3.Dot(transform.forward, vectorToTarget);

        return dot >= dotThreshold;
    }
}

  • 2.并在怪物的攻击动画事件中加上限制条件:
    EnemyController:
//怪物攻击的出伤害事件
    void Hit()
    {
        if (AttackTarget != null && transform.IsFacingTarget(AttackTarget.transform))
        {
            //拿到受害者的属性信息
            var targetStats = AttackTarget.GetComponent<CharacterStats>();
            //造成伤害
            targetStats.TakeDamage(characterStats, targetStats);
        }
    }
  • 3.给Boss的基础攻击增加击退效果

但暂时有点问题,无法正常击退玩家。后续再改BUG。

public class Boss_Rock : EnemyController
{
    //击飞玩家的力
    public float kickForce = 25f;

    public void KickOff()
    {
        if (AttackTarget != null && transform.IsFacingTarget(AttackTarget.transform))
        {
            Debug.Log("被踢开了");
            //拿到受害者的属性信息
            var targetStats = AttackTarget.GetComponent<CharacterStats>();

            //计算击飞方向(问题)
            //FIXME:有待修改
            Vector3 direction = (AttackTarget.transform.position - transform.position).normalized;
            AttackTarget.GetComponent<Rigidbody>().velocity = direction * kickForce;
            
            //造成伤害
            targetStats.TakeDamage(characterStats, targetStats);
        }
    }
}
  • 4.设置石头人可投掷物

– 1.拖入石头的素材,添加必要组件:RigidBody,Mesh Collider(勾选第一项),脚本Rock.cs

public class Rock : MonoBehaviour
{
	private Rigidbody rb;
	
	[Header("Basic Settings")]
	public float force;

	public GameObject target;
	private Vector3 direction;

	void Start()
	{
		rb = GetComponent<Rigidbody>();
		FlyToTarget();	
	}

	public void FlyToTarget()
	{
		//预防石头生成瞬间玩家脱离范围
		if (target == null)
		{
			target = FindObjectOfType<PlayerController>().gameObject;
		}
		direction = (target.transform.position - transform.position + Vector3.up).normalized;
		rb.AddForce(direction * force, ForceMode.Impulse);
	}
}

– 2.修改石头人生成石头代码:

public class Boss_Rock : EnemyController
{
	//扔出的石头的预制体
	public GameObject rockPrefab;
	//出手点的坐标
	public Transform handPos;

	//投掷石头的逻辑
	public void ThrowRock()
	{
		if (AttackTarget != null)
		{
			var rock = Instantiate(rockPrefab,handPos.position,Quaternion.identify);
			rock.GetComponent<Rock>().target = AttackTarget;
		}
	}
}

– 3.将ThrowRock方法添加到动画对应帧数上。

– 4.设置石头的状态:在被投掷出的时候能对敌人以及玩家造成伤害,但落地以后无法对玩家或敌人造成伤害。

public class Rock : MonoBehaviour
{
	public enum RockStates { HitPlayer,HitEnemy,HitNothing };
	//石头的伤害值
    public int damage = 8;
    //石头的状态
    public RockStates rockStates;

	void Start()
    {
        rb = GetComponent<Rigidbody>();
        //为了防止石头刚一出来就被判断为hitNothing
        rb.velocity = Vector3.one;
        //初始化石头的状态
        rockStates = RockStates.HitPlayer;
        //石头被生成的时候就自动飞向目标
        FlyToTarget();
        //石头扔出三秒后延迟销毁
        Destroy(this.gameObject, 3);
    }
	//逐帧判断,当石头几乎静止时变得不再有威胁
	void FixedUpdate()
    {
        Debug.Log(rb.velocity.sqrMagnitude);
        if (rb.velocity.sqrMagnitude < 1)
        {
            rockStates = RockStates.HitNothing;
        }    
    }

	void OnCollisionEnter(Collision collision)
    {
        
        switch (rockStates)
        {
            case RockStates.HitPlayer:
                if (collision.gameObject.CompareTag("Player"))
                {
                    CharacterStats characterStats = collision.gameObject.GetComponent<CharacterStats>();
                    //碰到玩家了,造成伤害,并对玩家播放受击动画(TakeDamage的函数重载)
                    characterStats.TakeDamage(damage,characterStats);
                    collision.gameObject.GetComponent<Animator>().SetTrigger("Hit");

                    rockStates = RockStates.HitNothing;
                }
                break;

            case RockStates.HitEnemy:
                if (collision.gameObject.CompareTag("Enemy"))
                {
                    var EnemyStats = collision.gameObject.GetComponent<CharacterStats>();
                    EnemyStats.TakeDamage(damage, EnemyStats);
                    //攻击到敌人以后,也将其设为无危胁状态
                    
                }
                break;
        }    
    }
}

– 5.函数重载TakeDamage()方法,让石头也能造成伤害:

	public void TakeDamage(int damage,CharacterStats defencer)
    {
        int finalDamage = Mathf.Max(damage - defencer.CurrentDefence, 1);
        CurrentHealth = Mathf.Max(CurrentHealth - finalDamage, 0);
    }

– 6.修改玩家的攻击逻辑,使其攻击石头也具有一定逻辑:

//范围检测
    private bool AttackRangeTest()
    {
        //Debug.Log(characterStats.AttackData.attackRange);
        //拿到检测到对应范围内的所有碰撞体
        Collider[] hitColliders = Physics.OverlapSphere(transform.position, characterStats.AttackData.attackRange);
        foreach (Collider collider in hitColliders)
        {
        	//有限判断敌人,如果不存在敌人,则判断是否有可攻击物
            if (collider.gameObject.CompareTag("Enemy") 
             || collider.gameObject.CompareTag("Attackable"))
            {
                AttackTarget = collider.gameObject;
                return true;
            }
        }
        return false;
    }

    //玩家的出伤害逻辑
    void Hit()
    {
        
        if (AttackRangeTest())
        {
            if (AttackTarget.CompareTag("Attackable"))
            {
                //进一步判断是石头
                if (AttackTarget.GetComponent<Rock>())
                {
                    AttackTarget.GetComponent<Rock>().rockStates = Rock.RockStates.HitEnemy;
                    AttackTarget.GetComponent<Rigidbody>().velocity = Vector3.one;
                    AttackTarget.GetComponent<Rigidbody>().AddForce(transform.forward * 20, ForceMode.Impulse);
                }
            }else if (AttackTarget.CompareTag("Enemy"))
            {
                CharacterStats targetStats = AttackTarget.GetComponent<CharacterStats>();
                targetStats.TakeDamage(characterStats, targetStats);
            }
            
        }
    }

12.血条UI的设计

  • 1.创建一个Canvas命名为 HealthBarCanvas,修改Canvas的 UI Scale Mode改为 World Space,并设置相机Camera。创建一个子物体UI image,命名 Bar Holder。

  • 2.对UI界面的位置信息进行调整,修改长3和高0.25(参考值)。

  • 3.在Package Manager中引入 3D sprite,创建一个2D Object–>Square,找到其基础的文件,复制一份图片另存起来。将其拖入到Bar Holder的Source Image中,并可修改其颜色(血条底色)。

  • 基于Unity引擎的RPG3D项目开发笔录_第7张图片

  • 4.继续创建Bar Health的子节点Image,尺寸参数与父节点保持一致,拖入Source Image,修改颜色(血条上层色),并将其改为滑动条的形式进行显示。

  • 5.写UI脚本操控血条的变化。

public class HealthBarUI : MonoBehaviour
{
    //血条预制体
    public GameObject healthBarPrefab;

    //血条位置
    public Transform barPoint;

    //是否让血条持续显示
    public bool alwaysVisible;
    //血条被唤醒后显示的时间
    public float visibleTime;

    //血量滑动条
    Image healthSlider;
    Transform UIBar;

    //摄像机的位置(保证始终正对摄像机)
    Transform camera;

    //拿到当前目标的血量信息
    private CharacterStats currentStats;

    void Start()
    {
        currentStats = GetComponent<CharacterStats>();
    }

    void OnEnable()
    {
        camera = Camera.main.transform;
        
        foreach (Canvas canvas in FindObjectsOfType<Canvas>())
        {
            if (canvas.renderMode == RenderMode.WorldSpace)
            {
                UIBar = Instantiate(healthBarPrefab, canvas.transform).transform;
                healthSlider = UIBar.GetChild(0).GetComponent<Image>();
                UIBar.gameObject.SetActive(alwaysVisible);
            }
        }
    }

    //血条跟随敌人
    void LateUpdate()
    {
        if (UIBar != null)
        {
            UIBar.position = barPoint.position;

            UIBar.forward = -camera.forward;
            
        }
    }

    void Update()
    {
        
        UpdateHealthBar(currentStats.CurrentHealth, currentStats.MaxHealth);
    }

    private void UpdateHealthBar(int currentHealth, int maxHealth) 
    {
        if (currentHealth <= 0)
        {
            if (UIBar != null)
            {
                Destroy(UIBar.gameObject);
            }

        }
        else
        {
            UIBar.gameObject.SetActive(true);

            float sliderPercent = (float)currentHealth / maxHealth;
            //Debug.Log("当前血量仅剩:" + sliderPercent);
            healthSlider.fillAmount = sliderPercent;
        }
        
    }

    
}

总结:该套程序采用逐帧检测血条变化,相对来说效率较差,优化可以让角色攻击时计算一次血条变化。后续会优化!!!

13.玩家升级系统

  • 1.先添加一下玩家的属性
    Character_SO:
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[CreateAssetMenu(fileName ="New Data",menuName = "Character Stats/Data")]
public class CharacterData_SO : ScriptableObject
{
    [Header("Stats Info")]
    //基础生命值
    public float baseHealth;
    //最大生命值
    public float maxHealth;
    //当前生命值
    public float currentHealth;
    //基础防御力
    public int baseDefence;
    //当前防御力
    public int currentDefence;

    [Header("击杀敌人获得的经验值")]
    public int killExp;

    [Header("玩家升级系统")]
    //当前等级
    public int currentLevel;
    //最大等级
    public int maxLevel;
    //升级所需基础经验值
    public int baseExp;
    //当前经验值
    public int currentExp;
    //升级经验提高值
    public int ExpIncrement;
    //升级属性加权
    public float levelBuff;

    //更新经验值函数
    public void UpdateExp(int Exp)
    {
        currentExp += Exp;

        if (currentExp >= baseExp)
        {
            LevelUp();
        }
    }

    private void LevelUp()
    {
        //提高等级(将当前等级限制在[0,maxLevel之间])
        currentLevel = Mathf.Clamp(currentLevel + 1, 0, maxLevel);
        //升级所需的经验值也随之提高
        baseExp += ExpIncrement;
        //血量提高5%
        maxHealth = baseHealth * (levelBuff * currentLevel + 1);
        currentHealth = maxHealth;
        Debug.Log("LevelUP,当前血量为" + currentHealth);
    }
}

  • 暂时为了学习升级逻辑的构思以及数值的模范书写,后续功能有待优化!

  • 2.在造成伤害界面 添加击杀增加经验的判断:
    CharacterStats:

 public void TakeDamage(CharacterStats attacker,CharacterStats defencer)
    {
        //计算一次攻击的伤害
        float coreDamage = UnityEngine.Random.Range(attacker.AttackData.minDamage, attacker.AttackData.maxDamage);

        //控制最低伤害为1
        int damage = Mathf.Max((int)coreDamage - defencer.CurrentDefence,1);
        
        //控制血量最低为0
        CurrentHealth = Mathf.Max(CurrentHealth - damage, 0);
        //更新 UI
        //经验值update
        if (CurrentHealth == 0)
        {
            attacker.characterData.UpdateExp(defencer.characterData.killExp);
        }
    }

14.玩家的血条UI

  • 1.同样优先创建一个Canvas,设置参考如下:
  • 基于Unity引擎的RPG3D项目开发笔录_第8张图片

做出如下效果的UI界面:
基于Unity引擎的RPG3D项目开发笔录_第9张图片
并在其父节点下嵌入以下脚本以控制血条的变化:
PlayerHealthUI:

using UnityEngine.UI;

public class PlayerHealthUI : MonoBehaviour
{
    Text levelText;

    Image healthSlider;

    Image expSlider;

    void Awake()
    {
        //拿到文本子节点
        levelText = transform.GetChild(2).GetComponent<Text>();
        healthSlider = transform.GetChild(0).GetChild(0).GetComponent<Image>();
        expSlider = transform.GetChild(1).GetChild(0).GetComponent<Image>();
    }

    void Update()
    {
        levelText.text = "level " + GameManager.Instance.playerStats.characterData.currentLevel.ToString("00");
        UpdateHealth();
        UpdateExp();
    }

    void UpdateHealth()
    {
        float sliderPercent = (float)GameManager.Instance.playerStats.CurrentHealth / GameManager.Instance.playerStats.MaxHealth;
        healthSlider.fillAmount = sliderPercent;
    }

    void UpdateExp()
    {
        float sliderPercent = (float)GameManager.Instance.playerStats.characterData.currentExp / GameManager.Instance.playerStats.characterData.baseExp;
        expSlider.fillAmount = sliderPercent;
    }
}

15.传送门(切换关卡)

场景内传送

  • 1.在之前Shader Graph目录下继续创建Shader,参数参考如下:
    基于Unity引擎的RPG3D项目开发笔录_第10张图片
  • 2.利用创建的Shader的基础上创建一个Meterial,并调参数。
  • 3.在层级窗口中创建一个Quad,并添加以上材质,在其下创建一个子节点 DestinationPoint 作为被传送点。
  • 4.创建 TransitionPoint.cs脚本控制传送点:(挂载在传送门父类上)
public class TransitionPoint : MonoBehaviour
{
    //传送类型(同场景,不同场景)
    public enum TransitionType
    {
        SameScene,DifferentScene
    }

    [Header("info")]
    //场景名字(如果同场景则可以不填)
    public string sceneName;
    //传送类型
    public TransitionType type;

    //传送到的目的地点
    public TransitionDestination.DestinationTags destinationTag;

    //只有该属性触发了才会传送
    private bool canTrans;

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.F) && canTrans)
        {
            //场景传送
            SceneController.Instance.TransitionToDestination(this);
        }    
    }


    void OnTriggerStay(Collider other)
    {
        if (other.CompareTag("Player"))
        {
            canTrans = true;
        }
    }

    void OnTriggerExit(Collider other)
    {
        if (other.CompareTag("Player"))
        {
            canTrans = false ;
        }
    }
}
  • 5.创建传送目标点脚本 TransitionDestination.cs:
public class TransitionDestination : MonoBehaviour
{
    public enum DestinationTags
    {
        ENTRE,A,B,C
    }

    public DestinationTags destinationTag;
}

  • 6.写场景控制脚本实现同场景传送逻辑:SceneController.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;

public class SceneController : Singleton<SceneController>
{
    Transform player;
    //玩家的预制体(加载新场景时引入)
    public GameObject playerPrefab;

    protected override void Awake()
    {
        base.Awake();
        DontDestroyOnLoad(this);
    }

    public void TransitionToDestination(TransitionPoint transitionPoint)
    {
        switch (transitionPoint.type) 
        {
            case TransitionPoint.TransitionType.SameScene:
                StartCoroutine(Transition(SceneManager.GetActiveScene().name, transitionPoint.destinationTag));
                break;
            case TransitionPoint.TransitionType.DifferentScene:
                StartCoroutine(Transition(transitionPoint.sceneName, transitionPoint.destinationTag));
                break;
        
        }

    }

    //使用携程异步加载场景
    IEnumerator Transition(string sceneName , TransitionDestination.DestinationTags destinationTag)
    {
        //TODO:保存数据

        //判断是相同场景还是不同场景
        if (SceneManager.GetActiveScene().name != sceneName)
        {
            //等待return值完成后再执行其他代码(异步加载)
            yield return SceneManager.LoadSceneAsync(sceneName);
            yield return Instantiate(playerPrefab, GetDestination(destinationTag).transform.position,GetDestination(destinationTag).transform.rotation);
            //中断携程
            yield break;
        }
        else
        {
            player = GameManager.Instance.playerStats.transform;
            player.SetPositionAndRotation(GetDestination(destinationTag).transform.position, GetDestination(destinationTag).transform.rotation);
            yield return null;
        }     
    }


    //在全部传送门中查找传送标签一样的传送门
    private TransitionDestination GetDestination(TransitionDestination.DestinationTags destinationTag)
    {
        var entrances = Resources.FindObjectsOfTypeAll<TransitionDestination>();
        for (int i = 0; i < entrances.Length; i++)
        {
            if (entrances[i].destinationTag == destinationTag)
            {
                return entrances[i];
            }
        }

        return null;
    }
}

以上代码遇到的问题:

  • 1.切换到新场景后,管理类代码全部消失,导致游戏无法正常运行,解决方法:在Manage相关代码前都加上重写的Awake()方法即可:
protected override void Awake()
{
    base.Awake();
    //意为该Manage脚本不会被销毁
    DontDestroyOnLoad(this);
}
  • 2.注意在切换至新场景之前,一定要在新场景安置 自己写的 PhotoGrapher 结点,并且在PlayerLogic初始化的时候找到摄像机别赋值完整:
	void Awake()
    {
        photographer = FindObjectOfType<Photographer>();
        rb = GetComponent<Rigidbody>();
        anim = GetComponent<Animator>();
        characterMove = GetComponent<CharacterMove>();
        photographer.InitCamera(followTarget);
        characterStats = GetComponent<CharacterStats>();
        audio = photographer.GetComponent<AudioSource>();
    }
  • 3.这样改完以后从第二场景重新返回第一场景时,会出现两个人物,并且人物在半空中。 (埋个伏笔)

16.保存数据

  • 1.使用JSON保存游戏数据,在切换场景时调用保存函数与读取函数:
  • 2.新建一个结点 SaveManager并创建脚本 SaveManager.cs挂载到结点上:
public class SaveManager : Singleton<SaveManager>
{
    protected override void Awake()
    {
        base.Awake();
        DontDestroyOnLoad(this);
    }

    //封装保存和读取玩家信息的函数
    public void SavePlayerData()
    {
        Save(GameManager.Instance.playerStats.characterData, GameManager.Instance.playerStats.characterData.name);
    }

    public void LoadPlayerData()
    {
        Load(GameManager.Instance.playerStats.characterData, GameManager.Instance.playerStats.characterData.name);
    }


    //存储数据
    public void Save(Object data,string key)
    {
        //将要存储的数值转化为JSON
        var jsonData = JsonUtility.ToJson(data,true);
        //将数据以键值对的形式保存
        PlayerPrefs.SetString(key, jsonData);
        PlayerPrefs.Save();
    }

    //加载数据
    public void Load(Object data, string key)
    {
        if (PlayerPrefs.HasKey(key))
        {
            JsonUtility.FromJsonOverwrite(PlayerPrefs.GetString(key), data);
        }
    }
}

  • 3.在切换场景时调用这个两个函数即可完成对玩家数据的保存:
    SceneController.cs
//使用携程异步加载场景
    IEnumerator Transition(string sceneName , TransitionDestination.DestinationTags destinationTag)
    {
        //TODO:保存数据
        SaveManager.Instance.SavePlayerData();

        //判断是相同场景还是不同场景
        if (SceneManager.GetActiveScene().name != sceneName)
        {
            //等待return值完成后再执行其他代码(异步加载)
            yield return SceneManager.LoadSceneAsync(sceneName);
            yield return Instantiate(playerPrefab, GetDestination(destinationTag).transform.position,GetDestination(destinationTag).transform.rotation);
            //读取数据
            SaveManager.Instance.LoadPlayerData();

            //中断携程
            yield break;
        }
        else
        {
            player = GameManager.Instance.playerStats.transform;
            player.SetPositionAndRotation(GetDestination(destinationTag).transform.position, GetDestination(destinationTag).transform.rotation);
            yield return null;
        }     
    }
  • 4.切记勿忘在新场景创建角色UI。

17.主菜单的制作

  • 1.制作UI,按钮,标题,背景等等
  • 2.分别实现退出游戏,新的游戏和继续游戏的脚本功能:
    – 1.退出游戏
public class MainManu : MonoBehaviour
{
    Button quitBtn;

    void Awake()
    {
        quitBtn = transform.GetChild(3).GetComponent<Button>();

        quitBtn.onClick.AddListener(QuitGame);
    }

    void QuitGame()
    {
        Application.Quit();
        Debug.Log("退出游戏");
    }
}

– 2.新的游戏:
1).清除之前的数据
2).在游戏控制中添加寻找全图起点的方法:
GameManager.cs:

 //寻找入口的函数
    public Transform GetEntrance()
    {
        foreach (var item in FindObjectsOfType<TransitionDestination>())
        {
            if (item.destinationTag == TransitionDestination.DestinationTags.ENTRE)
            {
                return item.transform;
            }
        }
        return null;
    }

2).切换到第一场景的某点(在场景控制中使用携程切换场景)

public void TransitionToFirstLevel()
    {
        StartCoroutine(LoadLevel("City"));
    }

    IEnumerator LoadLevel(string scene)
    {
        if (scene != "")
        {
            yield return SceneManager.LoadSceneAsync(scene);
            yield return player = Instantiate(playerPrefab, GameManager.Instance.GetEntrance().position, GameManager.Instance.GetEntrance().rotation).transform;

            //保存数据
            SaveManager.Instance.SavePlayerData();
            yield break;
        }
        
    }

– 3.继续游戏的实现:
1).在保存函数里加入保存当前地图逻辑

public class SaveManager : Singleton<SaveManager>
{
    //当前所在的场景
    private string sceneName = "";

    public string SneceName { get { return PlayerPrefs.GetString(sceneName); } }

    protected override void Awake()
    {
        base.Awake();
        DontDestroyOnLoad(this);
    }

    //封装保存和读取玩家信息的函数
    public void SavePlayerData()
    {
        Save(GameManager.Instance.playerStats.characterData, GameManager.Instance.playerStats.characterData.name);
    }

    public void LoadPlayerData()
    {
        Load(GameManager.Instance.playerStats.characterData, GameManager.Instance.playerStats.characterData.name);
    }


    //存储数据
    public void Save(Object data,string key)
    {
        //将要存储的数值转化为JSON
        var jsonData = JsonUtility.ToJson(data,true);
        //将数据以键值对的形式保存
        PlayerPrefs.SetString(key, jsonData);
        PlayerPrefs.SetString(sceneName, SceneManager.GetActiveScene().name);
        PlayerPrefs.Save();
    }

    //加载数据
    public void Load(Object data, string key)
    {
        if (PlayerPrefs.HasKey(key))
        {
            JsonUtility.FromJsonOverwrite(PlayerPrefs.GetString(key), data);
        }
    }
}

2).在场景控制里,调用协程:
SceneController.cs

public void TransitionToLoadGame()
    {
        StartCoroutine(LoadLevel(SaveManager.Instance.SceneName));
    }

3).在玩家生成的生成的时候,就读取一遍玩家数据
PlayerController.cs

	void OnEnable()
    {
        //利用单例模式调用注册玩家信息
        GameManager.Instance.RigisterPlayer(characterStats);
    }

    void Start()
    {
        SaveManager.Instance.LoadPlayerData();
        //初始化攻击冷却计时器(初始状态可攻击)
        AttackCD = -1;
    }

4).给予玩家回到Main的方式:
SceneController.cs:

	public void TransitionToMain()
    {
        StartCoroutine(LoadMain());
    }

	IEnumerator LoadMain()
    {
        yield return SceneManager.LoadSceneAsync("Main");
        yield break;
    }

5).添加补全继续游戏的调用函数
MainMenu.cs:

	void ContinueGame()
    {
        //读取进度
        SceneController.Instance.TransitionToMain();
    }

18.场景转场

  • 1.引入TimeLine窗口:Windows-->Sequencing-->Timeline

你可能感兴趣的:(游戏与梦想,unity,3d,游戏引擎,RPG)