欢迎来到我的博客小站。  交流请加我微信好友: studyjava。  也欢迎关注同名公众号:Java学习之道

[转]Unity实战之塔防游戏(一)

  |   0 评论   |   0 浏览

塔防游戏非常受欢迎,也就不足为奇了。没有什么比观看防御消灭邪恶入侵者更令人满足的了!在这个分为两部分的教程中,您将使用Unity构建塔防游戏!

您将学习如何...

  • 制造敌人之波
  • 让他们跟随航点
  • 建造和升级塔,并让它们将敌人减少到像素

最后,您将拥有一个可以扩展的流派框架!

注意:您需要了解Unity基础知识,例如如何添加游戏资产和组件,了解预制件以及一些基本C#。要学习这些东西,我建议您完成Sean Duffy的Unity教程或Brian Moakley的Unity入门C系列。

我使用的是OS X版本的Unity,但本教程也适用于Windows。

象牙塔的景色

在本教程中,您将构建一个塔防游戏,敌人(小虫子)爬向属于您和您的奴才(当然是怪物)的cookie!您可以在战略要点放置和升级怪物,以获得一点金。

玩家必须先杀死bug,然后才能享用您的cookie。一波又一波的敌人越来越难击败。当您在所有海浪中幸存下来(胜利!)或五个敌人到达cookie时,游戏结束。(打败!)。

这是完成游戏的屏幕截图:

怪物们团结! 保护您的Cookie!怪物们团结!保护cookie!

入门

如果尚未安装Unity,请从Unity的网站下载。

另外,下载此启动程序项目,解压缩并在Unity中打开TowerDefense-Part1-Starter项目。

入门项目包括艺术和声音资产,以及预建的动画和一些有用的脚本。这些脚本与塔防游戏并不直接相关,因此在此不再赘述。但是,如果您想了解有关创建Unity 2D动画的更多信息,请查看此Unity 2D教程

该项目还包含预制件,您稍后将对其进行扩展以创建角色。最后,该项目包括一个场景及其背景和用户界面。

打开“场景”文件夹中的GameScene,然后将“游戏”视图的纵横比设置为4:3,以确保标签与背景正确对齐。您应该在游戏视图中看到以下内容:**

入门项目屏幕截图

学分:

入门项目–检查!
资产–检查!
走向世界统治的第一步……嗯,我的意思是你的塔防游戏……完成了!

X标记位置:展示位置

怪物只能在带有x的位置张贴。

要将这些添加到场景中,请将Image \ Objects \ Openspot项目浏览器拖放到“*场景”*视图中。目前,位置无关紧要。

随着Openspot在选定的层次,点击添加组件检查和选择框撞机2D。Unity在“场景”视图中用绿线显示框对撞机。您将使用该对撞机检测该位置上的鼠标单击。

Unity会自动检测对撞机的正确尺寸。 多么酷啊?Unity会自动检测对撞机的正确尺寸。多么酷啊?

按照相同的步骤,将音频\音频源组件添加到Openspot。设定声音来源的音频剪辑tower_place,您可以在找到的音频文件,并停用玩醒

您需要再创建11个地点。尽管很想重复所有这些步骤,但Unity为此提供了一个很好的解决方案:预制件

Openspot层次结构拖放到项目浏览器Prefabs文件夹中。然后,其名称在层次结构中变为蓝色,以表明它已连接到预制件。像这样:**

预制件

现在有了预制件,您可以根据需要创建任意数量的副本。只需将Openspot项目浏览器Prefabs文件夹拖放到场景视图中。这样做11次,以使场景中总共有12个Openspot对象。****

现在,使用检查器将这12个Openspot对象的位置设置为以下坐标:

  • (X:-5.2,Y:3.5,Z:0)
  • (X:-2.2,Y:3.5,Z:0)
  • (X:0.8,Y:3.5,Z:0)
  • (X:3.8,Y:3.5,Z:0)
  • (X:-3.8,Y:0.4,Z:0)
  • (X:-0.8,Y:0.4,Z:0)
  • (X:2.2,Y:0.4,Z:0)
  • (X:5.2,Y:0.4,Z:0)
  • (X:-5.2,Y:-3.0,Z:0)
  • (X:-2.2,Y:-3.0,Z:0)
  • (X:0.8,Y:-3.0,Z:0)
  • (X:3.8,Y:-3.0,Z:0)

完成后,您的场景应如下所示。

