【unity实战】制作一个类帝国时代、红警——RTS战略性游戏

文章目录

先来看看实现的最终效果

在这里插入图片描述

什么是RTS游戏

当提到RTS(即实时战略)游戏时,这种类型的游戏通常强调玩家在实时环境中进行战略和战术决策的能力。这些游戏要求玩家同时管理资源、建设基地、招募和指挥军队,以达到战胜敌人的目标。以下是几个知名的RTS游戏及其例子:

1.《星际争霸II》(StarCraft II):这是一款由暴雪娱乐开发的经典RTS游戏系列。玩家可以选择三个不同的种族(人类、神族和虫族),并在一个未来宇宙中进行战斗。游戏以其复杂的战略和丰富多样的游戏机制而闻名,包括资源管理、建筑构筑、技能升级和战术指挥。

2.《红色警戒》系列(Command & Conquer):这是一系列由西木开发的RTS游戏。玩家可以扮演全球势力的指挥官,参与战略和战役,控制军队、建造基地、研究科技和使用各种武器来实现胜利。这个系列中有许多不同的游戏,如《红色警戒2》和《红色警戒3》。

3.《帝国时代》系列(Age of Empires):这是一款由微软游戏工作室开发的RTS游戏系列。该系列以历史背景为基础,玩家可以选择不同的文明(如希腊、罗马和蒙古),发展城市、建造建筑、训练军队,并进行战争与贸易。《帝国时代II》和《帝国时代III》是该系列的两个受欢迎的作品。

4.《战争三国志》系列(Total War: Three Kingdoms):这是一款由Creative Assembly开发的RTS游戏系列,以中国历史的三国时期为背景。玩家可以扮演不同的将领,统一中国、扩张领土,并进行各种政治和军事策略。该系列以其大规模战争和复杂的战略系统而闻名。

这些只是RTS游戏领域中的一小部分例子。还有许多其他优秀的游戏,如《帝国时代II:征服者》、《魔兽争霸III》和《公司·英雄》等,每个游戏都有其独特的特点和玩法。

一、两种方法实现相机的移动+旋转+缩放以及拖拽功能

前言

标准的即时战略类游戏,会有资源的采集、基地的建造等众多元素,玩家可以指挥控制独立的单位,亦或者是群组式的控制,在游戏中发挥自己战争领袖的才华,不同于第一人称主视角,以及越肩式相机,RTS游戏中的相机更加"自由”,需要可见的地图范围、缩放程度也各有不同,相机的很多控制,需要同时满足【键盘】和【鼠标】的多功能需求.

实现相机控制的两种思路
第一种:通过键盘输入控制相机
第二种:主相机为子物体,按键输入和鼠标拖拽等Raycast与偏移量控制相机

准备

为了省事,我们直接下载官方的案例进行修改,省去了搭建环境等次重要的繁琐步骤,这里主要是对制作RTS类游戏的相机控制进行讨论

【百度云】链接: https://pan.baidu.com/s/1LMT-n4SqCIGTscDKpyjCDA 密码: te3j

删除原来控制的相机移动缩放的部分,及【Chinemachine Brain组件】移除,我们要重写这一部分
在这里插入图片描述

第一种办法

1. 移动

我们要实现的是通过【键盘】来控制相机的移动,并且会通过鼠标去触碰屏幕的边缘,来进行相机的移动

1.1 代码实现,里面都写了详细的中文注释,就不过多解释了

using UnityEngine;

public class CameraController01 : MonoBehaviour
{
    
    
    private Vector3 moveInput;//接收键盘的输入量
    [SerializeField] private float panSpeed;//相机平移的速度

    [SerializeField] private float scrollSpeed;//鼠标滚动的速度

    private void Update()
    {
    
    
        HandleMovementInput();
    }

    private void HandleMovementInput()
    {
    
    
        //我们其实动态改变的是Main Camera的Trans组件的Pos
        Vector3 pos = transform.position;
		
		//接收键盘输入量
        //moveInput = new Vector3(Input.GetAxisRaw("Horizontal"), 0, Input.GetAxisRaw("Vertical"));//考虑性能,不要有太多的new
        moveInput.Set(Input.GetAxisRaw("Horizontal"), 0, Input.GetAxisRaw("Vertical"));

		//接收鼠标输入量,实现触屏幕边缘移动
        Vector2 mousePos = Input.mousePosition;
        if (mousePos.x > Screen.width * 0.9f && mousePos.x < Screen.width)
            moveInput.x = 1;
        if (mousePos.x < Screen.width * 0.1f && mousePos.x > 0)
            moveInput.x = -1;
        if (mousePos.y > Screen.height * 0.9 && mousePos.y < Screen.height)
            moveInput.z = 1;
        if (mousePos.y < Screen.height * 0.1 && mousePos.y > 0)
            moveInput.z = -1;

		//相机平移
        pos.x += moveInput.x * panSpeed * Time.deltaTime;
        pos.z += moveInput.z * panSpeed * Time.deltaTime;
        transform.position = pos;
    }
}

1.2 效果:

在这里插入图片描述

1.3 问题:

如果你想完全通过panSpeed对相机进行匀速平移的话,应该将movelnput进行归一化/向量化/矢量化,我们的向量通过「归一化」之后,才能保证相机的移动完全是通过panSpeed来控制的,因为这个例子中我们使用的是GetAxisRawGetAxisRaw返回的数值只有可能是0、-1或者1,但是如果你去使用的是GetAxis,那么在Update的方法中其实,你每一帧的os的增量,它并不是一个固定的数值,「归一化」的好处就在于,它会将movelnput的大小(Magnitude)向量的模长保证是在1

