上一节,我们讲解了如何创建游戏地图、目的地和墙的放置、寻路。本节将重点学习如何在地图中添加出生点、敌人,并让敌人沿着规划路径前往离他最近的目的地。
敌人(Enemies)
1.出生点(Spawn Points)
在产生敌人之前,我们需要确定将敌人放置在板上的哪个位置。 所以需要创建一个出生点。
1.1.瓦片内容(Tile Content)
a.生成点是瓦片内容的另一种类型,将其添加到GameTileContentType
// 瓦片内容类型
public enum GameTileContentType{
Empty, //路
Destination, //目的地
Wall, //墙
SpawnPoint //敌人生成点
}
b.创建一个预制件以使其可视化(参考目的地预制件,改个颜色就行)
c.将对出生点的支持添加到内容工厂,并为其提供对预制件的引用。
// 瓦片内容工厂
public class GameTileContentFactory : ScriptableObject
{
...
[SerializeField]
GameTileContent spawnPointPrefab = default; // 生成点预制件
...
// 生成不同类型瓦片
public GameTileContent Get(GameTileContentType type){
switch(type){
...
case GameTileContentType.SpawnPoint:
return Get(spawnPointPrefab);
}
return null;
}
...
}
1.2.切换出生点(Toggling Spawn Points)
a.与其他切换方法一样,添加一种将生成点切换到GameBoard的方法,并设置一个默认的出生点
游戏只有在有敌人的情况下才有意义,这就需要有出生点。 因此,有效的游戏面板应至少包含一个出生点。 添加敌人时,我们稍后还需要访问出生点,因此使用列表来跟踪所有带有出生点的瓦片。 切换出生点时更新列表,并防止删除最后一个出生点。
// 游戏面板对象
public class GameBoard : MonoBehaviour
{
...
List<GameTile> spawnPoints = new List<GameTile>(); //出生点列表
// 初始化方法
public void Initialize(Vector2Int size, GameTileContentFactory contentFactory){
...
// 设置出生点
ToggleSpawnPoint(tiles[0]);
}
...
// 对调瓦片的生成点<->非生成点属性
public void ToggleSpawnPoint(GameTile tile){
if(tile == null){
return;
}
if(tile.Content.Type == GameTileContentType.SpawnPoint){
if(spawnPoints.Count > 1){
spawnPoints.Remove(tile); //从列表中移除
tile.Content = contentFactory.Get(GameTileContentType.Empty);
}
}else if(tile.Content.Type == GameTileContentType.Empty){ //仅支持在空瓦片和出生地之间切换
tile.Content = contentFactory.Get(GameTileContentType.SpawnPoint);
spawnPoints.Add(tile); //添加到出生点列表
}
}
...
}
b.在Game中添加鼠标右键+Shit事件,用于设置出生点
// 游戏对象
public class Game : MonoBehaviour
{
...
void Update(){
if(Input.GetMouseButtonDown(1)){ //鼠标右键事件
if(Input.GetKey(KeyCode.LeftShift)){ //同时按下了Shift
board.ToggleSpawnPoint(board.GetTile(TouchRay)); //切换空瓦片和出生地
}else{
board.ToggleDestination(board.GetTile(TouchRay)); //切换空瓦片和目的地
}
}
...
}
}
c.运行并测试添加出生点
1.3.访问出生点(Accessing Spawn Points)
// 游戏面板对象
public class GameBoard : MonoBehaviour
{
...
// 获取单个出生点
public GameTile GetSpawnPoint(int i){
return spawnPoints[i];
}
// 获取出生点总数
public int SpawnPointCount => spawnPoints.Count;
}
2.生成敌人(Spawning Enemies)
生成敌人有点像创建瓦片内容。 我们通过工厂创建一个预制实例,然后将其放在板上。
2.1.工厂(Factories)
a.创建通用游戏对象工厂,并替换原瓦片内容工厂的MoveToFactoryScene对象移入场景方法
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
// 游戏对象工厂
public abstract class GameObjectFactory : ScriptableObject
{
Scene scene; //工厂的内容场景
// 创建预制件对象实例,并放入工厂场景
protected T CreateGameObjectInstance<T>(T prefab) where T : MonoBehaviour{
if(!scene.isLoaded){
if(Application.isEditor){ //在编辑器中时,检查场景是否存在
scene = SceneManager.GetSceneByName(name);
if(!scene.isLoaded){
scene = SceneManager.CreateScene(name);
}
}else{
scene = SceneManager.CreateScene(name);
}
}
T o = Instantiate(prefab);
SceneManager.MoveGameObjectToScene(o.gameObject, scene);
return o;
}
}
using UnityEngine;
//using UnityEngine.SceneManagement;
// 瓦片内容工厂
[CreateAssetMenu]
public class GameTileContentFactory : GameObjectFactory {
…
//Scene contentScene;
…
GameTileContent Get (GameTileContent prefab) {
GameTileContent instance = CreateGameObjectInstance(prefab);
instance.OriginFactory = this;
//MoveToFactoryScene(instance.gameObject);
return instance;
}
//void MoveToFactoryScene (GameObject o) {
// …
//}
}
b.创建EnemyFactory,通过Get方法实例化一个敌人预制件,以及一个相应的回收方法。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// 敌人预制件工厂
[CreateAssetMenu] //添加到unity create菜单
public class EnemyFactory : GameObjectFactory{
[SerializeField]
Enemy prefab = default;
// 生成敌人
public Enemy Get(){
Enemy o = CreateGameObjectInstance(prefab);
o.OriginFactory = this;
return o;
}
// 回收敌人
public void Reclaim(Enemy o){
Debug.Assert(o.OriginFactory == this, "Wrong factory reclaimed!");
Destroy(o.gameObject);
}
}
c.新的敌人类型仅需要追踪其原始工厂
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// 敌人
public class Enemy : MonoBehaviour
{
EnemyFactory originFactory;
public EnemyFactory OriginFactory{
get => originFactory;
set {
Debug.Assert(originFactory == null, "Redefined origin factory!");
originFactory = value;
}
}
}
2.2.预制体(Prefab)
敌人需要可视化,并且可以是任何东西。 我们将使用机器人,蜘蛛,鬼魂或诸如立方体之类的简单对象。 但总的来说,敌人拥有任意复杂的3D模型。
a.为敌人的预制层创建一个根对象,该根对象仅附加了Enemy组件。
b.给该对象一个Model子节点,即模型根
模型根的目的是相对于敌人的局部原点定位3D模型,因此将其视为其站立或悬停在其上方的枢轴点。 c.本例中,模型是默认比例的立方体,我将其设置为深蓝色。 使它成为模型根的子节点,并将其Y位置设置为0.25,以便它位于地面上
因此,敌人的预制件由三个嵌套对象组成:预制根,模型根和立方体。 对于简单的立方体而言,这可以认为是过渡设计了,但它可以移动和设置任何敌人的动画而不用担心其细节。
d.通过CreateAssetMenu创建一个敌人工厂并将预制件分配给它
2.3.添加敌人到地图(Placing Enemies on the Board)
为了将敌人放在面板上,游戏需要引用敌人工厂。 由于我们将需要大量敌人,因此还添加了一个生成速度的配置选项,以每秒敌人数表示。 0.1-10的范围似乎是合理的,默认值为1。
通过将速度乘以时间增量来跟踪Update中的生成进度。 如果进度超过1,则递减并通过新的SpawnEnemy方法生成敌人。 只要进度超过1,就继续执行此操作,以防速度过快且帧时间结束得太长,而产生多个敌人。
// 游戏对象
public class Game : MonoBehaviour{
...
float spawnProcess; //敌人生成进度
...
void Update(){
...
// 生成敌人
spawnProcess += spawnSpeed * Time.deltaTime;
while(spawnProcess >= 1f){
spawnProcess -= 1f;
SpawnEnemy();
}
}
// 生成敌人
void SpawnEnemy(){
GameTile spawnPoint = board.GetSpawnPoint(Random.Range(0, board.SpawnPointCount));
Enemy enemy = enemyFactory.Get();
enemy.SpawnOn(spawnPoint);
}
...
}
让SpawnEnemy从棋盘上随机获得一个生成点,并在该图块上生成一个敌人。 我们将为敌人提供一个SpawnOn方法以正确定位自身。
// 敌人
public class Enemy : MonoBehaviour
{
...
// 设置敌人到出生瓦片位置
public void SpawnOn(GameTile tile){
transform.localPosition = tile.transform.localPosition;
}
}
3.移动敌人(Moving Enemies)
一旦敌人出现,它应该开始沿着路径移动到最近的目的地。 我们必须为它们设置动画,以实现这一目标。 我们首先简单地将它们在图块之间滑动,然后使它们的移动更加复杂。
3.1.敌人集合(Enemy Collection)
a.Game添加敌人集合,并在Update中自动移动敌人
// 游戏对象
public class Game : MonoBehaviour{
...
EnemyCollection enemies = new EnemyCollection(); //敌人集合(用于控制移动等)
...
void Update(){
...
// 更新敌人位置等信息
enemies.GameUpdate();
}
// 生成敌人
void SpawnEnemy(){
...
enemies.Add(enemy); //添加到敌人集合中用于后续跟踪
}
}
b.添加EnemyCollection敌人集合,增加批量移动敌人方法
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// 敌人集合
[System.Serializable]
public class EnemyCollection{
List<Enemy> enemies = new List<Enemy>();
// 添加敌人到集合
public void Add(Enemy o){
enemies.Add(o);
}
// 更新集合中所有敌人的位置等信息
public void GameUpdate(){
for(int i=0; i<enemies.Count; i++){
if(!enemies[i].GameUpdate()){ // 敌人嗝屁了,从集合中移除并移动尾部索引减少碎片
int lastIdx = enemies.Count - 1;
enemies[i] = enemies[lastIdx];
enemies.RemoveAt(lastIdx);
}
}
}
}
c.敌人增加向前移动方法
// 敌人
public class Enemy : MonoBehaviour
{
...
// 更新敌人位置等信息(敌人死亡时返回false)
public bool GameUpdate(){
transform.localPosition += Vector3.forward * Time.deltaTime; //根据时间向前移动
return true;
}
}
d.运行,Shift+鼠标右键添加多个敌人出生点,可以看到敌人出生后固定向北移动
3.2.跟随路径(Following the Path)
我们的敌人正在前进,但他们还没有沿着路径前行。为了实现这一目标,敌人必须知道下一步要去哪里。
a.给GameTile一个公共getter属性来检索路径上的下一个瓦片
// 瓦片对象
public class GameTile : MonoBehaviour{
...
public GameTile NextTileOnPath => nextOnPath; //路径下一瓦片属性允许公开访问
...
}
b.让敌人追踪两个瓦片,这样它就不会受到路径变化的影响。还要追踪位置,这样我们就不必在每一帧中检索它们。它也需要追踪进度。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// 敌人
public class Enemy : MonoBehaviour{
...
// 位置追踪
GameTile fromTile, toTile; //瓦片从哪里来,下一步要去哪里
Vector3 fromPos, toPos; //同上的坐标
float progress; //进度
// 设置敌人到出生瓦片位置
public void SpawnOn(GameTile tile){
//transform.localPosition = tile.transform.localPosition;
Debug.Assert(tile.NextTileOnPath != null, "Nowhere to go!", this);
// 位置跟踪属性设置
fromTile = tile;
fromPos = fromTile.transform.localPosition;
toTile = tile.NextTileOnPath;
toPos = toTile.transform.localPosition;
progress = 0f;
}
// 更新敌人位置等信息(敌人死亡时返回false)
public bool GameUpdate(){
//transform.localPosition += Vector3.forward * Time.deltaTime; //根据时间向前移动
progress += Time.deltaTime;
while(progress >= 1f){
if(toTile.NextTileOnPath == null){ //没有下一瓦片,销毁敌人
OriginFactory.Reclaim(this);
return false;
}
// 更新位置跟踪关系
fromTile = toTile;
fromPos = toPos;
toTile = toTile.NextTileOnPath;
toPos = toTile.transform.localPosition;
progress -= 1f;
}
transform.localPosition = Vector3.LerpUnclamped(fromPos, toPos, progress); //为了确保移动的平顺性,我们使用进度插值
return true;
}
}
c.运行后可以看到敌人会沿着规划路径平滑移动,当然你也试下添加多个敌人出生点、目的地,并用墙阻挡他们
3.3.边到边平滑转弯(Going From Edge to Edge)
仔细观察,你会发现目前敌人在转向时,会先移动到瓦片中心后突然直角改变方向。如果敌人可以在瓦片间直接斜着或绕弧形移动会更流畅些。我们可以通过平均相邻瓦片的位置来找到它们之间的边缘点来实现。
a.通过ExitPoint属性记录下一路径相邻瓦片边缘点
// 瓦片对象
public class GameTile : MonoBehaviour
{
...
public Vector3 ExitPoint { get; private set;} //根据下一瓦片的位置,确定敌人离开当前瓦片时的离开点
...
// 设置为目的地
public void BecameDestination(){
...
ExitPoint = transform.localPosition; //目的地的出口点在其中心点
}
// 反向设置路径关系(从目的地瓦片开始)
GameTile GrowPathTo(GameTile neighbor){
...
neighbor.ExitPoint = (neighbor.transform.localPosition + transform.localPosition) * 0.5f; //平均相邻瓦片的位置来找到它们之间的边缘点
return neighbor.Content.Type != GameTileContentType.Wall ? neighbor : null;
}
...
}
b.调整敌人的行走目的地为瓦片边缘点交接
// 敌人
public class Enemy : MonoBehaviour
{
...
// 设置敌人到出生瓦片位置
public void SpawnOn(GameTile tile){
...
toPos = fromTile.ExitPoint; //当前瓦片与下一瓦片的位置边缘点中心点 //toTile.transform.localPosition;
progress = 0f;
}
// 更新敌人位置等信息(敌人死亡时返回false)
public bool GameUpdate(){
...
toPos = fromTile.ExitPoint; //当前瓦片与下一瓦片的位置边缘点中心点 //toTile.transform.localPosition;
progress -= 1f;
}
...
}
}
c.运行后可以看到,敌人在转弯时已经变得平滑
3.4.转向(Orientation)
目前敌人能沿着道路前进,但从未改变过朝向。从上图可以看到当敌人由于路径变化而转身时,它们会保持静止一秒钟后直接倒退。假如我们把方块改为怪兽的话,你一定会看到怪兽屁股朝前,倒着走路。我们需要增加保持一面朝前的的机制,就必须知道敌人沿路径前进的方向,我们在敌人中实现朝向的属性和转向的机制。
a.添加朝向枚举和方向旋转角度等扩展方法
using UnityEngine;
// 朝向
public enum Direction{ //方向枚举
North, East, South, West
}
// 转向动作
public enum DirectionChange{ //转向动作枚举
None, TurnRight, TurnLeft, TurnAround
}
// 方向类方法扩展类(类内部的static方法可自动扩展到任何对象上使用)
public static class DirectionExtensions{
// 旋转角度
static Quaternion[] rotations = {
Quaternion.identity,
Quaternion.Euler(0f, 90f, 0f),
Quaternion.Euler(0f, 180f, 0f),
Quaternion.Euler(0f, 270f, 0f)
};
// 获取指定方向的旋转角度
public static Quaternion GetRotation(this Direction direction){
return rotations[(int)direction];
}
// 获取从一个方向到下一个方向的转向动作
public static DirectionChange GetDirectionChangeTo(this Direction current, Direction next){
if(current == next){ //不转
return DirectionChange.None;
}else if(current + 1 == next || current - 3 == next){ //右转
return DirectionChange.TurnRight;
}else if(current - 1 == next || current + 3 == next){ //左转
return DirectionChange.TurnLeft;
}
return DirectionChange.TurnAround; //掉头
}
// 获取方向角度数
public static float GetAngle(this Direction direction){
return (float)direction * 90f;
}
}
b.瓦片增加路径朝向属性
// 瓦片对象
public class GameTile : MonoBehaviour
{
...
// 路径朝向
public Direction PathDirection { get; private set;} //下一瓦片的方向
...
// 反向设置路径关系(从目的地瓦片开始)
GameTile GrowPathTo(GameTile neighbor, Direction direction){
...
neighbor.PathDirection = direction; //路径朝向
return neighbor.Content.Type != GameTileContentType.Wall ? neighbor : null;
}
// 沿特定方向扩展路径
public GameTile GrowPathNorth() => GrowPathTo(north, Direction.South); //向后生长时,行走方向刚好相反
public GameTile GrowPathEast() => GrowPathTo(east, Direction.West);
public GameTile GrowPathSouth() => GrowPathTo(south, Direction.North);
public GameTile GrowPathWest() => GrowPathTo(west, Direction.East);
...
}
c.敌人出生和更新时旋转敌人朝向角度
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// 敌人
public class Enemy : MonoBehaviour
{
// 工厂反向引用
EnemyFactory originFactory;
public EnemyFactory OriginFactory{
get => originFactory;
set {
Debug.Assert(originFactory == null, "Redefined origin factory!");
originFactory = value;
}
}
// 位置追踪
GameTile tileFrom, tileTo; //瓦片从哪里来,下一步要去哪里
Vector3 positionFrom, positionTo; //同上的坐标
float progress; //进度
// 方向跟踪
Direction direction; //方向
DirectionChange directionChange; //转向动作
float directionAngleFrom, directionAngleTo; //方向度数
// 设置敌人到出生瓦片位置
public void SpawnOn(GameTile tile){
//transform.localPosition = tile.transform.localPosition;
Debug.Assert(tile.NextTileOnPath != null, "Nowhere to go!", this);
// 位置跟踪属性设置
tileFrom = tile;
tileTo = tile.NextTileOnPath;
progress = 0f;
// 初始化敌人准备状态
PrepareIntro();
}
// 敌人准备状态
void PrepareIntro(){
positionFrom = tileFrom.transform.localPosition;
positionTo = tileFrom.ExitPoint; //当前瓦片与下一瓦片的位置边缘点中心点 //tileTo.transform.localPosition;
direction = tileFrom.PathDirection; //路径方向
directionChange = DirectionChange.None; //初始状态下,敌人会从起始瓦片的中心移动到其边缘,因此不会发生方向变化
directionAngleFrom = directionAngleTo = direction.GetAngle(); //获取方向角度数
transform.localRotation = direction.GetRotation(); //旋转敌人的朝向旋转角度
}
// 更新敌人位置等信息(敌人死亡时返回false)
public bool GameUpdate(){
//transform.localPosition += Vector3.forward * Time.deltaTime; //根据时间向前移动
progress += Time.deltaTime;
while(progress >= 1f){
if(tileTo.NextTileOnPath == null){ //没有下一瓦片,销毁敌人
OriginFactory.Reclaim(this);
return false;
}
// 更新位置跟踪关系
tileFrom = tileTo;
tileTo = tileTo.NextTileOnPath;
progress -= 1f;
// 更新敌人位置、方向等
PrepareNextState();
}
transform.localPosition = Vector3.LerpUnclamped(positionFrom, positionTo, progress); //为了确保移动的平顺性,我们使用进度插值
// 使用进度差值实现平滑转向
if(directionChange != DirectionChange.None){
float angle = Mathf.LerpUnclamped(directionAngleFrom, directionAngleTo, progress);
transform.localRotation = Quaternion.Euler(0f, angle, 0f);
}
return true;
}
// 敌人行走中,更新敌人下一桢位置、方向等
void PrepareNextState(){
positionFrom = positionTo;
positionTo = tileFrom.ExitPoint; //当前瓦片与下一瓦片的位置边缘点中心点 //tileTo.transform.localPosition;
directionChange = direction.GetDirectionChangeTo(tileFrom.PathDirection);
direction = tileFrom.PathDirection; //路径方向
directionAngleFrom = directionAngleTo; //方向度数
//旋转角度
switch(directionChange){
case DirectionChange.None: PrepareForward(); break;
case DirectionChange.TurnRight: PrepareTurnRight(); break;
case DirectionChange.TurnLeft: PrepareTurnLeft(); break;
default: PrepareTurnAround(); break;
}
}
// 调整旋转角度
void PrepareForward(){
transform.localRotation = direction.GetRotation(); //旋转敌人的朝向旋转角度
directionAngleTo = direction.GetAngle(); //方向度数
}
// 万一转弯,我们不会立即旋转。 相反,必须插值到另一个角度:向右转90°,向左转90°,转弯时多180°。 To角度必须相对于当前方向,以防止由于缠绕角度而以错误的方式旋转。 我们不必担心会低于0°或高于360°,因为四元数。Euler可以解决这个问题。
void PrepareTurnRight(){
directionAngleTo = directionAngleFrom + 90f; //方向度数
}
void PrepareTurnLeft(){
directionAngleTo = directionAngleFrom - 90f; //方向度数
}
void PrepareTurnAround(){
directionAngleTo = directionAngleFrom + 180f; //方向度数
}
}
d.运行可以看到已经可以看到旋转掉头效果。不过如果遇到频繁左右转向时,会有点摇头摆尾…
4.可变敌人(Enemy Variety)
我们有一群敌人,它们都是相同的立方体,以相同的速度移动。 结果可能看起来像是一条长长的蛇,而不是单个敌人。 让我们通过随机化它们的大小,偏移量和速度使它们更加独特。
4.1.可变随机(Float Range)
随机类准备。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// 随机类
[System.Serializable]
public struct FloatRange {
[SerializeField]
float min, max;
public float Min => min;
public float Max => max;
public float RandomValueInRange {
get {
return Random.Range(min, max);
}
}
public FloatRange(float value) {
min = max = value;
}
public FloatRange (float min, float max) {
this.min = min;
this.max = max < min ? min : max;
}
}
public class FloatRangeSliderAttribute : PropertyAttribute {
public float Min { get; private set; }
public float Max { get; private set; }
public FloatRangeSliderAttribute (float min, float max) {
Min = min;
Max = max < min ? min : max;
}
}
using UnityEditor;
using UnityEngine;
// 块可视化
[CustomPropertyDrawer(typeof(FloatRangeSliderAttribute))]
public class FloatRangeSliderDrawer : PropertyDrawer {
public override void OnGUI (
Rect position, SerializedProperty property, GUIContent label
) {
int originalIndentLevel = EditorGUI.indentLevel;
EditorGUI.BeginProperty(position, label, property);
position = EditorGUI.PrefixLabel(
position, GUIUtility.GetControlID(FocusType.Passive), label
);
EditorGUI.indentLevel = 0;
SerializedProperty minProperty = property.FindPropertyRelative("min");
SerializedProperty maxProperty = property.FindPropertyRelative("max");
float minValue = minProperty.floatValue;
float maxValue = maxProperty.floatValue;
float fieldWidth = position.width / 4f - 4f;
float sliderWidth = position.width / 2f;
position.width = fieldWidth;
minValue = EditorGUI.FloatField(position, minValue);
position.x += fieldWidth + 4f;
position.width = sliderWidth;
FloatRangeSliderAttribute limit = attribute as FloatRangeSliderAttribute;
EditorGUI.MinMaxSlider(
position, ref minValue, ref maxValue, limit.Min, limit.Max
);
position.x += sliderWidth + 4f;
position.width = fieldWidth;
maxValue = EditorGUI.FloatField(position, maxValue);
if (minValue < limit.Min) {
minValue = limit.Min;
}
if (maxValue < minValue) {
maxValue = minValue;
}
else if (maxValue > limit.Max) {
maxValue = limit.Max;
}
minProperty.floatValue = minValue;
maxProperty.floatValue = maxValue;
EditorGUI.EndProperty();
EditorGUI.indentLevel = originalIndentLevel;
}
}
4.2.模型缩放(Model Scale)
我们首先调整敌人的缩放。 将比例配置选项添加到EnemyFactory。 比例范围别太大,能创建敌人的微型和巨型版本即可, 类似于0.5–1.5,默认设置为1。在Get的此范围内选择一个随机比例,并通过新的Initialize方法将其传递给敌人。
// 敌人预制件工厂
public class EnemyFactory : GameObjectFactory{
...
// 缩放比例范围
[SerializeField, FloatRangeSlider(0.5f, 2f)]
FloatRange scale = new FloatRange(1f);
// 生成敌人
public Enemy Get(){
...
o.Initialize(scale.RandomValueInRange); //随机大小
return o;
}
...
}
// 敌人
public class Enemy : MonoBehaviour{
...
// 初始化
public void Initialize(float scale){
model.localScale = new Vector3(scale, scale, scale);
}
...
}
今天我们重点学习了如何在地图中添加出生点、敌人,并让敌人沿着规划路径前往离他最近的目的地,并实现了转弯和掉头动作的插值平滑等。
下一次,我们尝试创建防御塔来攻击行走的敌人,好期待~!
yan 21.8.15
参考: