原文:Introduction to Unity Scripting
作者:Georgi Ivanov
译者:kmyhy
Unity 的许多功能都要通过它的富脚本语言 C# 来体现。你可以用它来处理用户输入,操作场景中的对象,碰撞检测,自动生成新的 GameObject 和在场景中发射定向光以处理游戏逻辑。听起来很可怕,但 Unity 提供了有良好文档的 API,使得这些任务的完成轻而易举——哪怕你是一个新手!
在本教程中,你将创建一个 Top down shooter 游戏,用 Unity 脚本处理敌人的生成、玩家控制、开火以及其它游戏中的重要方面。
注意:本教程假设你拥有一定的 C# 或类似编程语言经验,理解 Unity 的界面和工作方式。如果你忘记了这些内容,请阅读我们的 Unity 入门教程。
本教程针对 Unity 5.3 以上。你可以从这里下载 Unity 的最新版。
Unity 同时支持 UnityScript 和 Boo,但大部分程序员都喜欢使用 C#。C# 被数百万开发者用于开发 app、web 和游戏开发,有海量的资料和教程能够帮你学习它。
开始
下载开始项目 BlockBuster,解压缩,用 Unity 打开文件夹。
打开后是这个样子:
看一下场景视图。有一个小的竞技场,这是游戏的主战场,以及一个相机和一盏灯。如果你的布局和上图不同,请点击右上角的下拉框并选择 2 by 3。
主角都没有叫什么游戏?你的第一个任务就是创建一个代表玩家的 GameObject。
创建玩家角色
在结构视图中,点解 Create 按钮,选择 3D Object\Sphere。将球体放到 (X:0, Y:0.5, Z:0) 然后命名为 Player。
Unity 用全组件式系统来构建 GameObject。也就是说所有的 GameObject 都是由组件构成,这些组件会给游戏对象赋予行为和特性。这是几个 Unity 的内置组件:
- Transforme: 每个 GameObject 都有这个组件。它保存了 GameObject 的位置、角度和比例。
- Box Collider:一种立方形的碰撞体,用于检测碰撞。
- Mesh Filter:用于显示 3D 模型的网格数据。
Player 游戏对象需要和场景中的其它对象发生碰撞反应。要实现这一点,请在结构视图中选择 Player,然后点击检视器窗口中的 Add Component 按钮。在弹出菜单中选择 Physics > Rigidbody,这就为 Player 添加了一个刚体组件,这样它就能够使用 Unity 的物理引擎了。
修改这个刚性体的属性为:Drag 设为 1,Angular Drag 为 0,勾选 Freeze Position 旁边的 Y。
这将保证玩家角色不会被上下移动,同时在转动时不添加阻尼系数。
编写玩家移动的脚本
玩家角色创建好之后,准备创建接收键盘输入以及移动玩家的脚本。
在项目窗口中,点击 Create Button\Folder。命名文件夹为 Scripts,然后在下面创建一个 Player 子文件夹。
在 Player 文件夹中,点击 Create 按钮,选择 C# Script。新脚本命名为 PlayerMovement。这个样子:
注意:创建这些文件夹有利于将文件安装各自的职能进行组织,避免混乱。你将为 Player 创建多个脚本,因此单独用一个文件夹会更好。
双击 PlayerMovement.cs 脚本。这会用你喜欢的代码编辑器打开这个脚本。Unity 内置了 MonoDevelop,它支持所有平台,在安装器运行时,Windows 用户可以安装 Visual Studio 来取代它。
本教程假设你使用 MonoDevelop,但 Visual Studo 用户也不会有任何问题。
当代码编辑器打开,你会看到:
这是 Unity 在新脚本中生成的默认的类。它继承了 MonoBehaviour 基类,这样脚本才能够在游戏中运行,同时还有一些特殊的方法对特定事件作出响应。如果你是一个 iOS 开发者,这个类就好比 UIViewCotnroller。Unity 会在运行脚本时以特定顺序调用多个方法。最常见的几个方法包括:
- Start(): 这个方法在脚本第一次 update 时调用。
- Update(): 当游戏正在运行,同时脚本是可用的,这个方法会在每帧刷新时调用。
- OnDestroy(): 在这个脚本所附着的 GameObject 被销毁之前调用。
- OnCollisionEnter(): 当这个脚本所附着的碰撞体或刚体和其它碰撞体或刚体发生接触时调用。
完整的事件列表,请参考 Unity 的 MonoBehaviours 文档。
在 Start() 方法前,添加两行代码:
public float acceleration;
public float maxSpeed;
脚本看起来是这个样子:
这是公共变量声明,这意味着这两个变量能够在检视器中看到并修改,而无需在脚本和编辑器中来回切换。
acceleration 表示玩家的速度随着时间递增。maxSpeed 则表示速度的上限。
在它们后面声明这几个变量:
private Rigidbody rigidBody;
private KeyCode[] inputKeys;
private Vector3[] directionsForKeys;
私有变量无法用检视器来设置,它的初始化由开发者在某个时机负责。
rigidBody 用于保存一个对刚体组件的引用,即附着在 Player GameObject 上的刚体组件。
inputKeys 是一个键盘码的数组,用于检查输入。
directionsForKeys 用于保存一个 Vector3 变量数组,这些变量表示方向数据。
将 Start() 方法修改为:
void Start () {
inputKeys = new KeyCode[] { KeyCode.W, KeyCode.A, KeyCode.S, KeyCode.D };
directionsForKeys = new Vector3[] { Vector3.forward, Vector3.left, Vector3.back, Vector3.right };
rigidBody = GetComponent<Rigidbody>();
}
这段代码将按键对应到方向,比如 W 是向前。最后一行获得了一个对所附着的刚体组件的引用,将它保存到 rigidBody 变量以便使用。
要真正移动玩家的角色,还需要处理键盘输入。
将 Update() 修改为 FixedUpdate() 并加入以下代码:
// 1
void FixedUpdate () {
for (int i = 0; i < inputKeys.Length; i++){
var key = inputKeys[i];
// 2
if(Input.GetKey(key)) {
// 3
Vector3 movement = directionsForKeys[i] * acceleration * Time.deltaTime;
}
}
}
有几个地方需要注意一下:
- FixedUpdate() 是不依赖于帧率的,它和刚体一起使用。和以尽量快的速度运行不同,这个方法会以固定的间隔执行。
- 这个循环检测哪个按键被按下。
- 获取被按下的键所表示的方向,乘以加速度和上一帧耗费的秒数。最终算出一个方向向量(在 x,y 和 z 轴上的速度),你就可以用它来移动 Player 对象了。
如果你是编程新手,你可能奇怪为什么要乘以 Time.detalTime。游戏是在帧率(帧/秒)下运行的,帧率是取决于硬件和它运行压力,这样在性能好的机器上帧率快,而在性能差的机器上帧率慢,从而导致不可预知的结果。通常的办法是,当需要按每帧执行一个动作时,都乘上 Time.deltaTime。
在 FixedUpdate() 方法后添加:
void movePlayer(Vector3 movement) {
if(rigidBody.velocity.magnitude * acceleration > maxSpeed) {
rigidBody.AddForce(movement * -1);
} else {
rigidBody.AddForce(movement);
}
}
这个方法向刚体施加一个力,驱使它移动。乳沟当前速度超过 maxSpeed,这个力会转成反方向,让玩家减速,将速度有效地限制在最大速度下。
在 FixedUpdate() 方法中,在 if 语句右括号结束之前,添加:
movePlayer(movement);
棒极了!保存脚本,回到 Unity 编辑器。在项目窗口,将 PlayerMovement 脚本拖到结构视图的 Player 上。
将脚本添加到一个 GameObject 会导致创建一个组件实例,也就是说所有的代码会被所附着的 GameObject 所执行。
用检视器将 Acceleration 设置为 625,Max Speed 设置为 4375:
运行场景,用 WASD 键移动玩家角色:
太好了,我们只用了几行代码!:]
但是,有一个很明显的问题——玩家会飞快地跑到视线以外,让我们很难去和敌人战斗啊。
编写相机脚本
在脚本编辑器中,新建脚本名为 CameraRig,然后将它添加到 Main Camera。还需要介绍详细步骤吗?你可以参考下面的答案。
参考步骤
选择 Scripts 文件夹,点击项目浏览器中的 Create 按钮,选择 C# 脚本。取名为 CameraRig。将新脚本拖到 Main Camera 对象:
然后,在新的 CameraRig 类的 Start() 方法之上中添加如下变量:
public float moveSpeed;
public GameObject target;
private Transform rigTransform;
你可能想到了,moveSpeed 是相机跟随目标——任何场景内部游戏对象——进行移动的速度。
在 Start() 方法中,添加:
rigTransform = this.transform.parent;
这句引用了父对象 Camera 在场景树中的 transform 组件。每个在场景中的对象都会有一个 Transform 组件,它描述了对象的位置、角度和比例。
在同一脚本中,添加方法:
void FixedUpdate () {
if(target == null){
return;
}
rigTransform.position = Vector3.Lerp(rigTransform.position, target.transform.position,
Time.deltaTime * moveSpeed);
}
CameraRig 的移动代码比 PlayerMovement 要简单。这是因为你不需要刚体,在 rigTransform 和 target 的位置之间做插值运算即可。
Vector3.Lerp() 以两个点和一个0-1 之间的小数做参数,这个小数表示两个端点之间的一个位置。左端点为 0,右端点为 1。0.5 则返回两点之间的终点。
以渐慢方式让 rigTransform 靠近 target 的位置。也就是说——相机会跟随玩家角色。
回到 Unity。在结构视图选中 Main Camera。在检视器中,设置 Move Speed 为 8 ,Target 为 Player:
运行游戏,在场景中四处移动;相机将平滑跟随 target。
创建敌人
没有对手的射击游戏玩起来固然轻松,但也太无聊了 :] 通过顶部 GameObject\3D Object\Cube 菜单创建一个方块作为敌人。将方块命名为 Enemy 并添加一个 Rigidbody 组件。
在检视器中,首先将方块的 Transform 设为 (0, 0.5,4)。在 Rigidbody 组件的 Constraints栏,勾上 Freeze Position 旁边的 Y。
太好了——现在让敌人以一种吓人的方式移动吧。在 Scripts 目录下新建脚本 Enemy。这个步骤你应该很熟悉了,如果忘记了,请参考前面的描述过的步骤。
然后,在类中声明变量:
public float moveSpeed;
public int health;
public int damage;
public Transform targetTransform;
这些变量的作用并不难猜。moveSpeed 先前在相机中也用到过,这里是同样的作用。health 和 damage 用于决定敌人什么时候死,以及它们对玩家造成的伤害。targetTransform 引用了玩家的 transform。
对于 Player 来说,你需要一个类描述玩家的所有属性,这一切恰好是敌人想摧毁的。
在项目浏览器中,选中 Player 文件夹并新建脚本 Player,这个脚本用于对碰撞进行处理,并保存玩家的生命值。双击脚本,打开它。
添加一个公共变量用于保存玩家的生命值:
public int health = 3;
这里为 health 设置了一个默认值,但你还可以在检视器中修改这个值。
要处理碰撞,添加如下方法:
void collidedWithEnemy(Enemy enemy) {
// Enemy attack code
if(health <= 0) {
// Todo
}
}
void OnCollisionEnter (Collision col) {
Enemy enemy = col.collider.gameObject.GetComponent<Enemy>();
collidedWithEnemy(enemy);
}
OnCollisionEnter() 方法会在两个带有碰撞体的刚体发生碰撞时触发。Collision 参数包含了交点和碰撞速度等信息。在这里,你只对 Collision 中的 Enemy 组件感兴趣,因此调用 collidedWithEnemy() 并执行攻击逻辑——这个在后面添加。
回到 Enemy.cs,添加下列方法:
void FixedUpdate () {
if(targetTransform != null) {
this.transform.position = Vector3.MoveTowards(this.transform.position, targetTransform.transform.position, Time.deltaTime * moveSpeed);
}
}
public void TakeDamage(int damage) {
health -= damage;
if(health <= 0) {
Destroy(this.gameObject);
}
}
public void Attack(Player player) {
player.health -= this.damage;
Destroy(this.gameObject);
}
FixedUpdate() 方法你应该很熟悉了,略有不同的地方是,你用 MoveToward() 替代了 Lerp() 方法。这是因为敌人始终以同样的速度进行移动,当它到达目标后不需要减速。当敌人被子弹击中,TakeDamage() 方法被调用;当敌人的生命值变为 0,它将被销毁。Attack() 是类似的——它将伤害施加到 Player,然后敌人自动销毁。
回到 Player.cs,在 collidedWithEnemy() 方法中,将注释“Enemy attack code”替换成:
enemy.Attack(this);
在这个过程中,玩家被减血,敌人自毁。
返回 Unity。将 Enemy 脚本绑定到 Enemy 对象,在检视器中,修改 Enemy 的属性:
- Move Speed: 5
- Health: 2
- Damage: 1
- Target Transform: Player
现在你应该自己尝试着修改这些值。自己动手,然后和下面的 Gif 动画进行比较:
参考答案
在这个游戏中,当敌人和玩家发生碰撞,就会构成一次攻击。用 Unity 的物理引擎来检测碰撞不过是小菜一碟。
最终,将 Player 脚本绑定到结构视图中的 Player 上。
运行游戏,注意查看控制台:
当敌人碰上玩家,它会进行攻击并扣减玩家的生命值为 2。但控制台会抛出一个 NullReferenceException 错误,指向了 Player 脚本的这一行:
哎呀——玩家不仅仅和敌人发生了碰撞,也和游戏中的其它对象发生了碰撞,比如竞技场。因为这个对象没有 Enemy 脚本,因此 GetComponent() 返回了 null。
打开 Player.cs。在 OnCollisionEnter() 方法中,用一个 if 语句将 collidedWithEnemy() 方法包裹起来:
Open Player.cs. In OnCollisionEnter(), wrap collidedWithEnemy() in an if statements:
if(enemy) {
collidedWithEnemy(enemy);
}
不会为空了!
使用预制件
只能逃跑、躲避敌人的游戏完全是一边倒的游戏。是该武装我们的玩家进行战斗的时候了!
点击结构视图中的 Create 按钮,然后选择 3D Object/Capsule。命名为 Projectile,然后设置它的 transform 为:
Position: (0, 0, 0)
Rotation: (90, 0, 0)
Scale: (0.075, 0.246, 0.075)
当玩家开火时,会发射一颗 Projectile 对象。要实现这个,你需要创建一个预制件。和场景中你曾经创建的对象不同,预制件是根据游戏逻辑按需创建的。
在 Assets 下新建一个文件夹 Prefabs。将 Projectile 对象拖进这个文件夹。现在,你就拥有了一个预制件!
你的预制件需要写点脚本。在 Scripts 目录下新建脚本 Projectile,声明如下变量:
public float speed;
public int damage;
Vector3 shootDirection;
和教程里面其它会动的对象一样,这里也用到了速度和伤害变量,因为这也是战斗逻辑的一部分。shootDiretion 向量决定了子弹射向的方向。
要使用这个向量,需要定义如下方法:
// 1
void FixedUpdate () {
this.transform.Translate(shootDirection * speed, Space.World);
}
// 2
public void FireProjectile(Ray shootRay) {
this.shootDirection = shootRay.direction;
this.transform.position = shootRay.origin;
}
// 3
void OnCollisionEnter (Collision col) {
Enemy enemy = col.collider.gameObject.GetComponent<Enemy>();
if(enemy) {
enemy.TakeDamage(damage);
}
Destroy(this.gameObject);
}
上述代码解释如下:
- 子弹的运动和游戏中的其他对象不同。它没有目标,也没有实时为它施加外力;而是在它的整个生命周期中它都以既定的方向飞行。
- 这里,你会设置预制件的开始位置和方向。Ray 参数看起来比较神奇,待会你会学习它是如何计算的。
- 如果子弹碰到了敌人,它会调用 TakeDamage,然后自毁。
在场景结构视图中,将 Projectile 脚本绑定到 Projectile GameObject。将 Speed 设为 0.2,Damage 设为 1,然后点击检视器顶部附近的 Apply 按钮。这将修改应用到所有的预制件实例。
从场景结构视图中删除 Projectile 对象——你不再需要它了。
发射子弹
你已经有一个会飞会造成伤害的预制件了,接下来可以开始射击了。
在 Player 文件夹下,新建脚本 PlayerShooting,并将它绑定到场景的 Player 中。在脚本中声明变量如下:
public Projectile projectilePrefab;
public LayerMask mask;
第一个变量保存对之前创建的子弹预制件的引用。当玩家每次射击时,你都要创建一个该预制件的实例。掩码(mask 变量)用于过滤游戏对象。
等等,发射射线?这是什么魔法?
不,这里没有什么黑魔法——有时候你需要知道在某个方向上是否有碰撞发生。为了解决这个问题,Unity 从某个点开始向指定方向发射一条看不见的射线。很可能和这条射线相交的游戏对象会有很多,掩码(mask 变量)允许你过滤掉一些无关的对象。
Raycasts 超级好用,可以用于各种目的。通常会用它来判断其它角色在否中弹,也可以用它来判断在鼠标指针下面是否有某个几何体。要了解更多 Raycast 的用法,请阅读 Unity 官网上的 Unity 在线教学视频。
下图显示了一条从立方体到达圆锥体的射线。因为这条射线有一个 iconsphere 的掩码,它会忽略这个 GameObject 然后告诉你它击中了圆锥体:
让我们来发射自己的射线。
在 PlayerShooting.cs 中添加如下方法:
void shoot(RaycastHit hit){
// 1
var projectile = Instantiate(projectilePrefab).GetComponent<Projectile>();
// 2
var pointAboveFloor = hit.point + new Vector3(0, this.transform.position.y, 0);
// 3
var direction = pointAboveFloor - transform.position;
// 4
var shootRay = new Ray(this.transform.position, direction);
Debug.DrawRay(shootRay.origin, shootRay.direction * 100.1f, Color.green, 2);
// 5
Physics.IgnoreCollision(GetComponent<Collider>(), projectile.GetComponent<Collider>());
// 6
projectile.FireProjectile(shootRay);
}
以上代码解释如下:
- 实例化一个子弹预制件,然后获取它的 Projectile 组件,以便初始化它。
- 这个点的坐标应该是 (x,0.5,z)。X 和 Z 是鼠标点击位置投射到地板上的坐标。这个转换非常重要,因为子弹必须平行于地板飞行——不然的话如果你可能会向下射击,只有菜鸟才会将子弹打到地上。
- 计算从玩家角色到 pointAboveFloor 的方向。
- 创建一条射线,通过起点和方向来描述它的轨迹。
- 这句告诉 Unity 物理引擎忽略玩家和子弹碰撞体之间发生的碰撞。否则当二者发生碰撞后, Projectile 脚本中的 OnCollisionEnter() 方法会被调用。
- 最后,设置子弹的飞行路线。
注意:在发射射线的时候调用 Debug.DrawRay() 非常有用,它会让你看见射线,以及它击中些什么。
射击逻辑写完,添加下列方法,让玩家可以真的开枪:
// 1
void raycastOnMouseClick () {
RaycastHit hit;
Ray rayToFloor = Camera.main.ScreenPointToRay(Input.mousePosition);
Debug.DrawRay(rayToFloor.origin, rayToFloor.direction * 100.1f, Color.red, 2);
if(Physics.Raycast(rayToFloor, out hit, 100.0f, mask, QueryTriggerInteraction.Collide)) {
shoot(hit);
}
}
// 2
void Update () {
bool mouseButtonDown = Input.GetMouseButtonDown(0);
if(mouseButtonDown) {
raycastOnMouseClick();
}
}
上述方法依序解释如下:
- 这个方法从相机发射一条射线到鼠标点击位置。然后在指定 LayerMask 的条件下,检查有没有游戏对象和它相交。
- 在帧刷新时,这个方法检查鼠标左键是否点击。如果点击,调用 raycastOnMouseClick()。
回到 Unity,在检视器中设置以下属性:
- Projectile Prefab: 引用 Prefabs folder 文件夹中的 Projectile
- Mask: Floor
注意:Unity 有一个事先定义的图层(layer)列表,这些图层在创建掩码时会用到。点击 GameObject 的 Layer 下拉框,点击 Add Layer 就可以新建图层:
要为 GameObject 指定图层,请在 Layer 下拉框中进行选择:
关于图层的更多内容,请参考 Unity 的 Layers 文档。
运行项目,开火吧!子弹飞向所指的方向,但还是有点不对,不是吗?
如果子弹的方向和飞行的方向保持一致就更好了。要解决这个问题,打开 Projectile.cs,添加如下方法:
void rotateInShootDirection() {
Vector3 newRotation = Vector3.RotateTowards(transform.forward, shootDirection, 0.01f, 0.0f);
transform.rotation = Quaternion.LookRotation(newRotation);
}
注意 RotateTowards 方法和 MoveTowards 方法很像,但它把向量当成是方向而不是位置。同时,你也不需要随时都去修改方向,因此只需要一个接近于 0 的 step 就够了。在 Unity 中,Transform 的 rotation 是用四元数表示的,这不属于本书范畴。在本教程中,你只需要知道它在进行和 3D 旋转相关的计算中比起向量来说更有优势即可。
如果对四元数和及其优点感兴趣,可以阅读这篇:How I learned to Stop Worrying and Love Quaternions。
在 FireProjectile() 方法最后,调用 rotateInShootDirection()。 FireProjectile() 最终变成这个样子:
public void FireProjectile(Ray shootRay) {
this.shootDirection = shootRay.direction;
this.transform.position = shootRay.origin;
rotateInShootDirection();
}
再次运行游戏,向各个方向开火,这次子弹会直直指向射击的方向:
在不需要的时候可以删除 Debug.DrawRay 语句。
制造更多的坏蛋
只有一个敌人一点挑战性都没有嘛!但现在,自从你学过了预制件之后,你想要多少就可以制造出多少!:]
为了让玩家不可预知,你可以让每个 Enemy 的生命、速度和位置随机。
创建一个空的游戏对象——用 GameObject\Create Empty。命名它为 EnemyProducer 并添加一个 Box Collider 组件给它。在检视器中,修改它的属性:
- Position: (0, 0, 0)
- Box Collider:
- Is Trigger: true
- Center: (0, 0.5, 0)
- Size: (29, 1, 29)
你绑定的这个碰撞体在竞技场中定义了一个特殊的 3D 空间。要看见它,在结构视图中选中 Enemy Producer 游戏对象,然后在场景视图中你会看到:
绿色的外框代表了这个碰撞体。
接下来你将编写脚本,用这个空间中的随机位置 X 和 Z 生成一个 Enemy 预制件的实例。
新建脚本 EnemyProducer,并绑定到 EnemyProducer 游戏对象上。在类文件中,添加成员:
public bool shouldSpawn;
public Enemy[] enemyPrefabs;
public float[] moveSpeedRange;
public int[] healthRange;
private Bounds spawnArea;
private GameObject player;
第一个变量用于开启/禁止自动孵化。这个脚本会从 enemyPrefabs 中挑选一个随机的敌人预制件来实例化。后两个数组用于指定速度和生命值的最大、最小值。spawnArea 是你在场景视图中看见的那个绿色方框。最后,你需要引用玩家角色,将它作为坏蛋们的目标。
在这个脚本中,定义方法:
public void SpawnEnemies(bool shouldSpawn) {
if(shouldSpawn) {
player = GameObject.FindGameObjectWithTag("Player");
}
this.shouldSpawn = shouldSpawn;
}
void Start () {
spawnArea = this.GetComponent<BoxCollider>().bounds;
SpawnEnemies(shouldSpawn);
InvokeRepeating("spawnEnemy", 0.5f, 1.0f);
}
SpawnEnemies() 方法获取 Tag 标记为 Player 的游戏对象,并判断是否应当孵化出一个敌人。
Start() 方法初始化孵化区并在游戏启动后以 0.5 秒调用一个方法。这个方法每秒都会重复执行。除了充当 setter 方法,SpawnEnemies() 方法还会获取一个 Tag 为 Player 的游戏对象的引用。
但是,Player 游戏对象还没有设置 Tag 属性——你现在来做这个。从结构视图中选择 Player 对象,然后在检视器中,从 Tag 下拉菜单中选择 Player:
现在,需要编写孵化一个敌人的代码了。
打开 Enemy 脚本,添加方法:
public void Initialize(Transform target, float moveSpeed, int health) {
this.targetTransform = target;
this.moveSpeed = moveSpeed;
this.health = health;
}
这个方法可以看成是一个 setter 方法,用于创建对象。接下来,这个代码会用于孵化出敌人。打开 EnemyProducer.cs,添加下列方法:
Vector3 randomSpawnPosition() {
float x = Random.Range(spawnArea.min.x, spawnArea.max.x);
float z = Random.Range(spawnArea.min.z, spawnArea.max.z);
float y = 0.5f;
return new Vector3(x, y, z);
}
void spawnEnemy() {
if(shouldSpawn == false || player == null) {
return;
}
int index = Random.Range(0, enemyPrefabs.Length);
var newEnemy = Instantiate(enemyPrefabs[index], randomSpawnPosition(), Quaternion.identity) as Enemy;
newEnemy.Initialize(player.transform,
Random.Range(moveSpeedRange[0], moveSpeedRange[1]),
Random.Range(healthRange[0], healthRange[1]));
}
spawnEnemy() 方法会选择一个随机的敌人预制件,用随机的位置实例化预制件并初始化 Enemy 脚本中的公共变量。
EnemyProducer.cs 准备得差不多了!
回到 Unity。从结构视图拖一个 Ememy 对象到 Prefabs 文件夹,这将创建一个 Enemy 预制件。从场景中删除 enemy 对象——你用不到它了。然后将 Enemy Producer 脚本的公共变量修改为:
- Should Spawn: True
Enemy Prefabs:
- Size: 1
- Element 0: Reference the enemy prefab
Move Speed Range:
- Size: 2
- Element 0: 3
- Element 1: 8
Health Range:
- Size: 2
- Element 0: 2
- Element 1: 6
运行游戏查看效果——源源不断的坏蛋们出来了!
好的,这些方块没一点也不吓人。是时候来加点料了。
在场景中创建一个 3D Cylinder 和 Capsule。分别命名为 Enemy2 和 Enemy 3 。如同你在第一个敌人中所做的,添加刚体组件和 Enemy 脚本给它们。选中 Enemy2 在检视器中修改它的属性:
- Scale: (0, 0.5, 0)
Rigidbody:
- Use Gravity: False
- Freeze Position: Y
- Freeze Rotation: X, Y, Z
Enemy Component:
- Move Speed: 5
- Health: 2
- Damage: 1
- Target Transform: None
然后在 Enemy3 上重复同样步骤,但 Scale 设置为 0.7:
然后,将它们转成预制件,这和之前在原来的 Enemy 上的做法是一样的,然后在 Enemy Producer 中引用它们。在检视器中查看是这个样子:
Enemy Prefabs:
- Size: 3
- Element 0: Enemy
- Element 1: Enemy2
- Element 2: Enemy3
运行游戏,你会看到孵化出不同的预制件来了。
很快,你就会发现自己是无敌的!虽然这很爽,但你还是要让战斗均衡一点。
实现游戏控制器
你现在可以开枪和移动,敌人也出现了,接下来应该实现基本的游戏控制器。它会在玩家“死亡”之后重新开始游戏。但首先,你必须创建一种机制,通知所有感兴趣的对象玩家的生命值已经为 0。
打开 Player 脚本,在类声明之前添加:
using System;
在类中,添加一个新的公有时间:
public event Action<Player> onPlayerDeath;
事件是 C# 的语言特性,允许你将对象的变化广播给其它监听者。关于事件的用法,请阅读 Unity 的 event 在线教学视频。
将 collidedWithEnemy() 实现为如下代码:
void collidedWithEnemy(Enemy enemy) {
enemy.Attack(this);
if(health <= 0) {
if(onPlayerDeath != null) {
onPlayerDeath(this);
}
}
}
事件提供了一种简单的在对象之间实现信号状态改变的方法。一个游戏控制器应该对上面声明的事件很敏感。在 Scripts 文件夹中,新建脚本 GameController。双击文件打开它,添加如下变量:
public EnemyProducer enemyProducer;
public GameObject playerPrefab;
在这个脚本中我们需要控制敌人的生成,因为玩家死亡后还继续制造敌人是没有必要的。另外,重启游戏意味着需要重新创建玩家…也就是玩家也需要转成预制件。
添加下列方法:
void Start () {
var player = GameObject.FindGameObjectWithTag("Player").GetComponent<Player>();
player.onPlayerDeath += onPlayerDeath;
}
void onPlayerDeath(Player player) {
enemyProducer.SpawnEnemies(false);
Destroy(player.gameObject);
Invoke("restartGame", 3);
}
在 Start() 方法中,获取了一个对 Player 脚本的引用,并且订阅了之前新建的事件。当玩家生命值归 0,onPlayerDeath() 方法会被调用,于是停止孵化敌人,将玩家从场景中移除,然后延迟 3 秒调用 restartGame() 方法。
最后来实现重启游戏的代码:
void restartGame() {
var enemies = GameObject.FindGameObjectsWithTag("Enemy");
foreach (var enemy in enemies)
{
Destroy(enemy);
}
var playerObject = Instantiate(playerPrefab, new Vector3(0, 0.5f, 0), Quaternion.identity) as GameObject;
var cameraRig = Camera.main.GetComponent<CameraRig>();
cameraRig.target = playerObject;
enemyProducer.SpawnEnemies(true);
playerObject.GetComponent<Player>().onPlayerDeath += onPlayerDeath;
}
这里你需要进行一些清理工作:销毁场景中的敌人,创建新的 Player 对象。将相机的 target 设置为新实例,恢复敌人的孵化,让 Game Controller 订阅玩家死亡事件。
回到 Unity,打开 Prefabs 文件夹,将所有 Enemy 预制件的 tag 设为 Enemy。然后,将 Player 游戏对象拖进 Prefabs 文件夹。新建空游戏对象,命名为 GameController,将刚刚创建的脚本绑定上去。在检视器中,将所有的引用都连接上。
这个工作你已经很熟悉了。请自行完成引用的连接并检查是否和下面的答案一致:
Game Controller:
- Enemy Producer: 从结构视图中引用 Enemy Producer
- Player Prefab: 从 Prefabs 文件夹引用 Player Prefab
运行游戏,看游戏控制器是否运行正常。
好了,你用脚本完成了你的第一个 Unity 游戏!恭喜你 :]
结束
完成后的项目从这里下载。
现在,你应该掌握了如何构建一个简单的动作游戏。游戏制作不是简单工作;要完成整个游戏,大量的工作和脚本绝对只是其中一部分。为了添加更多的亮点,你必须在游戏中添加动画和 UI。因此,我强烈建议你阅读我们的这些教程:
如果你喜欢学习设计、编码和美化你的 Unity 游戏,请阅读 Unity 游戏教程。
这本书会教你构建 Unity 游戏所需要的一切只是,无论你是初学者还是有经验的开发者。在这本书中,你将编写出 4 个优秀的游戏:
- 一个 3D twin-stick 射击游戏
- 一个经典的 2D 平台游戏
- 一个 3D 塔防游戏(支持 VR)
- 一个第一人称射击游戏
希望你喜欢本教程,并激发你开始制作你一直想制作的游戏。有问题和建议,请在下面留言。