Notifications
Article
使用Unity训练AI玩《Flappy Bird》
Updated a month ago
80
0
《Flappy Bird》是一款由来自越南的独立游戏开发者Dong Nguyen所开发的作品,这款游戏最火热的时候,吸引了大量玩家沉迷其中。游戏中玩家必须控制一只小鸟,跨越由各种不同长度管道所组成的障碍。
随着人工智能时代的到来,我们可以将这项任务交给人工智能来完成。本文将介绍如何使用Unity ML-Agents机器学习代理工具训练AI玩《Flappy Bird》。
下图为训练后的AI达到的游戏水平。

构建《Flappy Bird》游戏

首先,我们需要制作简化版的《Flappy Bird》游戏。制作该游戏有很多种方法,本文选择的方法是为了让强化学习的过程尽可能清晰,而不是注重编程的最佳实践。
为了构建游戏,我们使用了FlapPyBird项目中的精灵,使用Unity就可以制作出该游戏。你可以根据本文内容从头构建游戏,或者也可以在本项目的GitHub库获取游戏成品。
下载FlapPyBird项目:
https://github.com/sourabhv/FlapPyBird
下载Flappy Agents项目:
https://github.com/xstreck1/Flappy-Agents

1.场景

首先我们需要创建一个新场景,下图为Unity中的Flappy Agents场景。背景的网格每隔1米有一个分隔线,使用准确的单位对训练过程很重要。
  • Main Camera:Main Camera用的是正交摄像机,大小设为2.56。我们将使用9:16的宽高比来模拟手机屏幕。
  • Unit:整个项目包含在Unit对象中,中心位置为(0,0)。这样做方便之后的并行训练。
  • Background:背景是个静态图片,位于Background排序图层。背景在整个游戏过程中不会移动。
  • Bird:Bird是将要训练的代理,位于Sprite 图层。
  • Colliders:该对象包含二个Box Collider,负责控制屏幕的顶部和底部边缘。
  • Bottom:该对象包含二个底部精灵,用作视觉效果。这些精灵位于Sprite图层,展示在管道前面。
  • PipeSet:PipeSet对象包含三组Pipes对象。用于查找当前位于小鸟附近并需要通过的障碍。
  • Pipes:Pipes是一对底部和顶部管道,二个管道上下对称。该对象在游戏期间会调整管道的位置,管道位于Tiles图层。
现在,我们需要一些简单的脚本来让游戏运行。

2.底部

游戏最好在固定位置进行,这样能避免运行更多实例产生的问题。小鸟会待在原有位置,世界会进行移动。为此,我们会将底部部分向左移动,这些部分离开屏幕画面后会转移到右边。
// Bottom.cs
using UnityEngine;
public class Bottom : MonoBehaviour
{
public float tileSize = 3.36f;
void LateUpdate()
{
transform.Translate(Vector3.left * Time.deltaTime);
if (transform.localPosition.x < -tileSize)
{
transform.Translate(Vector3.right * tileSize);
}
}
}
请注意:我们使用的是本地位置,这样所有坐标都会相对于Unit对象的位置。

3.管道

接下来需要移动的对象是管道。管道的行为和底部行为几乎一致,不同之处在于我们需要通过pipeVariancevalue随机设置Y轴位置,如果我们重新启动游戏,必须移动Pipes对象到它们的初始位置。
// Pipes.cs
using UnityEngine;public class Pipes : MonoBehaviour
{
const float spacing = 2f; // 管道间的水平距离
const int totalPipes = 3;
private Vector3 startPos;
public float pipeVariance = .5f; private void Awake () {
startPos = transform.localPosition;
RandomizeY();
} private void LateUpdate()
{
transform.Translate(Vector3.left * Time.deltaTime);
if (transform.localPosition.x < -spacing)
{
transform.Translate(Vector3.right *
spacing * totalPipes);
}
} public void InitialPosition()
{
transform.localPosition = startPos;
RandomizeY();
} private void RandomizeY()
{
transform.Translate(Vector3.up
* Random.Range(-pipeVariance, pipeVariance));
}
}
现在整个环境都会移动了。在进入游戏过程前,我们需要确保可以在游戏结束时重置整个环境,这部分将通过PipeSet对象实现。

