游戏开发日志 (其三):单位移动

发表于: 6/16/2022

Unity

单位移动

!!我还真时跟对象没有缘分,这几天的闷头搞事竟然还算摸到了一点ECS的边。选语言也是放着一众OOP不管一头扎进了函数式的坑,偏偏玩的还很菜。!!{.blur}

移动

:::default no-icon 本来以为近期可能不太有什么可以说道的事了,还是低估了游戏开发的奥妙,从头搭建一个世界真的是塔诺西呐。 :::

移动这个过程首先是转向,面向要前往的方向,开始前进是一个很自然,很符合直觉的过程,所以这里我也加上了需要转过身来才能移动的限制。

:::info 这里直接使用了我觉得很妙的组织方法,如果说可能有潜在的性能问题,应该也能方便的转写成ECS的模式(大概) :::

// Unity 会自动为我们添加依赖的 Rigidbody 组件
[RequireComponent(typeof(Rigidbody2D))]
public class Movable : MonoBehaviour
{
    Rigidbody2D rigid;
    // 可移动单位的属性
    float moveSpeed = 300;
    float rotateSpeed = 800;
    // 移动过程的参数
    Vector2 destination;
    Vector2 direction => destination - rigid.position;
    bool isMoving = false;
    // 一些温暖人心的状态判断
    public bool IsIdling => !isMoving;
    public bool IsMoveFinished(Vector2 destination) => !isMoving && rigid.position == destination;
    // 对外放出的功能
    public void MoveTo(Vector2 destination) {
        this.destination = destination;
        isMoving = true;
    }
    public void Interupt()
    {
        isMoving = false;
    }
    public void Resume()
    {
        isMoving = true;
    }

    // Use this for initialization
    void Start()
    {
        rigid = GetComponent<Rigidbody2D>();
    }

    // Update is called once per frame
    void Update()
    {
        if (isMoving)
        {
            // 首先旋转
            var lookRotation = Quaternion.LookRotation(Vector3.forward, direction);
            // 计算当前帧应该进行的转动
            var rotation = Quaternion.RotateTowards(transform.rotation, lookRotation, Time.deltaTime * rotateSpeed);
            // 当前方向与目标的夹角
            float angle = Quaternion.Angle(lookRotation, transform.rotation);
            // 转身,同时碰撞带来的避免不必要的乱转
            if(angle != 0)
            {
                rigid.freezeRotation = false;
                transform.rotation = rotation;
            }else rigid.freezeRotation = true;
            // 当角度小于15度时可以移动
            if(angle <= 15)
            {
                Vector2 move = Vector2.MoveTowards(rigid.position, destination, Time.deltaTime * moveSpeed*0.02f);
                rigid.position = move;
                // 到达目的地
                if(destination == rigid.position)
                {
                    isMoving = false;
                }
            }
        }
    }
}

这样一个独立的移动组件就完成了,当后面需要进行寻路等一大堆让人头大的操作时就可以方便的重写了! 同样得益于独立性,可以自由的想让谁移动就让谁移动了。


然而事情似乎也并没有如此简单,指令——移动、攻击、巡逻,是要有一个队列的。而后两种指令,也都是在移动的基础上添加的变化。

指令队列

指令

首先考虑一个抽象的指令,仅需要它的目的地,和判断是否生成一个后继指令就够了。

public interface InstructionIntent
{
    Vector2 Destination { get; }
    bool Derive(out InstructionIntent derived);
}

这样移动和巡逻指令很简单:

public struct MoveIntent : InstructionIntent
{
    Vector2 position;
    public Vector2 Destination => position;
    public MoveIntent(Vector2 position) { this.position = position; }

    public bool Derive(out InstructionIntent derived)
    {
        derived = null;
        return false;
    }
}
public struct PatrolIntent : InstructionIntent
{
    Vector2 from;
    Vector2 to;
    public Vector2 Destination => to;
    public PatrolIntent(Vector2 from, Vector2 to) { 
        this.from = from;
        this.to = to;
    }
    public bool Derive(out InstructionIntent derived)
    {
        derived = new PatrolIntent(to, from);
        return true;
    }
}

由于巡逻到目标点后,显然根据现实经验我们需要返回原点,所以指令完成后需要产生出新的指示返回的指令。

:::info 攻击指令涉及到另外的攻击组件,恐怕不能如此简化,不过暂且先放在这里。 :::

public struct AttackIntent : InstructionIntent
{
    GameObject target;
    public Vector2 Destination=>target.GetComponent<Transform>().position;
    public AttackIntent(GameObject target) { this.target = target; }

    public bool Derive(out InstructionIntent derived)
    {
        derived = null;
        return false;
    }
}

队列

public Queue<InstructionIntent> instructions = new();
public Stack<InstructionIntent> patrolSencondaryInstructions = new();

为了应对一连串的巡逻指令,实现逐点往返,很明显能想到这是一个栈,在当前指令队列全部完成后,将栈倒进队列,就实现了往复循环。

// 如果指令队列里存在指令,处理指令
if(instructions.TryPeek(out var current))
{
    
    if(movableUnit.IsMoveFinished(current.Destination))
    {// 如果已到达目的地
        
        // 是否有派生移动
        if (current.Derive(out var next))
        {
            patrolSencondaryInstructions.Push(next);
        }
        instructions.Dequeue();
        // 如果完成移动后队列为空,查看派生移动栈
        if (instructions.Count == 0)
        {
            while (patrolSencondaryInstructions.TryPop(out var i))
            {
                instructions.Enqueue(i);
            }
        }
    }
    else if(movableUnit.IsIdling)
    {// 移动
        movableUnit.MoveTo(current.Destination);
    }
}

随后检测地图上的鼠标右键事件,队列清空,停止移动,随后放入新指令。

// move or attack
if (Input.GetKeyDown(KeyCode.Mouse1))
{
    if (EventSystem.current.IsPointerOverGameObject())
        return;
    mainPlay.selectedShips.ForEach(o =>
    {
        o.GetComponent<Warship>().instructions.Clear();
        o.GetComponent<Warship>().Stop();
        o.GetComponent<Warship>().instructions.Enqueue(new MoveIntent(position));
    });
}

:::default 此处并未判断队列输入,不过并不复杂。 :::

分散队形