从零开始做一款Unity3D游戏<三>——编写游戏机制

Posted 接受平凡 努力出众

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了从零开始做一款Unity3D游戏<三>——编写游戏机制相关的知识,希望对你有一定的参考价值。

添加跳跃

了解枚举

使用层遮罩

发射投射物

实例化对象

管理游戏对象的创建

游戏管理器

维护玩家属性

get和set属性

精益求精

图形用户界面

胜败条件

使用预编译指定和命令空间

总结


前面一章,我们专注于通过代码来移动玩家和相机,同时了解了与 Unity 的物理系统相关的一些知识。然而,仅仅控制角色并不足以制作出具有竞争力的游戏:事实上,这只是各种不同游戏中都会存在的主题之一。
游戏的独特性来自游戏的核心机制以及这些机制赋予玩家的力量感与代入感。虚拟环境若不具有任何乐趣和可玩性,游戏便不值得重复玩耍,更不用说带来趣味了。当尝试实现游戏机制时,我们还会进一步学习 C#的编程知识以及一些中级特性

本章将完成 Hero Bor 游戏原型的制作,其中包含如下主题:

  • 通过施加力来添加跳跃。
  • 理解层遮罩。
  • 初始化对象和预制体
  • 理解游戏管理器。
  • 理解get和 set 属性。
  • 计算分数。
  • 编写UI。

添加跳跃

使用 Rigidbody 组件控制玩家移动带来的好处是,添加依赖于施加力的游戏机将变得很容易,例如跳跃。为了使玩家能够跳跃,本节将使用称为枚举的数据类型并且编写第一个工具函数。

提示:
工具函数是用来执行一些杂事的类方法,能使游戏代码不那么混乱。例如,检查玩家是否接触地面,从而进行跳跃(或提示)。

了解枚举

根据定义,枚举是属于同一变量的具名常量的集合。当需要使用一系列不同的值而这些值又属于相同的父类型时,枚举十分有用。
与进行描述相比,直接进行展示能让枚举理解起来更为容易。枚举的语法如下

enum PlayerAction  Attack, Defend,Flee ;

 下面分步解释枚举是如何起作用的。

  • 关键字enum声明了后面变量的类型
  • 枚举包含的值位于花括号中,使用逗号分隔(最后一个值除外)
  • 枚举必须以分号结尾,就像之前使用的所有其他类型一样。

例如,使用如下语法就可以声明一个枚举变量:

PlayerAction currentAction = PlayerAction.Defend;
  • 解释如下: 
  • 类型是PlayerAction。
  • 枚举变量包含名称并等价于 PlayerAction 的某个值。
  • 每个枚举常量都可以通过点符号来访问。

底层类型
枚举关联着底层类型,这意味着花括号内的每个常量值都有关联值。默认的底层类型是 int,初始值为0,就像数组一样,各个枚举常量按顺序获得下一个更大的值

注意:
并非所有类型都相同。枚举可以使用的底层类型已被限制为 byte、sbyte.short、ushort、int、uint、long 和 ulong.这些类型被称为整型,用来指定变量可以存储的数值的大小。这些内容超出了本书的讨论范围,大部分情况下使用 mt 类型即可。

例如,假设 PlayerAction 枚举的值现在如下所示:

enum PlayerAction f Attack = 0,Defend = 1,Flee = 2 i

 这里并无规则限制底层类型的值必须起始于 0;实际上,只需要指定第一个值,C#就会自动递增其余的值:

enumPlayerAction  Attack = 5,Defend,Flee ;

 在以上示例中,Defend自动等于6,Flee自动等于7。但是,如果需要使PlayerAction枚举包含不连续的值,那么需要显式地添加它们:

enum PlayerAction  Attack = 10,Defend = 5,Flee = 0;

 你甚至可以改变 PlayerAction 的底层类型至任何支持的类型,只需要在枚举名的后面添加一个冒号即可:

enum PlayerAction : byte Attack, Defend,Flee ;

 为了获取枚举的底层类型,需要执行显式的类型转换,我们已经介绍过这些内容因此下面的语法不足为奇

enum PlayerAction Attack = 10,Defend = 5,Flee = 0;
PlayerAction currentAction = PlayerAction.Attack;
int actionCost = (int)currentAction;

 枚举是编程领域中功能极为强大的工具,请一定熟练掌握。

实践:按空格键使玩家跳跃

你现在已经对枚举有了基本了解,下面使用枚举 KeyCode 来获取键盘输入。按如下代码修改 PlayerBehavior脚本,保存并单击 Play 按钮:

public class PlayerBehavior : MonoBehaviour

  public float moveSpeed = 10f;
  public float rotateSpeed = 75f;
  public float jumpVelocity = 5f;
  private float vInput;
  private float hInput;
  private Rigidbody rb;
  void Start()
  
   _rb = GetComponent<Rigidbody>();
  
  void Update()
 
   vInput - Input.GetAxis("Vertical") * moveSpeed;
   hInput = Input.GetAxis("Horizontal") * rotateSpeed;
 
  if(Input.GetKeyDown (KeyCode .Space))
  
    _rb.AddForce(Vector3.up * jumpVelocity!ForceMode.Impulse);
   
  //this.transform.Translate(Vector3.forward * vInputTime.deltaTime);
//this.transform.Rotate(Vector3.up * hInputTime.deltaTime);

  void FixedUpdate()
 
   //No changes needed ...
 

 下面对上述代码进行解释。

创建一个变量来保存施加的跳跃力的大小,可以在Inspector 面板中进行调整