实际上我们只希望去获取moveInput这个向量的方向,而不在乎它的大小,所以呢1*N=N,所以呢我们只希望获取它的方向,从而呢完全通过panSpeed来进行匀速的控制

修改

//相机平移
pos.x += moveInput.normalized.x * panSpeed * Time.deltaTime;
pos.z += moveInput.normalized.z * panSpeed * Time.deltaTime;

2. 缩放

接着,我们来快速实现相机的缩放功能,相机的缩放,改变的其实是相机Y轴的数值,也就需要改变的是变量pos中的y轴分量
那么我们的pos中y轴分量是什么呢?其实就是我们鼠标【滚轮的输入量Input.GetAxis("Mouse ScrollWheel'")】去乘以scrollSpeed,再去乘以Time.deltaTime

2.1 代码

pos.x += moveInput.normalized.x * panSpeed * Time.deltaTime;
pos.y += Input.GetAxis("Mouse ScrollWheel") * scrollSpeed * Time.deltaTime;//Y轴滚轮输入量,实现缩放
pos.z += moveInput.normalized.z * panSpeed * Time.deltaTime;

3. 限制范围

当我们缩小到一定程度,还可以继续缩小,并且相机是可以无限范围进行平移的,它可以移动到场景之外

我们这里可以去使用一个叫做【Mathf.Clamp方法】加以限制,Clamp的原意可以表示为夹子,想象成把第一个参数夹在第二个参数和第三个参数这个范围之内

代码

pos.x = Mathf.Clamp(pos.x, -10, 10);
pos.y = Mathf.Clamp(pos.y, 5, 30);
pos.z = Mathf.Clamp(pos.z, -25,5);//根据自己的地图范围进行调整,可以设置为变量,方便修改

4. 完整代码

好了,这就是第一种「基础版」的相机移动了,主要通过【键盘的输入】以及鼠标【靠近屏幕边缘】来触发相机的移动,其中包括了相机滚轮的缩放和施加一个限制范围。

using UnityEngine;

public class CameraController01 : MonoBehaviour
{
    
    
    private Vector3 moveInput;//接收键盘的输入量
    [SerializeField] private float panSpeed;//相机平移的速度

    [SerializeField] private float scrollSpeed;//鼠标滚动的速度

    private void Update()
    {
    
    
        HandleMovementInput();
    }

    private void HandleMovementInput()
    {
    
    
        //我们其实动态改变的是Main Camera的Trans组件的Pos
        Vector3 pos = transform.position;

        //moveInput = new Vector3(Input.GetAxisRaw("Horizontal"), 0, Input.GetAxisRaw("Vertical"));//性能?
        moveInput.Set(Input.GetAxisRaw("Horizontal"), 0, Input.GetAxisRaw("Vertical"));

        Vector2 mousePos = Input.mousePosition;
        if (mousePos.x > Screen.width * 0.9f && mousePos.x < Screen.width)
            moveInput.x = 1;
        if (mousePos.x < Screen.width * 0.1f && mousePos.x > 0)
            moveInput.x = -1;
        if (mousePos.y > Screen.height * 0.9 && mousePos.y < Screen.height)
            moveInput.z = 1;
        if (mousePos.y < Screen.height * 0.1 && mousePos.y > 0)
            moveInput.z = -1;

        pos.x += moveInput.normalized.x * panSpeed * Time.deltaTime;
        pos.y += Input.GetAxis("Mouse ScrollWheel") * scrollSpeed * Time.deltaTime;//Y轴滚轮输入量
        pos.z += moveInput.normalized.z * panSpeed * Time.deltaTime;

        pos.x = Mathf.Clamp(pos.x, -10, 10);
        pos.y = Mathf.Clamp(pos.y, 5, 30);
        pos.z = Mathf.Clamp(pos.z, -25, 5);//根据自己的地图范围进行调整,可以设置为变量,方便嘛!

        transform.position = pos;
    }
}

第二种办法

我们的第二个方法,可以去通过控制【父物体Camera Rig】的移动,从而间接的让子物体的相机一起跟随着【父物体】移动、旋转等一系列的功能实现

1. 移动代码

1.1 代码,使用Lerp插值函数实现移动缓冲效果

if (Input.GetKey(KeyCode.UpArrow) || Input.GetKey(KeyCode.W))
	newPos += transform.forward * panSpeed * Time.deltaTime;//相机平移向上
if (Input.GetKey(KeyCode.DownArrow) || Input.GetKey(KeyCode.S))
    newPos -= transform.forward * panSpeed * Time.deltaTime;
if (Input.GetKey(KeyCode.RightArrow) || Input.GetKey(KeyCode.D))
    newPos += transform.right * panSpeed * Time.deltaTime;//相机平移向右
if(Input.GetKey(KeyCode.LeftArrow) || Input.GetKey(KeyCode.A))
    newPos -= transform.right * panSpeed * Time.deltaTime;
    
//transform.position = newPos;//AxisRaw / Axis
//Lerp方法:当前位置,目标位置,最大距离:速度 * 时间 =>从当前位置,到目标位置,需要多少时间到达
transform.position = Vector3.Lerp(transform.position, newPos, moveTime * Time.deltaTime);

1.2 按Shift加速

如果你觉得平移速度很慢的时候,想按下shift加快相机的平移,我们可以设置两个不同的平移速度,当我们按下shift按钮之后,赋值panSpeed = fastSpeed;

[SerializeField] private float normalSpeed, fastSpeed;

if (Input.GetKey(KeyCode.LeftShift))
	panSpeed = fastSpeed;
else
    panSpeed = normalSpeed;

2. 旋转