4.PipeSet

在训练阶段,我们还要使用一个函数,用来提供下一个需要通过的管道位置。
由于管道宽度为0.5m,而小鸟宽度为0.1m,我们可以确定当管道距离小鸟左侧(0.5+0.1)/2=0.3m时,它们不会互相碰撞,后续障碍是下一个管道,此时该管道距离小鸟右侧1.7m。这意味着该管道的最左侧坐标是1.7-(0.5/2) = 1.45。
屏幕宽度是2.88m,因此最右边的可见坐标为1.44,因此我们的解决方案能在下一管道进入视图时注意到该管道的位置。
// PipeSet.cs
using UnityEngine;public class PipeSet : MonoBehaviour
{
public void ResetPos()
{
foreach (Transform child in transform)
{
child.GetComponent<Pipes>().InitialPosition();
}
} public Transform GetNextPipe()
{
float leftMost = float.MaxValue;
Transform leftChild = null;
foreach (Transform child in transform)
{
if (child.localPosition.x < leftMost &&
child.localPosition.x > -.3f)
{
leftChild = child;
leftMost = child.localPosition.x;
}
}
return leftChild;
}
}

5.BirdBasic

现在我们要处理Bird对象。基本上我们只需要检查碰撞,并确定在碰撞后是否重置位置。
鼠标单击左键,会添加上升动力。我们也会Counter变量中计算距离。由于场景每秒移动1m,我们只需要计算时间就能测量距离。
// BirdBasic.cs
using UnityEngine;public class BirdBasic : MonoBehaviour
{
private Rigidbody2D myBody;
private Vector3 startPos;
private bool dead = false; public PipeSet pipes;
public float counter = 0f; private void Start()
{
myBody = GetComponent<Rigidbody2D>();
startPos = transform.localPosition;
} private void Update()
{
if (!dead)
{
counter += Time.deltaTime;
if (Input.GetMouseButtonDown(0))
{
Push();
}
}
else
{
ResetPos();
}
} private void OnTriggerEnter2D(Collider2D collision2d)
{
dead = true;
} public void Push()
{
myBody.AddForce(Vector2.up, ForceMode2D.Impulse);
} public void ResetPos()
{
myBody.velocity = Vector3.zero;
transform.localPosition = startPos;
dead = false;
pipes.ResetPos();
counter = 0;
}
}
注意事项:
  • 我们会在OnTrigger上检测,因为游戏不需要实际碰撞物理。因此,场景中的所有碰撞体都需要设为触发器。
  • PipeSet引用需要在编辑器中指定。
  • 我们使用RigidBody2D 实现物理效果,需要将该组件附加到游戏对象上。然后游戏过程会由该刚体控制,重量越小,上升动力越大,重力比例越小,小鸟下落速度越慢。本示例中,我们将重量和重力设为0.3。
现在我们得到了可以运行的《Flappy Bird》游戏,现在我们可以自己玩玩这个游戏,接下来我们将让机器接管游戏。

开发代理

我们将通过使用强化学习,训练小鸟自动飞过障碍。我们需要安装Unity ML-Agents,Python,TensorFlow和TensorFlowSharp。
下面是安装和配置参考:
  • Mac下配置Unity机器学习代理工具
  • 配置Unity机器学习代理工具和TensorFlow环境(Windows 10)

1.学院脚本

第一步要创建新的学院(Academy)脚本。
在本项目中,我们可以使用预制BasicAcademy组件。BasicAcademy组件组件用于配置训练过程,应将该组件指定到一个位于场景根目录的空白对象上。
指定好组件后,我们将在检视窗口看到多个配置选项,展开Training Configuration部分,并将Time Scale设为10,这样会让训练过程的速度是正常游戏的10倍。

2.大脑组件

学院必须带有接收大脑(Brain)组件信息的子对象。Brain组件会在Unity中控制训练过程和游戏过程。创建代理后,我们会配置大脑。将该游戏对象命名为FlappyBrain,以便之后使用。
我们要将Bird对象转换为代理。代理是ML-Agents训练过程的基本单元,它是个能观察游戏世界、训练和做决策的组件。为此,我们需要使Bird脚本继承自Agent 而不是MonoBehaviour。接下来是新Bird对象的三个重要区别。