指定的键位被按下后,Input.GetKeyDown 方法将返回一个布尔值

  • GetKeyDown方法接收一个键位参数,可以是字符串或 KeyCode,其中KeyCode 是枚举类型。可使用 KeyCode.Space 方法对指定的键位进行检测。
  • 使用if语句检查 GetKeyDow 方法的返回值。如果返回tue,则执行i语句的语句体。

由于已经保存了 Rigidbody 组件,因此可以将 Vector3 和 ForceMode 参数传RigidbodyAddForce 方法以使玩家跳跃。

  • 向量(或施加的力)应该沿着up 方向并乘以jumpVelocity。
  • ForceMode 参数也是枚举类型,它决定了力是如何施加的。Impulse 表示给对象传递考虑了物体质量的即时力,这对跳跃机制来说很完美。

刚刚发生了什么
如果运行游戏,现在就可以向四周移动并且按下空格键来使玩家跳跃。但是,现在的机制会让玩家无限次地进行跳跃,这不是我们想要的结果。8.1.2 节将使用层遮罩来限制跳跃次数为单次。

使用层遮罩

层遮罩可以理解为用来归类游戏对象的不可见分组,Unity 的物理系统将使用这些分组来决定从寻路到碰撞体相交的一切表现。关于层遮罩的更多使用方式超出了本书的讨论范围,我们将创建并使用一个层级来执行简单的检查一一检查玩家是否触地。

实践:设置对象层级

在检查玩家是否触地前,首先把关卡中的所有对象添加到自定义的层遮罩中。这样就可以利用玩家对象上已有的Capsule Collider 来执行碰撞计算。

()选中Hicrarchy面板中的任意对象并选择 LayerAdd Layer,

 (2)向可用的第一个位置添加一个新的层级,命名为Ground,

 (3)在 Hierarchy 面板中选中父对象 Enviroment,选择 Layer|Ground,当弹出提示框询问是否应用至所有子对象时,单击 Yes 按钮。

 刚刚发生了什么
默认情况下,Unity 引擎使用了层级 07,在剩下的 24 个位置可以自定义层级。这里定义了一个新的名为Ground 的层级并将 Enviroment 对象的所有子对象添加到了这个层级中。之后就可以检查处于 Ground 层级的所有对象是否与某个指定的物体相交了。

实践:限制重复跳跃

由于不想使 Update 方法变得混乱不堪,因此我们将层遮罩的相关计算写到一个工具函数中,并根据结果返回 true 或false。

(1)添加如下代码至PlayerBehavior 脚本并运行游戏:

public class PlayerBehavior : MonoBehaviour

 public float moveSpeed = 10f;
public float rotateSpeed = 75f;
public float jumpVelocity = 5f;
public float distanceToGround = 0.1f;
public LayerMask groundlayer;
private float vInput;
private float hInput;
private Rigidbody  _rb;
private CapsuleCollider _col;
void Start()

 _rb = GetComponent<Rigidbody>();
 _col = GetComponent<CapsuleCollider>();

void Update()

  _vInput = Input.GetAxis("Vertical")*moveSpeed;
  _hInput = Input.GetAxis("Horizontal") * rotateSpeed;
  if(IsGrounded() & Input .GetKeyDown (KeyCode.Space))

  _rb.AddForce(Vector3.up * jumpVelocity,ForceMode.Impulse);


void FixedUpdate()

 
   //... No changes needed ..

private bool IsGrounded()

 Vector3 capsuleBottom = new

Vector3( _col.bounds.center.x,_col.bounds .min.y,_col.bounds.center.z);
Bool grounded =Physics.CheckCapsule(_col.bounds.center,capsuleBottom,distanceToGround,groundlayer,QueryTriggerInteraction.Ignore);
return grounded;


(2)在Inspector 面板中设置 Ground Layer 为 Ground.

下面对步骤(2)中的代码进行解释。

创建一个 float 变量来保存任意处于 Ground 层级的对象与 Player 对象的CapsuleCollider 组件之间的距离。

创建一个LayerMask 变量来进行碰撞检测,可以在Inspector 面板中进行设置

创建一个私有变量来保存玩家的 CapsuleCollider 组件

使用GetComponent0方法查找并返回 Player 对象上挂载的 CapsuleCollider组件

修改if语句,在执行跳跃之前检查IsGrounded 方法是否返回 te 以及空格键是否被按下。

声明将会返回一个布尔值的IsGrounded方法。

创建一个 Vector3 局部变量来保存 Player 对象的 CapsuleCollider 组件的底部置,我们将使用该位置判定与 Ground 层级中的对象发生的碰撞。

  • 所有 Collider 组件都包含 bounds 属性,可以通过 min、max 和 center 子属性来
    访问最小点、最大点和中心位置。
  • 碰撞体的底部是指三维空间中的点坐标(center.x,min.y,center.z)。

创建一个布尔局部变量来保存从Physics 类调用的 CheckCapsule 方法的结果该方法接收如下5 个参数:

  • 胶囊的起始位置,可设置为碰撞体的中心位置,因为我们只关心胶囊的底部是否接触地面。
     
  • 胶囊的结束位置,可传入已经计算好的 capsuleBottom。
  • 胶囊的半径,可传入 distanceToGround。
  • 想要用来检查碰撞的层遮罩,可传入 Inspector 面板中已经设置好的groundLayer。
  • 触发器的查询行为决定了 CheckCapsule 方法是否忽略设置为触发器的碰体。因为不需要检查触发器,所以使用枚举QueryTriggerInteraction.Ignore

计算结束,返回 grounded 中存储的结果。

刚刚发生了什么
添加至 PlayerBehavior 脚本的方法有些涩难懂,但分解后,我们发现要做的事情只是使用一个来自 Physics类的方法。用简单的语言解释就是,我们向 CheckCapsule方法提供了起点和终点、碰撞半径以及层遮罩。如果终点位置与 Ground 层级中的某个物体之间的距离小于碰撞半径,CheckCapsule 方法就返回 ue,这意味着玩家触地了。若玩家正处于跳跃过程中,CheckCapsule 方法就返回 false。因为每一帧都将在Update方法中使用if语句检查IsGround,因此只有当玩家触地时,才允许进行跳跃