实际上我们需要改变的是Camera Rig游戏对象上的rotation Y的数值

在这里插入图片描述
代码实现

private Vector3 newPos;
private Quaternion newRotation;
[SerializeField] private float rotationAmount;//旋转的程度

private void Start()
{
    
    
	newPos = transform.position;
    newRotation = transform.rotation;
}
private void Update()
{
    
    
	if (Input.GetKey(KeyCode.Q))
        newRotation *= Quaternion.Euler(Vector3.up * rotationAmount);//Q:逆时针
    if (Input.GetKey(KeyCode.E))
        newRotation *= Quaternion.Euler(Vector3.down * rotationAmount);//(0,-1,0)顺时针

	//transform.position = newPos;//AxisRaw / Axis
    //Lerp方法:当前位置,目标位置,最大距离:速度 * 时间 =>从当前位置,到目标位置,需要多少时间到达
    transform.position = Vector3.Lerp(transform.position, newPos, moveTime * Time.deltaTime);

    //transform.rotation = newRotation;
    transform.rotation = Quaternion.Lerp(transform.rotation, newRotation, moveTime * Time.deltaTime);
}

3. 缩放

如果想以相机中心点来进行缩放的话,这里提供另一种思路:点击子物体Main Camera,选择Local局部坐标系、自身坐标系下,我们可以通过拖拽z轴,那实际上在Main Camera当中,就是它的z和它的y的数值发生了改变

这里要注意的是,由于Main Camera属于子物体的原因,所以这里改变的Trans组件position中的数值,它其实是属于LocalPosition,而如果一个物体它作为父物体的话,那么这个游戏对象的Trans组件中的position,才隶属于世界坐标系

也就是我刚才所说的,在Sart方法中初始的缩放应该是子物体相机的当前localPosition,并非空物体Camera Rig原点位置处,我们这里所有的缩放操作,是基于子物体Main Camera中的localPosition进行的更新

private Transform cameraTrans;//子物体~主相机Trans,要改YZ数值
[SerializeField] private Vector3 zoomAmount;//要改YZ数值,设置zoomAmount结构体中YZ的数值
private Vector3 newZoom;

private void Start()
{
    
    
    cameraTrans = transform.GetChild(0);
    newZoom = cameraTrans.localPosition;
}

private void Update()
{
    
    
	if (Input.GetKey(KeyCode.R))
        newZoom += zoomAmount;//放大功能:Y越来越小,Z越来越大
    if (Input.GetKey(KeyCode.F))
        newZoom -= zoomAmount;//缩小:Y越来越大,Z越来越小
            
	cameraTrans.localPosition = Vector3.Lerp(cameraTrans.localPosition, newZoom, moveTime * Time.deltaTime);
}

如果你想放大缩小给一个限定范围,就可以参考刚才的Clamp方法,这里就不再过多的去重复了

4. 鼠标操作

如果想通过鼠标拖动地图来进行操作相机的平移、旋转、缩放

4.1 鼠标拖拽平移代码

private Vector3 dragStartPos, dragCurrentPos;//鼠标拖拽的起始点,和鼠标拖拽的当前位置
private Vector3 rotateStart, rotateCurrent;//鼠标初始位置和当前位置,用来计算相机旋转角度

if(Input.GetMouseButtonDown(1))//鼠标按下一瞬间!
{
    
    
    Plane plane = new Plane(Vector3.up, Vector3.zero);
    Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
    float distance;
    if(plane.Raycast(ray, out distance))//out输出参数,一般方法返回一个数值,out则返回return和out数值,2个结果
    {
    
    
        dragStartPos = ray.GetPoint(distance);
    }
}

if (Input.GetMouseButton(1))//鼠标按着(当前)
{
    
    
    Plane plane = new Plane(Vector3.up, Vector3.zero);
    Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
    float distance;
    if (plane.Raycast(ray, out distance))//out输出参数,一般方法返回一个数值,out则返回return和out数值,2个结果
    {
    
    
        dragCurrentPos = ray.GetPoint(distance);

        Vector3 difference = dragStartPos - dragCurrentPos;//大家可以试试反过来写,效果雷同只是方向相反
        newPos = transform.position + difference;
    }
}

4.2 鼠标滚轮缩放

newZoom += Input.mouseScrollDelta.y * zoomAmount;

4.3 旋转

我们希望通过鼠标【中键】的拖拽,实现原本键盘实现的相机旋转的效果

if (Input.GetMouseButtonDown(2))
	rotateStart = Input.mousePosition;
if(Input.GetMouseButton(2))
{
    
    
    rotateCurrent = Input.mousePosition;
    Vector3 difference = rotateStart - rotateCurrent;

    rotateStart = rotateCurrent;//赋值最新的鼠标位置

	//x y控制水平还是垂直方向拖动控制的旋转	
    newRotation *= Quaternion.Euler(Vector3.up * -difference.x / 20);//水平方向触发旋转
    //newRotation *= Quaternion.Euler(Vector3.up * -difference.y / 20);//垂直方向
}

5. 完整代码

using System;
using UnityEngine;

public class CameraController02 : MonoBehaviour
{
    
    
    private float panSpeed;
    [SerializeField] private float moveTime;//缓冲时间,用于之后的Vector3.Lerp和Quaternion.Lerp方法/函数
    [SerializeField] private float normalSpeed, fastSpeed;

    private Vector3 newPos;
    private Quaternion newRotation;
    [SerializeField] private float rotationAmount;//旋转的程度

    private Transform cameraTrans;//子物体嘛~主相机Trans,要改YZ数值
    [SerializeField] private Vector3 zoomAmount;//要改YZ数值,设置zoomAmount结构体中YZ的数值
    private Vector3 newZoom;