3.动作

逻辑不再发生在Update函数中,而是发生在AgentAction函数。
private bool screenPressed = false;
public override void AgentAction(
float[] vectorAction,
string textAction)
{
if (dead)
{
SetReward(-1f);
Done();
}
else
{
SetReward(0.01f); int tap = Mathf.FloorToInt(vectorAction[0]);
if (tap == 0)
{
screenPressed = false;
}
if (tap == 1 && !screenPressed)
{
screenPressed = true;
Push();
}
}
}

这部分是代理行为的核心内容,代理将在此做决策。每个代理步骤都会从神经网络接收一个动作向量,并由代理处理该向量。如果小鸟拍打翅膀的动作,我们会获取 vectorAction[0]的小数部分,如果该值为1,就让小鸟拍打翅膀。
由于鼠标按下事件不会被处理,我们需要强制释放按键。为此,我们使用ScreenPressed字段,它会在没有拍打翅膀动作时重置。
最后是最重要的奖励过程。如果Bird对象与管道碰撞,我们将奖励设为-1。否则我们会在训练的每个步骤设置0.01的奖励。
在强化学习过程中,代理的目标是最大化奖励,即做出赢得更高奖励的行为,而不是得到较低奖励的行为。奖励的距离数值需要由开发者选择,这些值被称为超参数(hyperparameters),选择合适的超参数是强化学习过程的核心要素。

4.重置脚本

当Bird对象发生碰撞时,我们会调用Done() 函数,该函数会重置环境。该调用由AgentReset()函数接收,它会替换ResetPos()函数。
public override void AgentReset()
{
myBody.velocity = Vector3.zero;
transform.localPosition = startPos;
dead = false;
pipes.ResetPos();
counter = 0f;
}

5.观测值

最后需要描述环境的当前状态,我们会提供下面信息:
  • Bird对象的Y轴位置
  • Bird对象的Y轴速度
  • 当前上管道的底部位置
  • 当前下管道的顶部位置
  • 小鸟最后动作是否是拍打翅膀
const float height = 2f; //从中心到顶部或底部的距离
const float pipeSpace = .6f; // 管道在Y轴被偏移0.6m
public override void CollectObservations()
{
AddVectorObs(gameObject.transform.localPosition.y / height);
AddVectorObs(Mathf.Clamp(myBody.velocity.y, -height, height)
/ height);
Vector3 pipePos = pipes.GetNextPipe().localPosition;
AddVectorObs((pipePos.y - pipeSpace) / height);
AddVectorObs((pipePos.y + pipeSpace) / height);
AddVectorObs(screenPressed ? 1f : -1f);
}
我们通过用距离除以高度将所有数值限制在-1到1的范围。该过程称为归一化,这将有助于提升算法的性能。
这便是我们需要的观测值。以下是Bird.cs脚本的完整代码,请将该脚本添加到Bird游戏对象上而不是BirdBasic组件上。
// Bird.cs
using MLAgents;
using UnityEngine;public class Bird : Agent
{
private Rigidbody2D myBody;
private Vector3 startPos;
private bool dead = false; private bool screenPressed = false;
const float height = 2f;
const float pipeSpace = .6f; public PipeSet pipes;
public float counter = 0f; private void Update()
{
counter += Time.deltaTime;
} private void Start()
{
myBody = GetComponent<Rigidbody2D>();
startPos = transform.localPosition;
} private void Push()
{
myBody.AddForce(Vector2.up, ForceMode2D.Impulse);
} public override void CollectObservations()
{
AddVectorObs(gameObject.transform.localPosition.y / height);
AddVectorObs(Mathf.Clamp(myBody.velocity.y, -height, height)
/ height);
Vector3 pipePos = pipes.GetNextPipe().localPosition;
AddVectorObs((pipePos.y - pipeSpace) / height);
AddVectorObs((pipePos.y + pipeSpace) / height);
AddVectorObs(screenPressed ? 1f : -1f);
} public override void AgentAction(
float[] vectorAction,
string textAction)
{
if (dead)
{
SetReward(-1f);
Done();
}
else
{
SetReward(0.01f); int tap = Mathf.FloorToInt(vectorAction[0]);
if (tap == 0)
{
screenPressed = false;
}
if (tap == 1 && !screenPressed)
{
screenPressed = true;
Push();
}
}
} public override void AgentReset()
{
myBody.velocity = Vector3.zero;
transform.localPosition = startPos;
dead = false;
pipes.ResetPos();
counter = 0f;
} private void OnTriggerEnter2D(Collider2D collision2d)
{
dead = true;
}
}