发射投射物

射击机制在游戏中十分常见,第一人称射击游戏中必然包含射击机制的某些变种Hero Bom游戏也不例外。本节将讨论如何在游戏运行时从预制体实例化游戏对象以及利用Unity的物理系统将这些对象向前射出。

实例化对象

在游戏中实例化游戏对象的概念与实例化类相同一一都需要某个初始值,这样C#才知道需要创建什么对象以及在何处创建。在场景中实例化游戏对象时,可以使用Instantiate 方法简化整个流程,只需要提供预制体对象、起始位置以及朝向即可。实际上,也可以使用 Unity 创建包含所需脚本和组件的对象,使之朝向指定的方向,然后在3D空间中按需进行调整。

实践:创建投射物预制体

在射击任何投射物之前,首先需要创建预制体。

1)在Hierarchy 面板中使用 Createl3D Obiect  Sphere 创建一个球体,命名为Bullet。然后修改Transform组件的各个轴的缩放值均为0.15

(2)单击Add Component 按钮,查找并添加 Rigidbody 组件,保留默认设置即可。

(3)使用 Create|Material 在 Materials 文件夹中创建一个新的材质,命名为Orb Mat。

  • 修改AIbedo 属性为深黄色。
  • 将Orb Mat 材质拖曳至 Bullet 对象上。

(4)拖放 Bullet对象至 Prefabs 文件夹

 刚刚发生了什么
我们创建并配置了 Bullet 预制体,这个预制体在游戏中可以实例化任意多次,并且可按需进行修改。

实践:添加射击机制
现在已经有可用的预制体了,在任何时候,当按下鼠标左键进行射击时,都可实例化并移动预制体的副本。

(1)按如下代码修改 PlayerBehavior 脚本:

public class PlayerBehavior : MonoBehaviour

  public float moveSpeed = 10f;
  public float rotateSpeed = 75;
  public float jumpVelocity = 5f;
  public float distanceToGround=0.1f;
  public LayerMask groundLayer;
  public GameObject bullet;
  public float bulletSpeed = 100f;
  private float _vInput;
  private float _hInput;
  private Rigidbody _rb;
  private CapsuleCollider _col;
  void Start()
  
    // ... No changes needed.
  
  void Update()
  
  // ... No changes needed ...
  
  void FixedUpdate()
  
    Vector3 rotation = Vector3.up * _hInput * Time.fixedDeltaTime;
    Quaternion deltaRotation = Quaternion.Euler(rotation);
    _rb.MovePosition(this.transform.position +this.transform.forward *_vInput * Time.fixedDeltaTime);
    _rb.MoveRotation( rb.rotation * deltaRotation);
    if (Input.GetMouseButtonDown(0))
     
     GameObject newBullet = Instantiate(bullet,this.transform.position,this.transform.rotation) as GameObject;
     Rigidbody bulletRB = newBullet.GetComponent<Rigidbody>();
     bulletRB.velocity = this.transform.forward * bulletSpeed;
  

  private bool IsGrounded()

  // .. No changes needed ..

(2)拖动 Bullet 预制体到 PlayerBehavior 脚本的 Inspector 面板中的 Bullet 属性上 

(3)运行游戏并使用鼠标左键向玩家开火!
下面对步骤2)中的代码进行解释。

创建两个公共变量:一个用来保存 Bullet 预制体;另一个用来保存子弹的速度

使用f语句检查 Input.GetMouseButtonDown 方法是否返回 true,就像之前查InputGetKeyDown方法一样。GetMouseButtonDown方法接收一个int类型的参这个参数的值决定了想要检测的鼠标按键: 0 表示左键,1表示右键,2 表示中滚轮。

每当鼠标左键被按下时,就创建一个 GameObiect 局部变量。
使用Instantiate 方法为 newBullet 变量赋值,向该方法传入 Bullet预制体,并以胶囊的位置和旋转作为起始值。

添加as GameObiect 以显式地转换所返回对象的类型,从而与 newBullet 的类型一致。

调用GetComponent 方法以返回 newBullet 上的 Rigidbody 组件并保存。

设置 Rigidbody 组件的 velocity 属性为玩家的 tranform.forward 万向乘以bulletSpeed。通过直接修改 velocity 而不是使用 AddForce 方法,可以确保开火时重力不会使弹道下坠为弧形。

管理游戏对象的创建

无论是编写应用还是 3D 游戏,都需要确保定期删除未使用的对象以避免程序过载。子弹在射出后就不那么重要了,但此类对象仍存在于关卡中与其发生碰撞的对象和墙体的附近。这种射击机制会导致场景中存在成百上千颗子弹,这不是我们想要的效果。

实践:销毁子弹
为了达到目的,我们可以想办法让子弹执行自身的销毁行为。
(1)在Scripts文件夹中创建一个新的C#脚本,命名为 BulletBehavior。
(2)拖放 BulletBehavior 脚本至 Prefabs 文件夹中的Bullet预制体上。
(3)在BulletBehavior 脚本中添加如下代码:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class BulletBehavior : MonoBehaviour 

    public float onscreenDelay = 5f;

	void Update () 
    
        Destroy(this.gameObject, onscreenDelay);
	

 下面对步骤(3)中的代码进行解释

声明一个 float 变量来保存 Bullet 预制体实例化之后要在场景中保留的时间