    private Vector3 dragStartPos, dragCurrentPos;//鼠标拖拽的起始点,和鼠标拖拽的当前位置
    private Vector3 rotateStart, rotateCurrent;//鼠标初始位置和当前位置,用来计算相机旋转角度

    private void Start()
    {
    
    
        newPos = transform.position;
        newRotation = transform.rotation;

        cameraTrans = transform.GetChild(0);
        newZoom = cameraTrans.localPosition;
    }

    private void Update()
    {
    
    
        HandleMovementInput();//通过键盘控制相机
        HandleMouseInput();//通过鼠标控制相机
    }

    private void HandleMouseInput()
    {
    
    
        if(Input.GetMouseButtonDown(1))//鼠标按下一瞬间!
        {
    
    
            Plane plane = new Plane(Vector3.up, Vector3.zero);
            Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
            float distance;
            if(plane.Raycast(ray, out distance))//out输出参数,一般方法返回一个数值,out则返回return和out数值,2个结果
            {
    
    
                dragStartPos = ray.GetPoint(distance);
            }
        }

        if (Input.GetMouseButton(1))//鼠标按着(当前)
        {
    
    
            Plane plane = new Plane(Vector3.up, Vector3.zero);
            Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
            float distance;
            if (plane.Raycast(ray, out distance))//out输出参数,一般方法返回一个数值,out则返回return和out数值,2个结果
            {
    
    
                dragCurrentPos = ray.GetPoint(distance);

                Vector3 difference = dragStartPos - dragCurrentPos;//大家可以试试反过来写,效果雷同只是方向相反
                newPos = transform.position + difference;
            }
        }

        newZoom += Input.mouseScrollDelta.y * zoomAmount;

        if (Input.GetMouseButtonDown(2))
            rotateStart = Input.mousePosition;
        if(Input.GetMouseButton(2))
        {
    
    
            rotateCurrent = Input.mousePosition;
            Vector3 difference = rotateStart - rotateCurrent;

            rotateStart = rotateCurrent;//赋值最新的鼠标位置
            newRotation *= Quaternion.Euler(Vector3.up * -difference.x / 20);//水平方向触发旋转
            //newRotation *= Quaternion.Euler(Vector3.up * -difference.y / 20);//垂直方向
        }
    }

    private void HandleMovementInput()
    {
    
    
        if (Input.GetKey(KeyCode.LeftShift))
            panSpeed = fastSpeed;
        else
            panSpeed = normalSpeed;

        if (Input.GetKey(KeyCode.UpArrow) || Input.GetKey(KeyCode.W))
            newPos += transform.forward * panSpeed * Time.deltaTime;//相机平移向上
        if (Input.GetKey(KeyCode.DownArrow) || Input.GetKey(KeyCode.S))
            newPos -= transform.forward * panSpeed * Time.deltaTime;
        if (Input.GetKey(KeyCode.RightArrow) || Input.GetKey(KeyCode.D))
            newPos += transform.right * panSpeed * Time.deltaTime;//相机平移向右
        if(Input.GetKey(KeyCode.LeftArrow) || Input.GetKey(KeyCode.A))
            newPos -= transform.right * panSpeed * Time.deltaTime;

        if (Input.GetKey(KeyCode.Q))
            newRotation *= Quaternion.Euler(Vector3.up * rotationAmount);//Q:逆时针
        if (Input.GetKey(KeyCode.E))
            newRotation *= Quaternion.Euler(Vector3.down * rotationAmount);//(0,-1,0)顺时针

        if (Input.GetKey(KeyCode.R))
            newZoom += zoomAmount;//放大功能:Y越来越小,Z越来越大
        if (Input.GetKey(KeyCode.F))
            newZoom -= zoomAmount;//缩小:Y越来越大,Z越来越小

        //transform.position = newPos;//AxisRaw / Axis
        //Lerp方法:当前位置,目标位置,最大距离:速度 * 时间 =>从当前位置,到目标位置,需要多少时间到达
        transform.position = Vector3.Lerp(transform.position, newPos, moveTime * Time.deltaTime);

        //transform.rotation = newRotation;
        transform.rotation = Quaternion.Lerp(transform.rotation, newRotation, moveTime * Time.deltaTime);

        cameraTrans.localPosition = Vector3.Lerp(cameraTrans.localPosition, newZoom, moveTime * Time.deltaTime);
    }
}

二、箱型框选角色与角色底部光圈的放大动画

这里不详细环境搭建和人物动画的实现,我们直接下载unity官方的demo进行修改学习,你可以直接下载《项目开始版》开始学习,里面删除很多原先的功能,我们来进行重开发

【项目开始版】https://pan.baidu.com/s/17JFX1ihjQdWQn9iFqVkGGw 密码: up15

1. 首先我们希望框选中的角色人物底座位置,会有一个明显的光圈显示

在这里插入图片描述

1.1 这里我们给每个角色创建一个空物体:Selected Sprite,然后添加SpriteRenderer组件

在这里插入图片描述
在这里插入图片描述

1.2 选择贴图

在这里插入图片描述

1.3 调整角度和大小

在这里插入图片描述

1.4 调整亮度

如果觉得圈的亮度太暗,我们可以新建材质,通过控制材质颜色透明的控制亮度,最省事的方法有一个"万金油"的shader,叫做Legacy Shaders/Paticles/Additive,设置好记得先禁用SpriteRenderer组件,因为默认角色是不选中的
在这里插入图片描述

2. 接看我们来做一个可视化的,标拖拽之后可见的箱型框选的一个透明的范围

2.1 这里我创建了一个3D立方体来进行显示,也就是cube