完成训练

开始训练前,我们设置了多个游戏副本,这些副本将并行训练,从而加速训练过程并实现多样性。我们使用15个Unit对象的副本来创建学院。
下图中为15个并行游戏,每个游戏在X轴偏移20m,在Y轴偏移8m。由于小鸟不会在X轴上移动,我们可以使场景视图一直关注整个学院。
我们现在设置并启动训练过程。首先需要使用Brain对象来描述配置,配置如下:
  • 将Space Size值设为5,对应在CollectObservations()函数中收集的5个观测值。
  • 将Space Type改为Discrete,将Branch Size设为2。对应带有二个选项的flap动作:拍打翅膀或不拍打翅膀。

1.玩家大脑

现在该系统能正常运行。我们可以通过将Brain Type设为玩家(Player)来进行测试。
为了让游戏对鼠标点击做出反应,并创建离散玩家行为,将Key设为Mouse 0,Branch Index设为0,Value设为1。通过结合上文中的代码,该大脑创建了游戏的可玩版本。

2.外部大脑

训练过程通过使用外部(External)大脑类型(Brain Type)来完成。
首先需要在ML-Agents项目的根文件夹启动命令行。
mlagents-learn config\trainer_config.yaml --train --run-id=Flappy0
如果已经正确安装环境,应该会看到Unity的Logo在几秒内弹出。在Unity中运行项目会开始学习过程,我们可以在终端看到各个奖励的生成和进展。
与此同时,我们也可以在Unity场景视图中看到所有游戏在同时进行。

3.配置

虽然系统能够很好地学习行为,但适当提高神经网络的复杂度会更好。我们在Trainer_config.yaml文件的结尾插入下面的内容:
FlappyBrain: hidden_units: 256 num_layers: 3
这样可以加倍每个图层的神经元数量,并添加一个图层,从而使系统学会更复杂的功能。我们在配置中用到了大脑游戏对象的名称,即FlappyBrain,使其匹配我们的项目。
我们保存改动,然后再次运行训练。

4.内部大脑

当训练完成时,大脑数据会创建在文件夹中,目录如下:
models/Flappy0-0/editor_FlappyAcademy_Flappy0-0.bytes
该文件包含实际训练的神经网络,我们将该文件复制到Unity项目文件夹,把大脑类型切换为内部(Internal),在Graph Model进行指定,然后运行游戏。
现在,我们将得到自己训练出的AI玩《Flappy Bird》。

5.得分

本项目中最后一项内容是计数器。如果我们想知道AI控制小鸟飞多远,可以添加画布,上面带有Text字段和以下组件:
// Counter.cs
using UnityEngine;
using UnityEngine.UI;
public class Counter : MonoBehaviour {
public Bird bird;
Text scoreText;
void Start () {
scoreText = GetComponent<Text>();
}
void Update () {
scoreText.text = Mathf.Floor(bird.counter / 2f).ToString();
}
}
在编辑器中从第一个单元指定小鸟,显示小鸟飞行的距离。我们也可以使用类似《Flappy Bird》的字体并添加Outline 组件,使游戏画面更像原版游戏。

小结

使用Unity ML-Agents机器学习代理工具训练AI玩《Flappy Bird》就介绍到这里,希望大家能学以致用,在更多的游戏创作中使用到Unity机器学习代理工具。更多Unity技术内容尽在Unity官方中文论坛(UnityChina.cn) !

小提示:Unity全球学生开发挑战赛目前正在举行中,如果在创作的参赛项目中有使用到Unity机器学习代理工具ML-Agents会在评选中有额外的加分。

Tags:
Unity China
376
Comments