使用 Destroy 方法删除GameObject。

  • Destroy 方法始终需要一个对象作为参数,在本例中,可使用 this 关键字指定脚本被附加到的对象
  • Destroy 方法还能使用可选的 float 参数来表示延迟时间,从而使子弹在屏幕上保留一小段时间。

刚刚发生了什么
Bullet 预制体会在指定的延迟时间过后从场景中销毁自身。这意味着子弹会执行自身定义的行为,无须其他脚本干预,这是对“组件”设计模式的理想应用。第 12章将讨论更多相关内容。

游戏管理器

学习编程的常见误区是把所有变量都设置为公共的。根据经验,首先应考虑将变量设为受保护的或私有的,仅当必要时再设为公共的。有经验的程序员会通过管理类来保护数据,为了养成好习惯,我们也会这样做。可以将管理类理解为安全访问重要变量和方法的通道。
pub
在编程中讨论安全性听起来有些奇怪。然而,当不同的类互相访问并更新数据时,事情会变得一团糟。只保留单个诸如管理类的联系点,可使影响变得最小。

维护玩家属性

Hero Bom 是一款十分简单的游戏,需要维护的数据只有两项:一是玩家收集了多少物品:二是玩家还剩多少生命值。可将这些变量设为私有的,使它们只能由管理类修改以保证受控且安全。

实践:创建游戏管理器

游戏管理类对于将来开发任何项目都是必需的,我们先来学习如何合适地创建游戏管理类。

(1)在 Scripts 文件夹中创建一个新的 C#脚本,命名为 GameBehavior。通常来说这个脚本应该命名为GameManager,但是 Unity 保留了这个名称供自己使用。
(2)在 Hicrarchy 面板中使用 Create  Create Empty 创建一个空对象并命名为GameManager。然后向 GameManager 空对象附加GameBehavior 脚本,

(3)添加如下代码至GameBehavior 脚本中:

public class GameBehavior : MonoBehaviour

 private intitemsCollected = 0;
private int_playerHP = 10;

 上述代码添加了两个私有变量来保存拾取的物品数量以及玩家剩余的生命值,设置为私有的是因为它们只能由 GameBehavior 类修改。如果设为公共的,其他类可能会修改它们,导致其中存储的数据不正确。

get和set属性

我们已经设置好了管理类脚本与私有变量,如何从其他类访问这些私有变量呢?我们可以通过向 GameBehavior 类添加不同的公共方法来向私有变量传递新值,但是还有没有更好的办法呢?

在这种情况下,C#为所有变量提供了 get 和 set 属性,从而完美地满足了现在的需求。可以将这些属性理解为由 C#编译器自动触发的方法,而无论是否显式地调用它们就像场景刚开始时 Unity 自动执行的 Start和Update方法一样。
get 和 set 属性能被添加至任何变量,包含或不包含初始值皆可:

public string firstName get; set;;

public string lastName get; set;= "Smith";

然而,仅仅这样使用没有任何附加效果;为此,需要让每个属性包含一个代码块

public string FirstName

 get 

  // Code block executes when variable is accessed

set 

  // Code block executes when variable is updated

 现在,根据变量使用的位置,get 和 set 属性会执行附加逻辑。由于还没有完成全部工作,因此仍然需要处理这些新的逻辑。
每个get代码块都需要返回一个值,而每个set代码块则需要赋予一个值,这里正是结合使用私有变量(称为后备变量)和具有 get 及 set 属性的公共变量的好地方。私有变量将受到保护,其他类则可以受控访问公共变量。

private string firstName
public string FirstName 

  get 
  
   return _firstName;
   
 set
  
   _firstName=value;
  

 下面对上述代码进行解释。

  • 任何时候,当其他类需要时,可以使用 get 属性返回存储在私有变量中的值下面对上述代码进行解释。而不需要实际将变量暴露给外部类。
  • 任何时候,当使用外部类给公共变量赋值时,可以更新私有变量,使二者同步

不进行实际应用,上述解释阅读起来会有点深奥。可利用已有的私有变量,GameBehavior脚本,添加具有 get 和 set 属性访问器的公共变量。

实践:添加后备变量

你已经理解了 get 和 set 属性访问器的语法,下面在管理类中实现它们,从而使代码更高效、更具可读性。
按如下所示修改GameBehavior脚本中的代码:

public class GameBehavior : MonoBehaviour

  private int _itemsCollected = 0;
  public int Items
  
   get  return _itemsCollected;
   set 
   
    _itemsCollected = value ;
    Debug.LogFormat("Items:0",_itemsCollected);
   
  
   private int_playerHP = 3;
   public int HP
 
  get  return _playerHP; 
  set
   _playerHP = value;
   Debug.LogFormat("Lives:0",_playerHP);
   
  

 下面对上述代码进行解释。

声明名为 Items 的公共变量,其中包含 get 和 set 属性。

 外部类访问 Items 变量时,使用 get 属性返回存储于 itemsCollected 中的值。

使用sct 属性在 lcms 变量被更新时为 itemCollected 赋新值,同时添加Debug.LogFormat 方法以打印修改后的 itemsCollected 的值。

设置具有get和set 属性的公共变量HP,从而对后备变量 playerHP 进行补充

刚刚发生了什么
GameBehavior脚本的两个私有变量现在都可以访问了,但是仅允许访问公开的部分。这确保了私有变量只能在特定的位置进行访问和修改。

实践:更新物品集合
我们已经设置好了 GameBehavior 脚本中的变量,每次在场景中收集 Pickup_Item时都可以更新Items变量。

(1)在ItemBehavior脚本中添加如下代码:

public class ItemBehavior : MonoBehaviour



void Start()

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

void OnCollisionEnter(Collision collision)

  if (collision.gameObject.name == "Player")
  
   Destroy(this.transform.parent.gameObject);
   Debug.Log("Item collected!");
   gameManager.Items += 1;


(2)运行游戏并收集物品,查看管理类脚本输出到控制台中的信息

下面对步骤(1)中的代码进行解释。

创建一个GameBehavior类型的变量来保存对脚本的引用。

在Start 方法中,使用 Find 方法查找对象并添加 GetComponent 方法以初始化gameManager。

当Pickup_Item 对象被销毁后,就在 gameManager 中增加Items 属性的值

刚刚发生了什么
由于已经在 ItemBehavior 类中处理好了碰撞逻辑,因此我们可以很容易地修改onolisionEnter 方法,从而在玩家拾取物品时与管理类进行沟通。将功能分离能使代召更具弹性,在开发期间进行修改时出错的可能性也会降低。

精益求精

目前,多个脚本共同配合,进而实现了玩家的移动、跳跃、收集、射击等机制。但是,现在仍然缺少用来展示玩家状态的显示内容或视觉提示,并且缺少游戏的胜败条件。本节将重点关注这两个主题。

图形用户界面

用户界面是任何计算机系统都有的可视组件,通常称为 UI。鼠标指针、文件夹以及桌面上的程序图标都是 UI元素。我们的游戏需要拥有简单的UI以使玩家知道已经集了多少物品以及当前的生命值,还需要一个能在发生特定事件时进行更新的文本框在Unity 中添加UI元素有两种方式:

  • 直接使用 Hierarchy 面板中的 Create 菜单进行创建,就像创建其他游戏对象一样。
  • 在代码中使用内置的 GUI类。

我们将一直使用代码方式,这么做并非因为代码方式优于另一种,而是为了与之前保持一致。

GUI类提供了一系列方法来创建和摆放组件,所有 GU方法都可在 MonoBehaviour脚本的OnGUI方法中进行调用。可以将 OnGUI方法理解为用于UI的 Update 方法.

实践:添加 UI 元素

目前还不需要向玩家显示很多信息,但是我们应该将需要显示的信息以令人愉悦引人注目的方式显示在屏幕上。
(1)按如下代码修改GameBehavior 脚本并收集物品:

public class GameBehavior : MonoBehaviour

  public string labelText = "Collect all 4 items and winyour freedom!";public int maxItems = 4;
  private int _itemsCollected = 0;
  public int Items

   get  return _itemsCollected; 
   set_itemsCollected = value;
   if(_itemsCollected >= maxItems)
   
     labelText = "You've found all theitems!";
   
   else
   
    labelText = "Item found, only " + (maxItems-_itemsCollected) + " more to go!";
   


 private int _playerLives =3;
 public int Lives

 get  return _playerLives;
 set 
     _playerLives = value;
Debug.LogFormat("Lives:(0),_playerLives);


void OnGUI()

  GUI.Box(new Rect(20,20,150,25),"Player
Health:"+_playerLives);
   GUIBox(new Rect(20,50,150,25),nItemsCollected: + _itemsCollected);
  GUI.Label(new Rect(Screen.width / 2 - 100,Screen.height - 50,300,50),labelText);


(2) 运行游戏,用户界面 

 下面对步骤(1)中的代码进行解释。

创建两个公共变量:一个表示要在屏幕底部显示的文本;另一个表示关卡中品的最大数量。

在itemsCollected变量的set 属性中声明一条if语句。

  • 如果玩家收集的物品的数量大于或等于 maxItems,那么玩家赢得游戏并目回顾第6新 labelText。
  • 否则,使用 labelText 显示还需要收集多少物体。

声明OnGUI方法以包含UI代码。

通过指定位置、大小与字符串信息来创建 GUIBox 方法

  • Rect 类的构造函数将接收宽度和高度值作为参数。
  • Rect对象的起始位置始终为屏幕的左上角。
  • 使用new Rect(20.20.150.25)可创建一个位于场景左上角的2D方框距离场景的左侧边界 20 像素,距离顶部边界也 20 像素,宽度为 150像素,高度为25像素。

在生命值方框的下面创建另一个方框以显示当前的物品数量

在屏幕的底部创建一个标签以显示 labelText

  • 因为OnGUI方法每帧至少会执行一次,所以在任何时候当abelText的值发生变化时,都会在屏幕上进行更新。
  • 这里使用Screen类的width和height属性获取绝对位置而不是手动计赛展幕的中心位置。

刚刚发生了什么

当我们运行游戏时,三个UI元素都显示了正确的值。每当收集一个Pickup llem时,lableText和 itemsCollected 都会得到更新

胜败条件

游戏的核心机制与简易UI都已实现,Hero Bom游戏还缺少如下重要的射击元素胜败条件。胜败条件用于管理玩家赢得游戏还是失败,并根据情况执行不同的代码

  • 收集关卡中的所有道具且生命值至少为1时胜利。
  • 受到敌人伤害且直到生命值变为0时失败。

以上条件会影响 UI以及游戏机制,但这些都已在 GameBehavior 脚本中高效处理过了。get和 set 属性会处理任何游戏相关逻辑,而GUI方法则会在玩家胜利或失败时改变UI。

实践:赢得游戏

为了给玩家带来清晰且即时的反馈,下面从添加胜利条件的逻辑开始。

(1)按如下代码修改GameBehavior 脚本

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
using CustomExtensions;

public class GameBehavior : MonoBehaviour, IManager

    public string labelText = "Collect all 4 items and win your freedom!";
    public readonly int maxItems = 4;
    public bool showWinScreen = false;
    public bool showLossScreen = false;

    public delegate void DebugDelegate(string newText);
    public DebugDelegate debug = Print;

    private string _state;
    public string State 
    
        get  return _state; 
        set  _state = value; 
    

    private int _itemsCollected = 0;
    public int Items
    
        get  return _itemsCollected; 
        set  
            _itemsCollected = value;

            if (_itemsCollected >= maxItems)
            
                labelText = "You've found all the items!";
                showWinScreen = true;
                Time.timeScale = 0;
            
            else
            
                labelText = "Item found, only " + (maxItems - _itemsCollected) + " more to go!";
            
        
    

    private int _playerLives = 3;
    public int Lives 
    
        get  return _playerLives; 
        set  
            _playerLives = value; 

            if(_playerLives <= 0)
            
                labelText = "You want another life with that?";
                showLossScreen = true;
                Time.timeScale = 0;
            
            else
            
                labelText = "Ouch... that's got hurt.";
            
        
    

    void Start()
    
        Initialize();

        InventoryList<string> inventoryList = new InventoryList<string>();
        inventoryList.SetItem("Potion");
        Debug.Log(inventoryList.item);
    

    public void Initialize() 
    
        _state = "Manager initialized..";
        _state.FancyDebug();

        debug(_state);
        LogWithDelegate(debug);

        PlayerBehavior playerBehavior = GameObject.Find("Player").GetComponent<PlayerBehavior>();
        playerBehavior.playerJump += HandlePlayerJump;
    

    public void HandlePlayerJump(bool isGrounded)
    
        if(isGrounded)
        
            debug("Player has jumped...");
        
    

    public static void Print(string newText)
    
        Debug.Log(newText);
    

    public void LogWithDelegate(DebugDelegate debug)
    
        debug("Delegating the debug task...");
    

	void OnGUI()
	
        GUI.Box(new Rect(20, 20, 150, 25), "Player Health: " + _playerLives);
        GUI.Box(new Rect(20, 50, 150, 25), "Items Collected: " + _itemsCollected);
        GUI.Label(new Rect(Screen.width / 2 - 100, Screen.height - 50, 300, 50), labelText);

        if (showWinScreen)
        
            if (GUI.Button(new Rect(Screen.width/2 - 100, Screen.height/2 - 50, 200, 100), "YOU WON!"))
            
                Utilities.RestartLevel();
            
        

        if(showLossScreen)
        
            if (GUI.Button(new Rect(Screen.width / 2 - 100, Screen.height / 2 - 50, 200, 100), "You lose..."))
            
                try
                
                    Utilities.RestartLevel(-1);
                    debug("Level restarted successfully...");
                
                catch (System.ArgumentException e)
                
                    Utilities.RestartLevel(0);
                    debug("Reverting to scene 0: " + e.ToString());
                
                finally
                
                    Utilities.RestartLevel(0);
                    debug("Restart handled...");
                
            
        
	

 (2)在Inpsector 面板中将Max Items 修改为1,然后进行测试,

 下面对步骤(1)中的代码进行解释。
创建一个新的布尔变量来维护胜利界面出现的时机。
当玩家收集完所有物品时,在 Items 对象的set 属性中将showWinScreen 设置为true。

在OnGU方法的内部使用 if 语句检查胜利界面是否应该显示

在屏幕的中央创建一个可单击的按钮。

  • GULBumon 方法将返回一个布尔值,当这个按钮被单击时返回 true,否则返回
    false.
  • 在i语句中调用GULButton 方法,从而当这个按钮被单击时执行f语句的语句体

刚刚发生了什么
maxItems 被设置为1,胜利按钮会在收集完场景中唯一的 Pickup Item 后出现
但是现在单击这个按钮不起任何作用,

使用预编译指定和命令空间

胜利条件可以按预期方式运行了,但是胜利后,玩家仍然可以控制胶囊,而且游戏一旦结束,尚没有办法重新开始。Unity 的 Time 类提供了 timeScale 属性,当这属性被设置为 0 时就会暂停整个游戏。为了重新开始游戏,我们需要访问命名空间SceneManagement。默认情况下,这个命名空间还无法从我们的类中直接访问。
命名空间可以将一系列类包含在某个特定的名称下,进而组织大型项目并避免共用相同名称的脚本间产生冲突。可通过向类中添加 using 指令以访问另一个命名空间中的类。
所有通过 Unity创建的C#脚本都包含如下三条默认的 using 指令:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

 这样就可以访问常用的命名空间了。Unity 和 C#提供了非常多的功能,可以通过在关键字 using之后加上命名空间的名称来进行添加。

实践:暂停与重启游戏
我们的游戏需要在玩家胜利或失败时能够暂停和重启。为此,我们需要引入新建的C#脚本默认都不会包含的命名空间。
在GameBehavior脚本中添加如下代码并运行游戏。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
using CustomExtensions;

public class GameBehavior : MonoBehaviour, IManager

    public string labelText = "Collect all 4 items and win your freedom!";
    public readonly int maxItems = 4;
    public bool showWinScreen = false;
    public bool showLossScreen = false;

    public delegate void DebugDelegate(string newText);
    public DebugDelegate debug = Print;

    private string _state;
    public string State 
    
        get  return _state; 
        set  _state = value; 
    

    private int _itemsCollected = 0;
    public int Items
    
        get  return _itemsCollected; 
        set  
            _itemsCollected = value;

            if (_itemsCollected >= maxItems)
            
                labelText = "You've found all the items!";
                showWinScreen = true;
                Time.timeScale = 0;
            
            else
            
                labelText = "Item found, only " + (maxItems - _itemsCollected) + " more to go!";
            
        
    

    private int _playerLives = 3;
    public int Lives 
    
        get  return _playerLives; 
        set  
            _playerLives = value; 

            if(_playerLives <= 0)
            
                labelText = "You want another life with that?";
                showLossScreen = true;
                Time.timeScale = 0;
            
            else
            
                labelText = "Ouch... that's got hurt.";
            
        
    

    void Start()
    
        Initialize();

        InventoryList<string> inventoryList = new InventoryList<string>();
        inventoryList.SetItem("Potion");
        Debug.Log(inventoryList.item);
    

    public void Initialize() 
    
        _state = "Manager initialized..";
        _state.FancyDebug();

        debug(_state);
        LogWithDelegate(debug);

        PlayerBehavior playerBehavior = GameObject.Find("Player").GetComponent<PlayerBehavior>();
        playerBehavior.playerJump += HandlePlayerJump;
    

    public void HandlePlayerJump(bool isGrounded)
    
        if(isGrounded)
        
            debug("Player has jumped...");
        
    

    public static void Print(string newText)
    
        Debug.Log(newText);
    

    public void LogWithDelegate(DebugDelegate debug)
    
        debug("Delegating the debug task...");
    

	void OnGUI()
	
        GUI.Box(new Rect(20, 20, 150, 25), "Player Health: " + _playerLives);
        GUI.Box(new Rect(20, 50, 150, 25), "Items Collected: " + _itemsCollected);
        GUI.Label(new Rect(Screen.width / 2 - 100, Screen.height - 50, 300, 50), labelText);

        if (showWinScreen)
        
            if (GUI.Button(new Rect(Screen.width/2 - 100, Screen.height/2 - 50, 200, 100), "YOU WON!"))
            
                Utilities.RestartLevel();
            
        

        if(showLossScreen)
        
            if (GUI.Button(new Rect(Screen.width / 2 - 100, Screen.height / 2 - 50, 200, 100), "You lose..."))
            
                try
                
                    Utilities.RestartLevel(-1);
                    debug("Level restarted successfully...");
                
                catch (System.ArgumentException e)
                
                    Utilities.RestartLevel(0);
                    debug("Reverting to scene 0: " + e.ToString());
                
                finally
                
                    Utilities.RestartLevel(0);
                    debug("Restart handled...");
                
            
        
	

 下面对上述代码进行解释。

使用 using 关键字添加 SceneManagement 命名空间,Unity 提供的这个命名空间会处理所有场景相关的逻辑。

当胜利界面出现时,把 Time.timeScale 设置为0以暂停游戏,从而禁止任何输入和移动。

当单击胜利界面中的按钮时,调用 LoadScene 方法。

  •  LoadScene 方法接收一个int 类型的参数来表示场景的索引。
  • 因为项目中只有一个场景,所以使用索引0来重新开始游戏。

重新打开场景后,把Time.timeScale 重置为默认值 1,这样所有控件和行为就可以再次执行了。

刚刚发生了什么
现在,当玩家收集物品并单击胜利界面中的按钮时,关卡会重启,所有脚本和组件都会被重置为原始值并为下一轮游戏做准备。

总结

恭喜!从玩家的视角看,Hero Bor 游戏现在已处于可玩状态。我们实现了跳跃和射击机制,对物理碰撞进行了管理并生成了对象,还添加了少量的基础性 I 元素来给予反馈。你甚至可以在玩家胜利时重置关卡!
本章介绍了大量新的主题,一定要回顾并确保自己真的理解所写代码中发生了什么。尤其要掌握枚举、get 和 set 属性以及命名空间方面的知识。从本章开始,随着进步探究 C#语言,代码只会变得越来越复杂。
在第9章,我们将使敌人在与玩家距离过近时能够注意到玩家,从而执行跟随射击行为,以此增大玩家收集物品时的风险。

从零开始开发一款H5小游戏 攻守阵营,赋予粒子新的生命

本系列文章对应游戏代码已开源 Sinuous game

每个游戏都会包含场景和角色。要实现一个游戏角色,就要清楚角色在场景中的位置,以及它的运动规律,并能通过数学表达式表现出来。

场景坐标

canvas 2d的场景坐标系采用平面笛卡尔坐标系统,左上角为原点(0,0),向右为x轴正方向,向下为y轴正方向,坐标系统的1个单位相当于屏幕的1个像素。这对我们进行角色定位至关重要。

技术图片

Enemy粒子

游戏中的敌人为无数的红色粒子,往同一个方向做匀速运动,每个粒子具有不同的大小。

入口处通过一个循环来创建Enemy粒子,随机生成粒子的位置x, y。并保证每个粒子都位于上图坐标系所在象限中。由于 map.width <= x <= 2 * map.width,所以粒子最开始是看不到的。

//index.js
function createEnemy(numEnemy) {
    enemys = [];
    for (let i = 0; i < numEnemy; i++) {
        const x = Math.random() * map.width + map.width;
        const y = Math.random() * map.height;
        enemys.push(new Enemy({x, y}));
    }
}

接下来只要在update中给粒子一个位移偏量speed,粒子就会做匀速运动。speed越大,速度越快。

update() {
    this.x -= this.speed; //speed为位移偏量
    this.y += this.speed;
}

由于红色粒子看起来是无穷无尽的,而我们只是创建了有限个粒子,所以需要在粒子离开视界的时候重置粒子的位置。视界之外的位置开始运动,并保证该位置的随机性。

//Enemy.js
update() {
    this.x -= this.speed; //speed为位移偏量
    this.y += this.speed;
    
    //粒子从左边离开视界
    if (this.x < -10) {
        this.x = map.width + 10 + Math.random() * 30;
    }
    //粒子从底部离开视界
    if (this.y > map.height + 10) {
        this.y = -10 + Math.random() * -30;
    }
}

可以用一张图来直观地表示Enemy粒子的运动过程

技术图片

Player粒子

玩家粒子则由鼠标控制,在上一节中我们已经简单介绍了游戏中的鼠标交互。

而在手机上的实现还略有差别。手机上的做法是监听手指的位移量并让Player粒子做偏移。而不是每次touch都重置粒子的位置,这样体验就会好很多。

//Player.js
if (isMobile) {
    self.moveTo(self.x, self.y);
    window.addEventListener(‘touchstart‘, e => {
        e.preventDefault();
        self.touchStartX = e.touches[0].pageX;
        self.touchStartY = e.touches[0].pageY;
    });
    //手机上用位移计算位置
    window.addEventListener(‘touchmove‘, e => {
        e.preventDefault();
        let moveX = e.touches[0].pageX - self.touchStartX;
        let moveY = e.touches[0].pageY - self.touchStartY;
        self.moveTo(self.x + moveX, self.y + moveY);
        self.touchStartX = e.touches[0].pageX;
        self.touchStartY = e.touches[0].pageY;
    });
} else {
    let left = (document.getElementById("game").clientWidth - 
            document.getElementById("world").clientWidth)/2;
    window.addEventListener(‘mousemove‘, (e = window.event) => {
        self.moveTo(e.clientX - left - 10, e.clientY - 30);
    });
}

Player 粒子值得一讲的就是它飘逸的尾巴。在经过反复尝试了多次后才实现这个效果。

首先想到要让尾巴长度固定,那么在每次render的时候,都在尾部渲染固定数量的粒子。那粒子的位置怎么判断呢?
在每次render的时候,我们往数组添加一个粒子,记录此时的Player坐标,当数组达到一定长度时,删除尾部粒子,添加新粒子。这样尾巴就记录了Player一个短时间内的各个时间点位置。看起来就像是"跟随"在Player粒子后面了。

//Player.js
render() {
    self.recordTail();
}

recordTail() {
    let self = this;
    //保持尾巴粒子个数不变
    if (self.tail.length > self.tailLen) {
        self.tail.splice(0, self.tail.length - self.tailLen);
    }
    self.tail.push({
        x: self.x,
        y: self.y
    });
}

这样只是记录了一些尾巴上点的位置,我们需要把各个点连起来。这里需要用到lineTo方法。

具体代码实现:

//Player.js
renderTail() {
    let self = this;
    let tails = self.tail, prevPot, nextPot;
    map.ctx.beginPath();
    map.ctx.lineWidth = 2;
    map.ctx.strokeStyle = self.color;

    for(let i = 0; i < tails.length - 1; i++) {
        prevPot = tails[i];
        nextPot = tails[i + 1];
        if (i === 0) {
            map.ctx.moveTo(prevPot.x, prevPot.y);
        } else {
            map.ctx.lineTo(nextPot.x, nextPot.y);
        }

        //保持尾巴最小长度,并有波浪效果
        prevPot.x -= 1.5;
        prevPot.y += 1.5;
    }

    map.ctx.stroke();
    
    self.renderLife();
}

如果只是连接各点,那只能画出Player划过的轨迹,我们还要给尾巴加上惯性效果,注意到上面有这两行代码

prevPot.x -= 1.5;
prevPot.y += 1.5;

每一次render中,让尾巴中的每个点x-1.5, y-1.5。实际上就是让粒子沿着左下方的方向运动,这跟Enemy粒子的方向是一致的。实现了尾巴惯性摆动的效果。

接下来就是添加尾巴上的生命点,这个就比较简单,只需在尾巴上间隔的某些点,画出圆形就可以了

//Player.js
//渲染生命值节点
renderLife() {
    let self = this;
    for(let j = 1; j <= self.livesPoint.length; j++) {
        let tailIndex = j * 5;
        let life = self.livesPoint[j - 1];
        life.render(self.tail[tailIndex]);
    }
}

//Life.js
render(pos) {
    let self = this;
    
    //粒子撞击后不渲染
    if (!this.dead) {
        map.ctx.beginPath();
        map.ctx.fillStyle = self.color;
        map.ctx.arc(pos.x, pos.y, 3, 0, 2 * Math.PI, false);
        map.ctx.fill();
    }
}

Skill粒子

Skill粒子实际上可以看做是Enemy中的一种特殊粒子,具有和Enemy一样的运动规律。代码中的Skill也是继承自Enemy的(这有点奇怪..)

Skill粒子具有不同的属性和颜色,实现起来也很简单。

//Skill.js
const COLORS = {
    shield: ‘#007766‘,
    gravity: ‘#225599‘,
    time: ‘#665599‘,
    minimize: ‘#acac00‘,
    life: ‘#009955‘
};
const TEXTS = {
    shield: ‘盾‘,
    gravity: ‘力‘,
    time: ‘慢‘,
    minimize: ‘小‘,
    life: ‘命‘
};

render() {
    var self = this;

    map.ctx.beginPath();

    self.color = COLORS[self.type];

    map.ctx.fillStyle = self.color;
    map.ctx.arc(self.x, self.y, self.radius, 0, Math.PI*2, false);
    map.ctx.fill(); 
}

到此游戏中的角色都介绍完了,下一节要讲的是 《从零开始开发一款H5小游戏(四) 撞击吧粒子-炫酷技能的实现》

本文转载于:猿2048https://www.mk2048.com/blog/blog.php?id=h2hjccjakaa


以上是关于从零开始做一款Unity3D游戏<三>——编写游戏机制的主要内容,如果未能解决你的问题,请参考以下文章

如果让你来做一款HTML5游戏,休闲还是重度?

从零开始开发一款H5小游戏 攻守阵营,赋予粒子新的生命

从零开始学Unity游戏开发

小白学习Unity 3D做经典游戏坦克大战日常

Unity3D开发小游戏Unity3D开发《3D迷宫》小游戏

Unity3D开发小游戏Unity3D开发《3D迷宫》小游戏