塔防游戏的现货位置

放置怪物

为了简化放置,该项目的Prefab文件夹包含一个Monster预制件。

Monster预制-现成可用Monster预制件-准备使用

此时,它由一个空的游戏对象组成,该对象具有三个不同的精灵,它们的射击动画作为其子对象。

每个精灵代表处于不同功率水平的怪物。预制件还包含一个音频源组件,每当怪物发射激光时,您都会触发该组件播放声音。

现在,您将创建一个脚本,该脚本可以将Monster放置在Openspot上

项目浏览器中,在Prefabs文件夹中选择Openspot。在检查器中,单击“添加组件”,然后选择“新建脚本”并将其命名为PlaceMonster。选择C Sharp作为语言,然后点击创建和添加。由于您已将脚本添加到Openspot预制中,因此场景中的所有Openspot现在都已附加了脚本。整齐!******************

双击脚本以在IDE中将其打开。然后添加以下两个变量:

public GameObject monsterPrefab;
private GameObject monster;

您将实例化一个存储在其中的对象的副本monsterPrefab以创建一个怪物,并将其存储在其中,monster以便在游戏过程中对其进行操作。

每个位置一个怪物

添加以下方法,每个位置只允许一个怪物:

private bool CanPlaceMonster()
{
return monster == null;
}

CanPlaceMonster()您检查monster变量是否仍然null。如果是这样,则表示目前没有怪物,可以放置一个。

现在添加以下代码,以在玩家单击此GameObject时实际放置怪物:

//1
void OnMouseUp()
{
  //2
  if (CanPlaceMonster())
  {
    //3
    monster = (GameObject) 
      Instantiate(monsterPrefab, transform.position, Quaternion.identity);
    //4
    AudioSource audioSource = gameObject.GetComponent<AudioSource>();
    audioSource.PlayOneShot(audioSource.clip);

    // TODO: Deduct gold
  }
}

此代码将怪物放置在鼠标单击或点击上。那么这是如何工作的呢?

  1. OnMouseUp当玩家点击GameObject的物理对撞机时,Unity会自动调用。
  2. 当被调用时,这个方法的地方,如果一个新的怪物CanPlaceMonster()回报true
  3. 使用创建怪物Instantiate,该方法创建具有指定位置和旋转的给定预制实例。在这种情况下,您复制并monsterPrefab赋予其当前GameObject的位置且不旋转,将结果转换为aGameObject并将其存储在中monster
  4. 最后,您调用PlayOneShot以播放附加到对象AudioSource组件的声音效果。

现在,您的PlaceMonster脚本可以放置一个新的怪物,但是您仍然必须指定预制件。

使用正确的预制件

保存文件,然后切换回Unity。

要分配monsterPrefab变量,请首先在项目浏览器的Prefabs文件夹中选择Openspot。**

在“检查器”中,单击PlaceMonster(脚本)组件的Monster Prefab字段右侧的圆圈,然后从出现的对话框中选择Monster

分配预制件

而已。单击或点击运行场景并在各个x点上构建怪物。

成功! 您可以建造怪物。 但是,它们看起来像个怪异的糊状,因为绘制了怪物的所有子精灵。 接下来,您将解决此问题。成功!您可以建造怪物。但是,它们看起来像个怪异的糊状,因为绘制了怪物的所有子精灵。接下来,您将解决此问题。

升级那些怪物

在下图中,您可以看到怪物在更高等级下看起来越来越恐怖。

它是如此蓬松! 但是,如果您尝试窃取其Cookie,则该怪物可能会变成真正的杀手。它是如此蓬松!但是,如果您尝试窃取其Cookie,则该怪物可能会成为杀手。

脚本充当为怪物实施升级系统的基础。它跟踪怪物在每个级别上应该有多强大,当然还要跟踪怪物的当前级别。

立即添加此脚本。

项目浏览器中选择Prefabs / Monster。添加一个名为MonsterData的新C#脚本。在IDE中打开脚本,然后在类上方添加以下代码。********MonsterData

[System.Serializable]
public class MonsterLevel
{
  public int cost;
  public GameObject visualization;
}

这创建了MonsterLevel。它将成本(用金,稍后将予以支持)和特定怪物级别的视觉表示进行分组。

[System.Serializable]在顶部添加以使该类的实例可在检查器中进行编辑。这样,即使在游戏运行时,您也可以快速更改Level类中的所有值。这对于平衡您的游戏非常有用。