一会儿我们会进行鼠标的拖拽,通过鼠标位置的不同,改变它的LocalScale的数值,改变这个箱型框选范围,呈现对应的形状大小
在这里插入图片描述

2.2 新建材质,控制立方体的透明的

在这里插入图片描述

2.3 角色脚底选中圈代码

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using DG.Tweening;

public class ActorVisualHandler : MonoBehaviour
{
    
    
    public SpriteRenderer spriteRenderer;

    public void Select()
    {
    
    
        spriteRenderer.enabled = true;//开启
        //使用DOTween实现动画效果
        spriteRenderer.transform.DOScale(0, 0.35f).From().SetEase(Ease.OutBack);

    }

    public void Deselect()
    {
    
    
        spriteRenderer.enabled = false;//SR组件关闭
    }

}

2.3 ActorManager代码

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

public class ActorManager : MonoBehaviour
{
    
    
    public static ActorManager instance;//单例模式

    [SerializeField] private Transform selectedArea;//框选的范围,实际是一个立方体,一会显示框选范围
    public List<Actor> allActors = new List<Actor>();//场景中所有己方角色
    [SerializeField] private List<Actor> selectedActors = new List<Actor>();//当前选中的角色(们)

    //鼠标拖拽的起始点,终点,和计算显示的框选Cube的中心位置和实际大小,用于localScale的实现
    private Vector3 dragStartPos, dragEndPos, dragCenter, dragSize;
    public LayerMask mouseDragLayerMask;

    private bool isDragging;
    public LayerMask dragSelectedLayerMask;//只框选角色,即BoxCastAll方法中的指定层

    private void Awake()
    {
    
    
        if(instance == null)
        {
    
    
            instance = this;
        }
        else
        {
    
    
            if (instance != this)
                Destroy(gameObject);
        }
        DontDestroyOnLoad(gameObject);//防止场景转换时,保持唯一性
    }

    private void Start()
    {
    
    
        selectedArea.gameObject.SetActive(false);//一开始框选是不可见的
        //所有在场景中的角色,都应该添加到allActors这个List中
        foreach(Actor actor in GetComponentsInChildren<Actor>())
        {
    
    
            allActors.Add(actor);//相当于游戏开始后完成了“注册”的工作
        }
    }

    private void Update()
    {
    
    
        MouseInput();
    }

    private void MouseInput()
    {
    
    
        if(Input.GetMouseButtonDown(0))//按下鼠标「开始拖拽」时,需要存储这个点在【世界坐标系】下的位置信息
        {
    
    
            Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
            //if(Physics.Raycast(ray, out RaycastHit raycastHit, 100, LayerMask.GetMask("Level"))) 
            if (Physics.Raycast(ray, out RaycastHit raycastHit, 100, mouseDragLayerMask))
            {
    
    
                dragStartPos = raycastHit.point;//raycastHit结构体,out是输出参数,作为方法的第二个输出使用
            }
        } 
        else if (Input.GetMouseButton(0))//「按住」鼠标左键的时候
        {
    
    
            Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
            if (Physics.Raycast(ray, out RaycastHit raycastHit, 100, mouseDragLayerMask))
            {
    
    
                dragEndPos = raycastHit.point;//raycastHit结构体,out是输出参数,作为方法的第二个输出使用
            }

            //这里我们希望的是只有拖动了一段距离以后,才会出现框选的范围,而不是细微的变化就会出现框选
            if(Vector3.Distance(dragStartPos, dragEndPos) > 1)
            {
    
    
                isDragging = true;//正在拖动
                selectedArea.gameObject.SetActive(true);//框选范围可见

                //生成一个范围大小和坐标了
                dragCenter = (dragStartPos + dragEndPos) / 2;//0, 20 -> 10 | -40, 90 -> 25
                dragSize = dragEndPos - dragStartPos;
                selectedArea.transform.position = dragCenter;
                selectedArea.transform.localScale = dragSize + Vector3.up;//提高一点框选可见范围的空中高度
            } 
        } 
        else if(Input.GetMouseButtonUp(0))
        {
    
    
            //情况1: 我们之前还在拖动,范围内全选的工作
            if(isDragging == true)
            {
    
    
                //松开dragSelectedLayerMask
                SelectActors();
                isDragging = false;
                selectedArea.gameObject.SetActive(false);
            }
            else//情况2: 我们之前其实没有在拖动,这时候鼠标松开其实纯碎只是鼠标左键的点击,可能是角色的移动、攻击/采集
            {
    
    
                SetTask();
            }
        }

        //if(UnityEngine.EventSystems.EventSystem.current.IsPointerOverGameObject())
        //{
    
    
        //    isDragging = false;
        //    selectedArea.gameObject.SetActive(false);
        //    return;
        //}
    }

    private void SetTask()
    {
    
    
        //Debug.Log("Set Task!");

        //首先要注意一点,如果我们框选空气的话,或者随便点击左键,触发这个方法都不该有什么逻辑执行
        if (selectedActors.Count == 0)
            return;

        Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
        Collider collider;
        if(Physics.Raycast(ray, out RaycastHit hitInfo, 100))
        {
    
    
            collider = hitInfo.collider;//获取射线检测到的这个点的Collider组件
            //如果射线检测到的,鼠标点的这个玩意是「地形」的话,移动到这个位置
            if(collider.CompareTag("Terrain"))
            {
    
    
                foreach(Actor actor in selectedActors)
                {
    
    
                    Ray _ray = Camera.main.ScreenPointToRay(Input.mousePosition);
                    if(Physics.Raycast(_ray, out RaycastHit raycast, 100, mouseDragLayerMask))
                    {
    
    
                        Vector3 _targetPos = raycast.point;
                        actor.SetDestination(_targetPos);
                    }
                }
            }
            //如果射线检测到的,鼠标点的这个玩意是「石头/敌人/马车」的话,采集/攻击
        }
    }

