游戏开发日志 (其二):单位选择、小地图:续

发表于: 6/12/2022

Unity

鼠标框选单位

小地图交互

如果有人看了上一篇,可能已经稍微有点感觉,我们要做的是不是RTS游戏呢?——很难说不是。

参演物体

依照惯例,首先创建一个{竞技场^Arena}作为整场游戏的地图。 Boundary作为地图边界限制物体移动。 {战舰^Warship}作为Prefab自然就是单位啦。

objects

:::default no-icon 此处也能看到在上一篇中未展示的{小地图指示器^Map Indicator} :::

框选单位

单位选择的思路

选中单位的视觉标识

单位具有被选中属性

鼠标框选

框体素材、跟随鼠标

鼠标左键点击与拖动框选的统一

被框选、点击物体的判断

单位 - Warship

给绑定到单位的脚本中添加如下内容

public bool IsSelected { get; set; } = false;

void Update()
{
    selectionIndicator.SetActive(IsSelected);
}

选择框

外观

选择框的外观经过权衡选择使用 LineRenderer 实现,将它的 Size 设置为5以容纳一个四边闭合的矩形线框,具体的点我们在脚本里计算。 给它一个白色的材质,随意设置一个你喜欢的颜色,并调整宽度到合适。 要注意的是将 ++Use World Space 取消选中++{.dot},这样绘制出来的框体就会跟随物体的位置。

selection box

现在我们来处理鼠标。

首先是左键按下,确定了一个点,随后在拖动中更新另一个点绘制线框,在鼠标抬起后确定了另一个点。

记录鼠标起点,通过判断 nullable 来确定鼠标是否已经按下。

Vector3? selection_start_mouse_position;

总是会用到的鼠标当前位置

Vector3 position = Camera.main.ScreenToWorldPoint(Input.mousePosition);

鼠标按下,激活选择框对象,记录位置,这里面的判断是为了避开上层的UI元素,你也不想点小地图就取消了当前单位的选择对吧。

if (Input.GetKeyDown(KeyCode.Mouse0))
{
    if (EventSystem.current.IsPointerOverGameObject())
        return;
    selection_start_mouse_position = position;
    selectionBox.SetActive(true);
}

鼠标拖动时,调整 LineRenderer 不必说,随后需要调整整个物体的中心位置,确保起始点不变。

if (Input.GetKey(KeyCode.Mouse0))
{
    position.z = 100;

    if (selection_start_mouse_position is Vector3 sp)
    {

        Vector3 size = position - sp;
        size.z = 0;
        var half_size = size / 2;
        selectionBox.GetComponent<LineRenderer>().SetPositions(new Vector3[]
        {
            new(half_size.x, half_size.y),
            new(half_size.x,-half_size.y),
            new(-half_size.x,-half_size.y),
            new(-half_size.x,half_size.y),
            new(half_size.x, half_size.y)
        });
        selectionBox.transform.position = position - half_size;
    }
}

在抬起鼠标时,重置大小并隐藏。

if (Input.GetKeyUp(KeyCode.Mouse0))
{
    if (selection_start_mouse_position is Vector3 sp)
    {
        selectionBox.SetActive(false);
        selectionBox.GetComponent<LineRenderer>().SetPositions(new Vector3[] { });
        selection_start_mouse_position = null;
    }
}

功能

一个很自然的想法是使用起始两点确定的矩形,判断单位是否在矩形内部,不过这样有个小问题,使用中心点判断时需要确保中心点在矩形的内部,不能轻易的实现符合视觉预期的效果,所以我选择了碰撞体。

碰撞体

在鼠标拖动时添加如下代码调整碰撞体大小,需要注意的是碰撞体的 size 参数需要保证顶点距离不能过小或为负。

if (Input.GetKey(KeyCode.Mouse0))
{
    position.z = 100;

    if (selection_start_mouse_position is Vector3 sp)
    {

        Vector3 size = position - sp;
        size.z = 0;
        var half_size = size / 2;
        selectionBox.GetComponent<LineRenderer>().SetPositions(new Vector3[]
        {
            new(half_size.x, half_size.y),
            new(half_size.x,-half_size.y),
            new(-half_size.x,-half_size.y),
            new(-half_size.x,half_size.y),
            new(half_size.x, half_size.y)
        });
        selectionBox.GetComponent<BoxCollider2D>().size = new(Max(Abs(size.x), 0.01f), Max(Abs(size.y), 0.01f));
        selectionBox.transform.position = position - half_size;
    }
}