定义怪物等级

在这种情况下,您将预定义的存储MonsterLevel在中List<T>

为什么不简单使用MonsterLevel[]?好吧,您将需要MonsterLevel多次特定对象的索引。尽管为此编写代码并不难,但是您将使用IndexOf(),它实现的功能Lists。这次无需重新发明轮子。:]

重新发明轮子通常是一个坏主意。 (来自)重新发明轮子通常是个坏主意(来自Michael Vroegop

MonsterData.cs的顶部,添加以下using语句:

using System.Collections.Generic;

这使您可以访问通用数据结构,因此可以List<T>在脚本中使用该类。

注意:泛型是C#的强大组成部分。它们允许您定义类型安全的数据结构而无需提交类型。这对于列表和集合之类的容器类很实用。要了解有关泛型的更多信息,请参阅《C#泛型介绍》

现在添加以下变量MonsterData来存储的列表MonsterLevel

public List<MonsterLevel> levels;;

使用泛型,可以确保levels List只能包含MonsterLevel对象。

保存文件并切换到Unity以配置每个阶段。

项目浏览器中选择Prefabs / Monster。在“检查器”中,您现在可以在MonsterData(脚本)组件中看到“色阶”字段。将其大小设置为3。**************

屏幕截图2015年7月24日上午11.26.28

接下来,将每个级别的成本设置为以下值:

  • 元素0200
  • 元素1110
  • 元素2120

现在分配可视化字段值。

在项目浏览器中展开Prefabs / Monster,以便您可以查看其子级。将子Monster0拖放Element 0可视化字段。

重复分配Monster1元1Monster2元2。请参阅以下GIF演示此过程:

分配怪物2

当您选择Prefabs / Monster时,预制件应如下所示:

在检查器中定义怪物的等级。在检查器中定义怪物的等级。

定义当前水平

在您的IDE中切换回MonsterData.cs,然后向中添加另一个变量MonsterData

private MonsterLevel currentLevel;

在私有变量中,currentLevel您将存储……等待中……怪物的当前水平。我敢打赌,您没有看到一个来临:]

现在设置currentLevel并使其可被其他脚本访问。将以下内容MonsterData以及实例变量声明添加到中:

//1
public MonsterLevel CurrentLevel
{
  //2
  get 
  {
    return currentLevel;
  }
  //3
  set
  {
    currentLevel = value;
    int currentLevelIndex = levels.IndexOf(currentLevel);

    GameObject levelVisualization = levels[currentLevelIndex].visualization;
    for (int i = 0; i < levels.Count; i++)
    {
      if (levelVisualization != null) 
      {
        if (i == currentLevelIndex) 
        {
          levels[i].visualization.SetActive(true);
        }
        else
        {
          levels[i].visualization.SetActive(false);
        }
      }
    }
  }
}

那里有相当多的C#,是吗?顺其自然:

  1. 为私有变量定义一个属性currentLevel。使用定义的属性,您可以像调用任何其他变量一样进行调用:as CurrentLevel(从类内部)或as monster.CurrentLevel(从类外部)。您可以在属性的getter或setter方法中定义自定义行为,并且通过仅提供getter,setter或两者,可以控制属性是只读,只写还是读/写。
  2. 在getter中,返回的值currentLevel
  3. 在设置器中,将新值分配给currentLevel。接下来,您将获得当前级别的索引。最后,您可以遍历所有级别并将可视化设置为活动或不活动,具体取决于currentLevelIndex。这很棒,因为这意味着只要有人设置currentLevel,精灵就会自动更新。属性一定派上用场!

添加以下实现OnEnable

void OnEnable()
{
  CurrentLevel = levels[0];
}

这会CurrentLevel根据放置情况进行设置,确保仅显示正确的精灵。

注意:初始化OnEnable而不是的属性很重要OnStart,因为在实例化预制件时调用order方法。

OnEnable在创建预制件时(如果预制件保存为启用状态)将立即调用,但OnStart直到对象开始作为场景的一部分开始运行时才调用。

在放置怪物之前,您需要检查此数据,以便在中对其进行初始化OnEnable

保存文件并切换到Unity。运行项目并放置怪物;现在它们显示正确的最低级别的精灵。

不再糊涂

升级那些怪物

切换回您的IDE并将以下方法添加到MonsterData