    private void SelectActors()
    {
    
    
        DeselectActors();//每次框选其实都是重新选择,所以需要删去上一次的所有选中的角色

        //Debug.Log("We have Selected Actors!!!!!!");
        //dragSize = new Vector3(Mathf.Abs(dragSize.x), 1f, Mathf.Abs(dragSize.z / 2));//这里是Z在3D世界
        dragSize.Set(Mathf.Abs(dragSize.x / 2), 1f, Mathf.Abs(dragSize.z / 2));//HalfExtent
        RaycastHit[] hits = Physics.BoxCastAll(dragCenter, dragSize, Vector3.up, Quaternion.identity, 0, dragSelectedLayerMask);
        foreach(RaycastHit hit in hits)
        {
    
    
            Actor actor = hit.collider.GetComponent<Actor>();//我们要检测,框选到的是不是Actor角色
            if(actor != null)
            {
    
    
                selectedActors.Add(actor);//将选中的角色,添加到selectedActor这个List中
                actor.visualHandler.Select();
            }
        }
    }

    private void DeselectActors()
    {
    
    
        foreach(Actor actor in selectedActors)
        {
    
    
            actor.visualHandler.Deselect();//将之前所有已经选中的Actors的下标光圈关闭
        }

        selectedActors.Clear();//Clear删除之前SelectedActors这个List中的所有元素
    }

}

挂载脚本
在这里插入图片描述

3. UI遮挡的问题

我们可以去使用EventSystem.current.IsPointerOverGameObject(),这个方法将会检测是否点击在UI上,如果拖拽的时候正好在UI上的时候,那么我们不妨让拖拽立即停止isDraggingi=false;并且框选立方体不可见,而且直接return,不执行之后所有的内容

代码

if(UnityEngine.EventSystems.EventSystem.current.IsPointerOverGameobject())
{
    
    
	isDragging false;
	selectedArea.gameobject.SetActive(false);
	return;
}

三、角色的移动、采集,鼠标与对象的高亮,以及DOTween动画解决反复按下畸形问题

1. 移动

1.1 导航烘培

先进行导航场景地图烘培,不懂得可以看我之前的文章:导航系统
在这里插入图片描述

1.2 角色移动代码

Actor代码

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;

public class Actor : MonoBehaviour
{
    
    
    [HideInInspector] public ActorVisualHandler visualHandler;
    private NavMeshAgent agent;
    private Animator animator;

    private void Start()
    {
    
    
        visualHandler = GetComponent<ActorVisualHandler>();
        agent = GetComponent<NavMeshAgent>();
        animator = GetComponentInChildren<Animator>();
    }

    private void Update()
    {
    
    
        //Animator是通过人物的速度Speed来切换不同的动画状态片段的
        animator.SetFloat("Speed", Mathf.Clamp(agent.velocity.magnitude, 0, 1));
    }

    //人物的移动, _target表示人物应该移动到的鼠标位置,由于是未知的,所以设置为参数
    public void SetDestination(Vector3 _target)
    {
    
    
        agent.SetDestination(_target);
    }

}

1.3 ActorManager代码

private void SetTask()
{
    
    
     //Debug.Log("Set Task!");

     //首先要注意一点,如果我们框选空气的话,或者随便点击左键,触发这个方法都不该有什么逻辑执行
     if (selectedActors.Count == 0)
         return;

     Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
     Collider collider;
     if(Physics.Raycast(ray, out RaycastHit hitInfo, 100))
     {
    
    
         collider = hitInfo.collider;//获取射线检测到的这个点的Collider组件
         //如果射线检测到的,鼠标点的这个玩意是「地形」的话,移动到这个位置
         if(collider.CompareTag("Terrain"))
         {
    
    
             foreach(Actor actor in selectedActors)
             {
    
    
             	actor.SetDestination(hitInfo.point);
             }
         }
         //如果射线检测到的,鼠标点的这个玩意是「石头/敌人/马车」的话,采集/攻击
     }
 }

这里做一个分段,有人可能对移动已经很熟悉了,而且移动也是比较常规的操作,所以这里放一个项目开始版供下载:
https://pan.baidu.com/s/1oI4n4KCSRUox4goPqtDdyg 密码: b1fk

2. 高亮

当我们鼠标放在游戏对象上,我们想去实现高亮的效果,并且不同资源分类还需要进行划分

如何实现高亮?其实本质改变石头材质的HDR Color数值,实现高亮效果
在这里插入图片描述
在这里插入图片描述
创建C#脚本:Resources,我们先将脚本添加到两个Prefab中,代码实现如下:

using DG.Tweening;

public enum Resourcetype {
    
     Wood, Stone }
public class Resources : MonoBehaviour
{
    
    
    [SerializeField] private Resourcetype resourceType;
    public bool isHovering;//判断采集物是否要高亮
    [SerializeField] private int bounsAmount;
    private MeshRenderer meshRenderer;
    private Color originalColor;

	private void Start()
    {
    
    
        meshRenderer = GetComponent<MeshRenderer>();
        //originalColor = meshRenderer.material.color;//获取的其实只是材质的Base Color
        originalColor = meshRenderer.material.GetColor("_EmissionColor");
    }
    
	private void OnMouseEnter()//含Collider
    {
    
    
        isHovering = true;
        meshRenderer.material.SetColor("_EmissionColor", Color.gray);
    }