在鼠标抬起时,首先取消之前的选中,然后通过OverlapCollider取得重叠的碰撞体,并选中这些碰撞体对应的单位。

if (Input.GetKeyUp(KeyCode.Mouse0))
{
    if (selection_start_mouse_position is Vector3 sp)
    {
        foreach (var ship in mainPlay.selectedShips)
        {
            ship.GetComponent<Warship>().IsSelected = false;
        }
        mainPlay.selectedShips.Clear();

        List<Collider2D> shipColliders = new();

        selectionBox.GetComponent<BoxCollider2D>().OverlapCollider(
            new ContactFilter2D {
                useLayerMask = true,
                layerMask = LayerMask.GetMask("Unit")
            }, shipColliders);
        mainPlay.selectedShips = shipColliders.Select(c => c.gameObject).ToList();

        foreach (var ship in mainPlay.selectedShips)
        {
            ship.GetComponent<Warship>().IsSelected = true;
        }

        selectionBox.SetActive(false);
        selectionBox.GetComponent<LineRenderer>().SetPositions(new Vector3[] { });
        selection_start_mouse_position = null;
    }
}

:::info 此处使用了 ContactFilter 来排除其他层中的物体。 目前并未进行单位阵营归属的判断。 :::

示例

至此,框选功能完成。

小地图:续,更多的交互

在上一篇中只是实现了小地图的显示功能,下面开始完善。

显示当前镜头范围

也就是小地图上的白框,我们可以延续之前显示单位缩略图的思路,给 Main Camera 添加一个 minimap 层的子物体。

子物体

子物体

同样使用 LineRenderer 来绘制。 只需要在游戏初始化的时候设置一下。

void Start()
{
    // 获取主摄像头范围
    half_cam_size = (Camera.main.ScreenToWorldPoint(new Vector3(Screen.width, Screen.height)) - Camera.main.ScreenToWorldPoint(Vector3.zero))/2;

    GameObject.Find("Camera Range").GetComponent<LineRenderer>().SetPositions(
        new Vector3[] { 
            new(-half_cam_size.x, half_cam_size.y), 
            new(half_cam_size.x, half_cam_size.y), 
            new(half_cam_size.x, -half_cam_size.y), 
            new(-half_cam_size.x, -half_cam_size.y), 
            new(-half_cam_size.x, half_cam_size.y) 
        });
}

示例

点击小地图移动镜头

由于 Input.GetXX 会作用于全局,为了避免不必要的判断,作为 UI 组件可以使用 IPointerDownHandler 系列接口获取点击事件。

public void OnPointerDown(PointerEventData eventData)
{

    Vector2 ratio = (eventData.position - Vector2.one * 100) / 200;
    Camera.main.transform.position = new(ratio.x * mainPlay.MapSize.x, ratio.y * mainPlay.MapSize.y, -10);
}

小地图的大小目前设置为 200,故鼠标事件的位置需要减去一半大小后再除以 200 得到点击位置相对于全图的比例。 注意不要改动摄像机高度。

更进一步。

bool map_operation = false;
// Update is called once per frame
void Update()
{
    if (map_operation && Input.GetKey(KeyCode.Mouse0))
    {
        Vector3 p = Input.mousePosition;
        
        p.x = Mathf.Clamp(p.x, 0, 200);
        p.y = Mathf.Clamp(p.y, 0, 200);

        Vector2 ratio = (p - Vector3.one * 100) / 200;
        Camera.main.transform.position = new(ratio.x * mainPlay.MapSize.x, ratio.y * mainPlay.MapSize.y, -10);
    }
}

public void OnPointerDown(PointerEventData eventData)
{
    map_operation = true;
}

public void OnPointerUp(PointerEventData eventData)
{
    map_operation= false;
}