public MonsterLevel GetNextLevel()
{
  int currentLevelIndex = levels.IndexOf (currentLevel);
  int maxLevelIndex = levels.Count - 1;
  if (currentLevelIndex < maxLevelIndex)
  {
    return levels[currentLevelIndex+1];
  } 
  else
  {
    return null;
  }
}

如果怪物没有达到最大等级以返回下一个等级,则GetNextLevel获得的索​​引currentLevel和最高等级的索引。否则,返回null

您可以使用此方法确定是否可以升级怪物。

添加以下方法来提高怪物的等级:

public void IncreaseLevel()
{
  int currentLevelIndex = levels.IndexOf(currentLevel);
  if (currentLevelIndex < levels.Count - 1)
  {
    CurrentLevel = levels[currentLevelIndex + 1];
  }
}

在这里,您可以获得当前级别的索引,然后通过检查它是否小于来确保它不是最大级别levels.Count - 1。如果是这样,请设置CurrentLevel下一个级别。

测试升级能力

保存文件,然后在IDE中切换到PlaceMonster.cs并添加此新方法:

private bool CanUpgradeMonster()
{
  if (monster != null)
  {
    MonsterData monsterData = monster.GetComponent<MonsterData>();
    MonsterLevel nextLevel = monsterData.GetNextLevel();
    if (nextLevel != null)
    {
      return true;
    }
  }
  return false;
}

首先通过检查monster变量来检查是否存在可以升级的怪物null。如果是这种情况,您可以从怪物的当前等级获得它MonsterData

然后,您测试是否有更高级别的可用,即何时GetNextLevel()不返回null。如果可以进行升级,则返回true,否则返回false

启用黄金升级

要启用升级选项,请将else if分支添加到OnMouseUp

if (CanPlaceMonster())
{
  // Your code here stays the same as before
}
else if (CanUpgradeMonster())
{
  monster.GetComponent<MonsterData>().IncreaseLevel();
  AudioSource audioSource = gameObject.GetComponent<AudioSource>();
  audioSource.PlayOneShot(audioSource.clip);
  // TODO: Deduct gold
}

用检查是否可以升级CanUpgradeMonster()。如果是,则MonsterData使用GetComponent()和调用访问组件IncreaseLevel(),这会增加怪物的等级。最后,您触发怪物的AudioSource

保存文件,然后切换回Unity。暂时运行游戏,放置并升级尽可能多的怪物...。

升级所有怪物所有怪物升级

支付金-游戏管理员

现在可以立即构建和升级所有怪物,但是挑战在哪里呢?

让我们深入探讨黄金问题。跟踪问题是您需要在不同游戏对象之间共享信息。

下图显示了所有需要执行操作的对象。

突出显示的游戏对象都需要知道玩家拥有多少金币。突出显示的游戏对象都需要知道玩家拥有多少金币。

您将使用其他对象可访问的共享对象来存储此数据。

右键单击层次结构,然后选择创建空。将新游戏对象命名为GameManager

将名为GameManagerBehaviorC#脚本添加到GameManager,然后在IDE中打开新脚本。您将在标签中显示玩家的总金牌,因此将以下行添加到文件顶部:****

using UnityEngine.UI;

这使您可以访问特定于UI的类,例如Text,项目将其用于标签。现在将以下变量添加到类中:

public Text goldLabel;

这将存储对Text用来显示玩家拥有多少金币的组件的引用。

既然已经GameManager知道标签,那么如何确保变量中存储的金量与标签上显示的量同步?您将创建一个属性。

将以下代码添加到GameManagerBehavior

private int gold;
public int Gold {
  get
  { 
    return gold;
  }
  set
  {
    gold = value;
    goldLabel.GetComponent<Text>().text = "GOLD: " + gold;
  }
}

看起来很熟悉?类似于CurrentLevel您在中定义的Monster。首先,您创建一个私有变量gold来存储当前的黄金总量。然后定义一个名为-creative的属性Gold,对吗?-并实现一个getter和setter。

该获取器仅返回的值gold。塞特犬更有趣。除了设置变量的值,它还将text字段设置goldLabel为显示新的黄金量。

您感觉如何?添加以下行以Start()给玩家1000黄金,如果您感到不舒服,则少给黄金:

Gold = 1000;

将标签对象分配给脚本

保存文件并切换到Unity。

层次结构中,选择GameManager。在检查器中,单击“金标”右边的圆圈。在“*选择文本”*对话框中,选择“*场景”*选项卡,然后选择“ GoldLabel”