	private void OnMouseExit()
    {
    
    
        isHovering = false;
        meshRenderer.material.SetColor("_EmissionColor", originalColor);
    }
}

3. 采集物和动画

这里我们会涉及到一个游戏当中非常常见的一个问题,也就是角色的攻击力,它对采集物(敌方),生命值的影响

3.1 我们创建一个Damageablel脚本

using System;

//通常情况下,这里我会使用接口
//这个案例中这个脚本会涉及字段、事件、方法,所以使用简单的类
public class Damageable : MonoBehaviour
{
    
    
    [SerializeField] private int maxHp = 100;
    private int currentHp;

    //不去使用案例中的UnityEvent
    public event Action OnHit;//只需要+=添加事件就行了
    public event Action OnDestroy;

    private void Start()
    {
    
    
        currentHp = maxHp;
    }

    public void Hit(int _damageAmount)
    {
    
    
        OnHit();//OnHit.Invoke();
        currentHp -= _damageAmount;
        if(currentHp <= 0)
        {
    
    
            OnDestroy();//调用事件
            Destroy(gameObject);
        }
    }
}

3.2 在Resources中调用

private Damageable damageable;

private void Start()
{
    
    
    damageable = GetComponent<Damageable>();
}

我们继续去写HitResources方法,将会添加到OnHit事件上,如果采集物被击中,触发OnHit事件的同时,就会调用添加在OnHit事件上的这个HitResources方法,下面也是同样一个道理,只是添加的事件变成了OnDestroy事件

比如我们想给它一个简单的小动画,可以引入DG.Tweening命名空间

 //这个方法将会+=到OnHit事件上,表示当击中目标后,调用的方法
 public void HitResources()
 {
    
    
     //每次击中后,调用一个小动画
     transform.DOShakeScale(0.5f, 0.2f, 10, 90, true);
 }

 //这个方法将会+=到OnDestroy事件上,表示目标阵亡后,调用这个方法
 public void AddResources()
 {
    
    
     Debug.Log("Resources : " + resourceType + " increase : " + bounsAmount + " !");
 }

订阅和取消事件

private void Start()
{
    
    
     damageable = GetComponent<Damageable>();
     damageable.OnHit += HitResources;
     damageable.OnDestroy += AddResources;
 }

private void OnDestroy()
{
    
    
    damageable.OnHit -= HitResources;
    damageable.OnDestroy -= AddResources;//事件的取消订阅,为了防止出现内存泄漏的问题!
}

private void Update()
{
    
    
    if (Input.GetKeyDown(KeyCode.Space))
        HitResources();//仅做测试使用
}

4. 畸变

如果我们连续多按几次以后就会发现,相同的对象在动画完成之后,却出现了不同的形状的畸变,有的马车你会看到它被压缩的极其的变形
原因就是因为我们连续的按下Space按钮调用动画的时候,我们在上一个动画还没有完成的情况下,紧接着就进行下一个动画,造成了大小畸形的变化(形状不统一是因为随机种子在影响,形状未还原是因为动画未完成又再次进行)

在这里插入图片描述
解决

那就是调用它DOTween API当中的一个叫做:transform.DOComplete();就可以了

//这个方法将会+=到OnHit事件上,表示当击中目标后,调用的方法
public void HitResources()
{
    
    
    //每次击中后,调用一个小动画
    transform.DOComplete();
    transform.DOShakeScale(0.5f, 0.2f, 10, 90, true);
}

5. 采集

也就是当鼠标点击石块,角色开始移动到附近,并且开始采集、播放攻击动画

攻击动画在合适位置添加关键帧事件
在这里插入图片描述
Actor 代码

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;

public class Actor : MonoBehaviour
{
    
    
    [HideInInspector] public ActorVisualHandler visualHandler;
    private NavMeshAgent agent;
    private Animator animator;

    //角色需要一个当前的攻击目标,可受伤的攻击目标Damageable
    private Damageable damageableTarget;
    private AnimationEventListener animationEvent;
    public int attack = 10;//角色攻击力 = 10,如果采集物的生命值=100,就要敲击10次

    private Coroutine currentTask;

    private void Start()
    {
    
    
        visualHandler = GetComponent<ActorVisualHandler>();
        agent = GetComponent<NavMeshAgent>();
        animator = GetComponentInChildren<Animator>();

        animationEvent = GetComponentInChildren<AnimationEventListener>();
        animationEvent.attackEvent.AddListener(Attack);//将Attack作为事件,在动画帧上执行
    }

    private void Update()
    {
    
    
        //Animator是通过人物的速度Speed来切换不同的动画状态片段的
        animator.SetFloat("Speed", Mathf.Clamp(agent.velocity.magnitude, 0, 1));
    }

    //人物的移动, _target表示人物应该移动到的鼠标位置,由于是未知的,所以设置为参数
    public void SetDestination(Vector3 _target)
    {
    
    
        agent.SetDestination(_target);
    }

    public void Attack()
    {
    
    
        if (damageableTarget)
            damageableTarget.Hit(attack);
    }

    //方法:采集/攻击,这个方法将会在ActorManager脚本中的另一种情况下调用
    public void AttackTarget(Damageable _target)
    {
    
    
        StopTask();//这里你要想到一个问题就是,每次采集的都是不同的,所以要忘记上次的任务

        damageableTarget = _target;
        //先走到采集物/敌人的附近,再开始采集/攻击
        currentTask = StartCoroutine(StartAttack());
    }

