代码改变世界

Unity 之 Redux 模式(第一篇)—— 人物移动

2016-12-06 12:33  软件猫  阅读(1325)  评论(1编辑  收藏  举报

作者:软件猫

日期:2016年12月6日

转载请注明出处:http://www.cnblogs.com/softcat/p/6135195.html

 

在朋友的怂恿下,终于开始学 Unity 了,于是有了这篇文章。

 

本文用一个控制小人移动的示例,讲述如何在 Unity 中实现 Redux 架构。

关于 Flux、单向数据流是什么,请参考 阮一峰 大大的文章 http://www.ruanyifeng.com/blog/2016/01/flux.html

 

Redux 是什么鬼

 

Reflux是根据 Facebook 提出的 Flux 创建的 node.js 单向数据流类库。它实现了一个简化版的 Flux 单向数据流。

如下图所示:

 小明(User)在家打游戏,边看着屏幕,边用键盘鼠标控制游戏中的人物。

 

屏幕后面有个 ViewProvider(当然,小明才不管这个)。

ViewProvider 负责两个事情:

1、每一帧渲染前,根据数据(State)更新 GameObject 中的参数。

2、获取键盘鼠标的输入,然后向 Store 发 Action,告诉 Store,小明按了键盘⬆️键

别的事情它就不管了。它不能亲自去修改 State 数据。

 

Store 也负责两件事情:

1、保存游戏的数据,这里我们叫 State。

2、建了一个处理管道,里面丢了一堆 Reducer。Action 来了以后,会丢进这个管道里。管道中的 Reducer 会判断这个 Action 自己是否关心,如果关心,则处理 Action 中承载的数据,并更新 State

 

它们两各司其职,并形成了一个单项数据流。

 

每个游戏通常只有一个 Store,集中管理游戏数据,方便 Load & Save。

Store 中的 State 是一个很大的数据树,保存了游戏中所有的数据。

通常建议这个树是扁平化的,一般只有两三层。这样在序列化和反序列化的时候可以得到更好的性能。

 

Unity 中的 GameObject 通常会对应一到多个 ViewProvider。

每个 ViewProvider 通常都会发出 Action。

每个 Action 都有对应的一到多个 Reducer 来处理数据。

 

实践1: 用常规的方式实现一个可以控制走动的小人

 

1、创建一个 Unity 2D 项目。

2、将下面的小人作为 Sprite 资源拖入 Project。

3、将小人从 Project 中拖入 Scene,并重命名为 Player。

4、设置 Position 为 0,0,0。

5、设置 Rotation 为 0,0,90,让小人面向上方。

6、选中 Player,点击菜单 Component -> Physics 2D -> Rigidbody 2D,为小人添加刚体组件。

7、创建如下脚本,并拖放到 Player 上。这段脚本用于处理 Player

using UnityEngine;
using System.Collections;

public class PlayerMovement : MonoBehaviour
{
    [SerializeField]
    float speed = 3f;

    Rigidbody2D rigid;

    float ax, ay;

    void Start ()
    {
        rigid = GetComponent<Rigidbody2D> ();
    }

    void FixedUpdate ()
    {
        getInput ();
        rotate ();
        move ();
    }

    // 获取摇杆输入
    void getInput ()
    {
        ax = Input.GetAxis ("Horizontal");
        ay = Input.GetAxis ("Vertical");
    }

    // 处理旋转
    void rotate ()
    {
        if (ax == 0 && ay == 0)
            return;

        float r = Mathf.Atan2 (ay, ax) * Mathf.Rad2Deg;

        rigid.MoveRotation (r);
    }

    // 处理移动
    void move ()
    {
        Vector2 m = new Vector2 (ax, ay);
        m = Vector2.ClampMagnitude (m, 1);

        Vector2 dest = (Vector2)transform.position + m;
        Vector2 p = Vector2.MoveTowards (transform.position, dest, speed * Time.fixedDeltaTime);

        rigid.MovePosition (p);
    }

}

我们设置了一个 speed 参数,用于设置小人行走的速度。

我们创建了 FixedUpdate 方法,接受摇杆输入数据,然后分别处理小人的转向和移动。

完成后点击 Play ,小人可以在 Game 视图中通过方向键控制移动。

 

实践2: 实现Redux模式

 

现在,我们来实现 Redux。

首先创建如下脚本文件:

文件名 描述
IAction.cs Action 接口
IReducer.cs Reducer 接口
Store.cs 存放 State,构建 Reducer 管道
State.cs State 数据的根
ViewProvider.cs PlayerViewProvider 的基类
PlayerActions.cs 存放多个 Player 相关的 Action
PlayerReducers.cs 存放多个 Player 相关的 Reducer
PlayerState.cs 保存和 Player 相关的 State
PlayerViewProvider.cs 继承 ViewProvider,实现 Action 和 Render

 

文件建好后,我们直接上代码:

 

1、IAction.cs

public interface IAction
{

}

这个比较简单,一个空接口。用于识别 Action 而已。

 

2、IReducer.cs

public interface IReducer
{
    State Reduce (State state, IAction action);
}

创建了一个接口,声明了 Reduce 方法。在 Store 管道中,循环调用所有的 Reducer,并执行这个方法。

方法传入当前的 State 和要处理的 Action。Reducer 判断如果是自己的 Action,则处理数据,并修改 State,然后将 State 返回。

注意:在 Redux 模式中,通常建议 State 是一个不变量,Reducer 并不直接修改它,而是创建一个修改过的 State 的副本,然后将其返回。

使用不变量有很多好处,比如我们可以轻松实现一个 Do - Undo 的功能。不过游戏里这个功能大多时候不太有用(特例:纸牌)

但是在游戏开发中,由于考虑到性能问题,这里还是舍弃了这个特性。

 

3、Store.cs

using UnityEngine;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;

public class Store : MonoBehaviour
{
    // 保存 State 数据
    public static State State { get; private set; }

    // Reducer 列表
    static List<IReducer> reducerList;

    // 静态构造函数
    static Store ()
    {
        State = new State ();

        // 反射获取项目中所有继承 IReducer 的类,生成实例,并加入 reducerList 列表
        reducerList = AppDomain.CurrentDomain.GetAssemblies ()
            .SelectMany (a => a.GetTypes ().Where (t => t.GetInterfaces ().Contains (typeof(IReducer))))
            .Select (t => Activator.CreateInstance (t) as IReducer)
            .ToList ();
    }

    // ViewProvider 调用 Dispatch 方法,传入 Action
    // 循环调用所有的 Reducer,传入当前的 State 与 Action
    // 将 Reducer 返回的 State 保存
    public static void Dispatch (IAction action)
    {
        foreach (IReducer reducer in reducerList) {
            State = reducer.Reduce (State, action);
        }
    }

    // 状态改变事件
    public static Action<State> StateChanged;
    public static Action<State> FixedStateChanged;

    // FixedUpdate 时执行,监测 State 是否变更,并抛出 FixedStateChanged 事件
    void FixedUpdate ()
    {
        StartCoroutine (AfterFixedUpdate ());
    }

    IEnumerator AfterFixedUpdate ()
    {
        yield return new WaitForFixedUpdate ();

        if (!State.IsFixedStateChanged)
            yield break;

        State.IsFixedStateChanged = false;

        if (FixedStateChanged != null)
            FixedStateChanged (State);
    }

    // LateUpdate 时执行,监测 State 是否变更,并抛出 StateChanged 事件
    void LateUpdate ()
    {
        if (!State.IsStateChanged)
            return;

        State.IsStateChanged = false;

        if (StateChanged != null)
            StateChanged (State);
    }

}

Store 负责下面的事情:

 

a、保存 State

b、创建 Reducer 管道,用于处理 Action

c、在每一个固定帧,所有的 GameObject 执行完 FixedUpdate 后,执行 AfterFixedUpdate,抛出 FixedStateChanged 事件。

详见 Unity 之 AfterFixedUpdate,在所有 GameObject FixedUpdate 后执行

 

d、在 LateUpdate 时,抛出 StateChanged 事件。

 

由于物理引擎需要使用固定帧率的 FixedUpdate,这里把 FixedStateChanged 和 StateChanged 分开,分别抛出事件。

 

4、State.cs

// State 根。用于存放其他模块定义的 State。
public class State
{
    // 变更标记。Reducer 如果更改了 State 中的数据,需要将此值设置为 True。
    public bool IsStateChanged { get; set; }

    // 物理引擎的数据变更单独记录
    public bool IsFixedStateChanged { get; set; }

    // Player 模块定义的 State
    public Player.PlayerState Player { get; private set; }

    public State ()
    {
        Player = new Player.PlayerState ();
    }
}

IsStateChanged 会被 Reducer 修改为 True。Store 会通过 IsChanged 触发 OnStateChanged 事件,并通知 ViewProvider。

同样,IsFixedStateChanged = true 会触发 OnFixedStateChanged 事件。

 

5、ViewProvider.cs

using UnityEngine;

// 继承了 MonoBehaviour,可用于附加到 GameObject 上
public class ViewProvider : MonoBehaviour
{
    // 注册 StateChanged 和 FixedStateChanged 事件
    protected virtual void Awake ()
    {
        Store.StateChanged += OnStateChanged;
        Store.FixedStateChanged += OnFixedStateChanged;
    }

    // 注销 StateChanged 和 FixedStateChanged 事件
    protected virtual void OnDestroy ()
    {
        Store.StateChanged -= OnStateChanged;
        Store.FixedStateChanged -= OnFixedStateChanged;
    }

    // 处理状态变更
    protected virtual void OnStateChanged (State state)
    {
        
    }

    // 处理物理引擎相关状态变更
    protected virtual void OnFixedStateChanged (State state)
    {

    }

}

ViewProvider 基类。注册/注销 OnStateChanged 和 OnFixedStateChanged 事件。子类可以 override 这两个方法,实现相应的游戏数据变更。

 

1-5 我们把框架搭好了,下面开始实现 PlayerMovement 。

 

6、PlayerActions.cs

using UnityEngine;

namespace Player
{
    // Player 初始化,设置坐标、旋转角度与移动速度
    public class InitAction : IAction
    {
        public Vector2 position { get; set; }

        public float rotation { get; set; }

        public float speed { get; set; }
    }

    // 移动轴
    public class AxisAction : IAction
    {
        public float x { get; set; }

        public float y { get; set; }
    }
}

两个 Action

 

7、PlayerReducers.cs

using UnityEngine;

namespace Player
{
    // 处理初始化过程
    public class InitReducer : IReducer
    {
        public State Reduce (State state, IAction action)
        {
            // 检测 action 类型是不是自己想要的,如果不是,则说明自己不需要做什么,直接返回 state 即可。
            if (!(action is InitAction))
                return state;

            InitAction a = action as InitAction;

            // 初始化 PlayerState
            state.Player.Position = a.position;
            state.Player.Rotation = a.rotation;
            state.Player.Speed = a.speed;

            return state;
        }
    }

    // 处理摇杆数据
    public class AxisReducer : IReducer
    {
        public State Reduce (State state, IAction action)
        {
            // 检测 action 类型是不是自己想要的,如果不是,则说明自己不需要做什么,直接返回 state 即可。
            if (!(action is AxisAction))
                return state;
            
            AxisAction a = action as AxisAction;

            // 如果摇杆在 0 点,则不需要处理数据,直接返回 state。
            if (a.x == 0 && a.y == 0)
                return state;

            // 根据 action 传入的摇杆数据修改 state
            float speed = state.Player.Speed;
            Vector2 position = state.Player.Position;

            // 旋转
            state.Player.Rotation = Mathf.Atan2 (a.y, a.x) * Mathf.Rad2Deg;

            // 位移
            Vector2 m = new Vector2 (a.x, a.y);
            m = Vector2.ClampMagnitude (m, 1);

            Vector2 dest = position + m;
            state.Player.Position = Vector2.MoveTowards (position, dest, speed * Time.fixedDeltaTime);

            // 每次修改 state 之后,需要告诉 state 已经被修改过了
            state.IsFixedStateChanged = true;

            return state;
        }
    }

}

InitReducer:读取了游戏的初始化数据,并传给State。它并不知道初始化数据是从哪里来的(也许是某个xml,或者来自网络),只管自己执行初始化动作。

AxisReducer:我们把 PlayerMovement 中的代码搬了过来。

 

8、PlayerState.cs

using UnityEngine;

namespace Player
{
    public class PlayerState
    {
        // 玩家坐标
        public Vector2 Position { get; set; }

        // 玩家面向的方向
        public float Rotation { get; set; }

        // 移动速度
        public float Speed { get; set; }
    }
}

这个文件写好后,在 State 中加入 PlayerState 类型的属性,并在 State 构造函数中初始化。

 

9、PlayerViewProvider.cs

using UnityEngine;

namespace Player
{
    public class PlayerViewProvider: ViewProvider
    {
        [SerializeField]
        float speed = 3f;

        Rigidbody2D rigid = null;

        void Start ()
        {
            rigid = GetComponent<Rigidbody2D> ();

            // 执行初始化
            Store.Dispatch (new InitAction () {
                position = transform.position,
                rotation = transform.rotation.eulerAngles.z,
                speed = this.speed,
            });
        }

        void FixedUpdate ()
        {
            // 获取轴数据,并传递 Action
            float ax = Input.GetAxis ("Horizontal");
            float ay = Input.GetAxis ("Vertical");

            if (ax != 0 || ay != 0) {
                Store.Dispatch (new AxisAction () { x = ax, y = ay });
            }
        }
            
        protected override void OnFixedStateChanged (State state)
        {
            if (rigid != null) {
                // 刚体旋转和移动
                rigid.MoveRotation (state.Player.Rotation);
                rigid.MovePosition (state.Player.Position);
            }
        }

    }
}

最终,我们通过 PlayerViewProvider 将上面所有的代码连起来。

在 Start 时初始化数据,这里我们是直接取的 Unity 编辑器中的数据。真实游戏数据会来自网络或游戏存档。

在 FixedUpdate 时获取移动轴数据,然后执行 Action。

在 OnFixedStateChanged 中改变刚体数据。

 

脚本写好后,我们创建一个空 GameObject,重命名为 Store,拖入 Store 脚本。

然后把 PlayerViewProvider 拖到 Player 这个 GameObject 上,并关掉实践1中的 PlayerMovement。

执行游戏!大功告成!

 

重要!这一篇旨在说明 Redux 模式。实际开发中,Rigidbody2D.MovePosition 会根据碰撞物来决定最终的 Position 和 Rotation。在下一篇,我们会针对这个问题进行改造。