分配goldLabel

运行场景,标签显示Gold:1000

1000金

检查玩家的“钱包”

在IDE中打开PlaceMonster.cs,并添加以下实例变量:

private GameManagerBehavior gameManager;

您将用于gameManager访问GameManagerBehavior场景的GameManager的组件。要分配它,请将以下内容添加到Start()

gameManager = GameObject.Find(“ GameManager”).GetComponent <GameManagerBehavior>();

您可以使用来获得名为GameManager的GameObject GameObject.Find(),它会返回找到的具有给定名称的第一个游戏对象。然后,检索其GameManagerBehavior组件并将其存储以备后用。

注意:您可以通过在Unity编辑器中设置字段,或向其中添加一个静态方法(GameManager返回一个单例实例)来实现此目的,以实现此目的GameManagerBehavior

但是,上面的代码块中有一个黑马方法:Find,它在运行时速度较慢,但​​方便且可以少量使用。

得到的钱!

您还没有扣除金,所以加入这一行两次OnMouseUp(),更换每一个阅读的评论// TODO: Deduct gold

gameManager = GameObject.Find("GameManager").GetComponent<GameManagerBehavior>();

保存文件并切换到Unity,升级一些怪物并观看Gold读数更新。现在您可以扣除金币,但是只要有空间,玩家就可以建造怪物。他们只是负债累累。

无限信用

无限信用?太棒了!但是你不能允许这个。只能在玩家拥有足够的金币时放置怪物。

需要金币来怪物

在您的IDE中切换到PlaceMonster.cs,并用以下内容替换其中的内容CanPlaceMonster()

int cost = monsterPrefab.GetComponent<MonsterData>().levels[0].cost;
return monster == null && gameManager.Gold >= cost;

检索费用从放置怪物levelsMonsterData。然后,您检查monster不是nullgameManager.Gold并且大于成本。

挑战:添加一个检查,检查玩家CanUpgradeMonster()自己是否有足够的金币。

揭示

在Unity中保存并运行场景。继续吧,只需尝试放置无限的怪物!

限量金。现在您只能建造数量有限的怪物。

塔政治:敌人,波浪和航路点

是时候为敌人“铺路”了。敌人出现在第一个航路点,移向下一个并重复直到到达您的Cookie。

您将通过以下方式使敌人前进:

  1. 为敌人定义一条道路
  2. 沿着道路移动敌人
  3. 旋转敌人,使其向前看

使用路标创建道路

右键单击“层次结构”,然后选择“创建空白”以创建一个新的空白游戏对象。将其命名为Road,并确保其位于位置(0,0,0)

现在,右键单击层次结构并创建另一个空的游戏对象的道儿了。将其命名为Waypoint0并将其位置设置为*(-12,2,0)* -这是敌人开始进攻的地方。

道路-航点等级

使用以下名称和位置,以相同的方式再创建五个航路点:

  • 航点1:(X:7,Y:2,Z:0)
  • 航点2:(X:7,Y:-1,Z:0)
  • 航点3:(X:-7.3,Y:-1,Z:0)
  • 航点4:(X:-7.3,Y:-4.5,Z:0)
  • 航点5:(X:7,Y:-4.5,Z:0)

以下屏幕截图突出显示了航点位置和生成的路径。

屏幕截图2015年7月24日下午12.09

产生敌人

现在要让一些敌人跟随这条路。该预制件文件夹中包含一个敌人预制。它的位置是*(-20,0,0)*,因此新的实例将在屏幕之外产生。

否则,它的设置很像Monster预制件,带有AudioSource和一个孩子Sprite,它是一个精灵,因此您以后可以旋转它而不必旋转即将出现的运行状况栏。

沿着道路移动怪物

将名为MoveEnemy的新C#脚本添加到Prefabs \ Enemy预制中。在您的IDE中打开脚本,并添加以下变量:****

[HideInInspector]
public GameObject[] waypoints;
private int currentWaypoint = 0;
private float lastWaypointSwitchTime;
public float speed = 1.0f;

waypoints将航路点的副本存储在数组中,而上述确保您不会意外更改检查器中的字段,但仍可以从其他脚本访问它。[HideIn<em>inspector</em>]``waypoints****

currentWaypoint跟踪敌人当前正在离开的航路点,并lastWaypointSwitchTime存储敌人经过它的时间。最后,您存储敌人的speed