    IEnumerator StartAttack()
    {
    
    
        //情况1: 有点击到敌人,敌人还活着,能采集,继续采集
        while(damageableTarget)
        {
    
    
            //首先,我们要走到这个地方啊
            SetDestination(damageableTarget.transform.position);
            //那走到啥位置停下来呢?
            yield return new WaitUntil(() => agent.remainingDistance <= agent.stoppingDistance && !agent.pathPending);
            while(damageableTarget && Vector3.Distance(damageableTarget.transform.position, transform.position) < 4f)
            {
    
    
                yield return new WaitForSeconds(1);//每间隔1秒攻击一次,如果太短的话可能会在采集物消失后,多一次攻击动画
                if(damageableTarget)
                {
    
    
                    animator.SetTrigger("Attack");//调用Attack动画,采集物消失后,由于角色速度小于等于0.01,就回到Idle动画片段
                    //Instantiate(hitEffect, damageableTarget.transform.position, Quaternion.identity);//别写在这个地方
                }
            }
        }

        //情况2: 采集完了,没东西了
        currentTask = null;
    }

    private void StopTask()
    {
    
    
        damageableTarget = null;//首先,让采集目标为空
        if (currentTask != null)//停止正在手头的这份工作,即:立即停止协程的执行
            StopCoroutine(currentTask);
    }
}

ActorManager.cs中调用

private void SetTask()
{
    
    
    //首先要注意一点,如果我们框选空气的话,或者随便点击左键,触发这个方法都不该有什么逻辑执行
    if (selectedActors.Count == 0)
        return;

    Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
    Collider collider;
    if(Physics.Raycast(ray, out RaycastHit hitInfo, 100))
    {
    
    
        collider = hitInfo.collider;//获取射线检测到的这个点的Collider组件
        //如果射线检测到的,鼠标点的这个玩意是「地形」的话,移动到这个位置
        if(collider.CompareTag("Terrain"))
        {
    
    
            //...
        }
        //如果射线检测到的,鼠标点的这个玩意是「石头/敌人/马车」的话,采集/攻击
        else if(!collider.CompareTag("Player"))
        {
    
    
            Damageable damageable = collider.GetComponent<Damageable>();//获取到点击的这个含collider组件的游戏对象
            if(damageable != null)
            {
    
    
                foreach(Actor actor in selectedActors)
                {
    
    
                    actor.AttackTarget(damageable);//满足条件的选中角色,向鼠标点击的这个含Damageable脚本的游戏对象,调用AttackTarget方法
                }
            }
        }
    }
}

6. 添加粒子效果

“饱暖思淫欲”,我们开始添加一点粒子效果来丰富一下和这个场景的互动

6.1 UnityEvent事件方法实现

修改Actor.cs代码

public GameObject hitEffect;

public void AttackEffect()
{
    
    
    if (damageableTarget)
        Instantiate(hitEffect, damageableTarget.transform.position, Quaternion.identity);
}

Actor.cs前面添加事件

private void Start()
{
    
    
    animationEvent.attackEvent.AddListener(AttackEffect);
}

拖入粒子特效
在这里插入图片描述
效果
在这里插入图片描述

6.2 c#事件方法实现

我们刚才使用的是UnityEvent事件来完成的,那现在呢,我们用c#事件来看看,比如说销毁的时候啊,我们也给它来一个效果,这里我们新创建了一个脚本:DestroyVisual,将两种类型的采集物,添加到这个脚本

由于我们的事件在Damageable脚本中,我们之后的方法肯定也是添加到这个脚本的事件上的,所以,我们声明并且获取相应的引用,声明销毁的粒子效果,这里创建一个方法,作为事件响应者的事件处理器,简单的Instantiate

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

public class DestroyVisual : MonoBehaviour
{
    
    
    private Damageable damageable;
    public GameObject destroyParticle;

    private void Start()
    {
    
    
        damageable = GetComponent<Damageable>();
        damageable.OnDestroy += DestroyEffect;//事件的添加
    }

    private void OnDestroy()
    {
    
    
        damageable.OnDestroy -= DestroyEffect;//事件的取消订阅,防止内存泄漏
    }

    public void DestroyEffect()
    {
    
    
        Instantiate(destroyParticle, transform.position + Vector3.up, Quaternion.identity);
    }

}

拖入销毁粒子效果
在这里插入图片描述
效果
在这里插入图片描述

最终代码

完整版: https://pan.baidu.com/s/1alZejJUkcbwvdYJpdGxq-A 密码: s412

参考

【unity官方短视频】https://www.bilibili.com/video/BV1fy4y1J7Es
【视频】:https://www.bilibili.com/video/BV1zK4y1Q74m/?spm_id_from=333.999.0.0&vd_source=2526a18398a079ddb95468a0c73f126e
【视频】:https://www.bilibili.com/video/BV1wz4y1m7LM/?spm_id_from=333.999.0.0&vd_source=2526a18398a079ddb95468a0c73f126e
【视频】:https://www.bilibili.com/video/BV1jV411v74U/?spm_id_from=333.999.0.0&vd_source=2526a18398a079ddb95468a0c73f126e

完结

如果你有其他更好的方法也欢迎评论分享出来,当然如果发现文章中出现了问题或者疑问的话,也欢迎评论私信告诉我哦

好了,我是向宇,https://xiangyu.blog.csdn.net/

一位在小公司默默奋斗的开发者,出于兴趣爱好,于是开始自习unity。最近创建了一个新栏目【你问我答】,主要是想收集一下大家的问题,有时候一个问题可能几句话说不清楚,我就会以发布文章的形式来回答。 虽然有些问题我可能也不一定会,但是我会查阅各方资料,争取给出最好的建议,希望可以帮助更多想学编程的人,共勉~

猜你喜欢

转载自blog.csdn.net/qq_36303853/article/details/131425981