将此行添加到Start()

lastWaypointSwitchTime = Time.time;

初始化lastWaypointSwitchTime为当前时间。

要使敌人沿着路径移动,请将以下代码添加到Update()

// 1 
Vector3 startPosition = waypoints [currentWaypoint].transform.position;
Vector3 endPosition = waypoints [currentWaypoint + 1].transform.position;
// 2 
float pathLength = Vector3.Distance (startPosition, endPosition);
float totalTimeForPath = pathLength / speed;
float currentTimeOnPath = Time.time - lastWaypointSwitchTime;
gameObject.transform.position = Vector2.Lerp (startPosition, endPosition, currentTimeOnPath / totalTimeForPath);
// 3 
if (gameObject.transform.position.Equals(endPosition)) 
{
  if (currentWaypoint < waypoints.Length - 2)
  {
    // 3.a 
    currentWaypoint++;
    lastWaypointSwitchTime = Time.time;
    // TODO: Rotate into move direction
  }
  else
  {
    // 3.b 
    Destroy(gameObject);

    AudioSource audioSource = gameObject.GetComponent<AudioSource>();
    AudioSource.PlayClipAtPoint(audioSource.clip, transform.position);
    // TODO: deduct health
  }
}

一步步:

  1. 从Waypoints数组中,检索当前路径段的开始和结束位置。
  2. 使用公式time = distance / speed来计算整个距离所需的时间,然后确定路径上的当前时间。使用Vector2.Lerp,您可以在分段的开始位置和结束位置之间插入敌人的当前位置。
  3. 检查敌人是否到达了endPosition。如果是,请处理以下两种可能的情况:
    1. 敌人尚未到达最后一个航路点,因此请增加currentWaypoint和更新lastWaypointSwitchTime。稍后,您将添加代码以旋转敌人,使其也指向其移动的方向。
    2. 敌人到达了最后一个航路点,因此将其摧毁并触发了音效。稍后,您还将添加代码以减少播放器的health

保存文件并切换到Unity。

给敌人一种方向感

在当前状态下,敌人不知道航路点的顺序。

层次结构中选择Road,然后添加一个名为SpawnEnemy的新*C#脚本。然后在您的IDE中打开它,并添加以下变量:*****

public GameObject[] waypoints;

您将用来waypoints以正确的顺序存储对场景中航路点的引用。

保存文件并切换到Unity。在层次结构中选择Road并将Waypoints数组的Size设置为6。********

将每个Road的子级拖到字段中,将Waypoint0放入Element 0,将Waypoint1放入Element 1,依此类推。

航点2

现在,您有了一个包含整齐排列的航路点的数组,因此有了一条路径–请注意,它们永远不会后退;他们会死于试图解决糖问题。

必死

检查一切正常

在您的IDE中转到SpawnEnemy,并添加以下变量:

public GameObject testEnemyPrefab;

这使该参考敌人的预制testEnemyPrefab

要在脚本启动时创建敌人,请将以下代码添加到Start()

Instantiate(testEnemyPrefab).GetComponent<MoveEnemy>().waypoints = waypoints;

这将实例化存储在其中的预制件的新副本,testEnemy并为其指定要遵循的航路点。

保存文件并切换到Unity。在层次结构中选择“道路” ,并将其“测试敌人”设置为“敌人”预制件。**

运行项目,看看敌人在路上。

BugFollowsRoadWithoutRotating

您是否注意到他们并不总是在寻找他们要去的地方?滑稽!但是,您正在尝试成为一名专业人士,对吗?继续第二部分,学习如何使他们表现出最好的面孔。

然后去哪儿?

您已经完成了很多工作,并且在拥有自己的塔防游戏的途中也步入正轨。

玩家可以建造怪物,但数量不限,而且有一个敌人冲向您的cookie。玩家有金币,也可以升级怪物。

此处下载结果。

在第二部分中,您将介绍产生大量敌人并将其吹走的浪潮。在第二部分见


标题:[转]Unity实战之塔防游戏(一)
作者:shirln
地址:https://mmzsblog.cn/articles/2020/10/09/1602235884982.html
-----------------------------
如未加特殊说明,此网站文章均为原创。
转载须在文章起始位置标注作者及原文连接,否则保留追究法律责任的权利。

个人微信公众号 ↓↓↓                 

微信搜一搜爱上游戏开发