[3D 游戏实战开发 | 青训营笔记17]

204 阅读14分钟

前言

这是我参与[第五届青训营]伴学笔记创作活动的第 17 天,说到游戏,相信大家绝对是兴奋至极,那我们制造一款3D游戏绝对是很多童年的3D游戏的梦想,现在跟着来去实践一下吧

3D 游戏实战开发

目标受众:游戏开发零基础但想要学习游戏开发且有一定编程基础的游戏研发新人、大学生等。

课程目标

通过此文章,可以利用 Unity (或其他3D游戏引擎) 快速搭建电子游戏原型,参加 Game Jam 活动。电子游戏原型可以帮你完成下面的工作:

  • 快速试验、否定或修改游戏机制与规划。
  • 探索游戏的动态行为,理解由规则产生的可能结果
  • 确保规则和游戏元素易于被玩家理解。
  • 了解玩家对游戏的情绪反应。

简言之:一名程序员,如何在没有美术的前提下,制作一个游戏Demo,让别人亲身体验以了解游戏机制。

  1. 3D 实体搭建
    • 在游戏场景中搭建静态物体。
  2. 相机,光照,天空盒
    • 让实体在游戏场景中动起来
  3. 控制与碰撞
    • 营造游戏场景中的氛围感
  4. 玩法逻辑与UI
    • 为游戏注入玩法与规则的灵魂。

3D 实体搭建

3D 实体 | 变换组件 | 简单材质 | 预制体

3D 实体

  • 3D游戏是由一个个具有形状的实体组成的。每个实体在空间中存在于特定的位置,有特定的姿态 (旋转角度)。 微信截图_20230215181724.png

微信截图_20230215181713.png

实体的位姿态(Transform3D)

  1. 位置 Position (x, y, z),是一个三维向量坐标
  2. 旋转 Rotation (x,y z),是一个三维向量坐标 微信截图_20230215181732.png 微信截图_20230215181758.png 微信截图_20230215181739.png 微信截图_20230215181745.png
  3. 缩放 Scale (x,y, z),是一个三维向量坐标在Unity中,绝大部分情况下,是先缩放,后旋转,最后平移

3D 实体的创建

  1. 通过加载3D模型创建,如 fbxgltfobj.
  2. 通过组合参数化的基本几何体创建

微信截图_20230215181811.png 微信截图_20230215181805.png

3D 实体的绘制

  1. 材质。
  2. 颜色。
  3. 纹理。

微信截图_20230215181818.png

微信截图_20230215181826.png

微信截图_20230215181835.png

敌机模型制作

微信截图_20230215181855.png Cockpit(Sphere)
P[0,0,0] R[0,0,0] S[2,2,1]
Wing(Sphere)
P[0,0,0] R[0,0,0] S[5,5,0.5]

微信截图_20230215181901.png Cockpit(Sphere)
P[0,0,0] R[0,0,0] S[2,2,1]
Wing(Sphere)
P[0,0,0] R[0,0,0] S[6,4,0.5]

微信截图_20230215181910.png CockpitL(Sphere)
P[-1.5,0,0] R[0,0,0] S[1,3,1]
CockpitR(Sphere)
P[2,0,0] R[0,0,0] S[2,2,1]
Wing(Sphere)
P[0,0,0] R[0,0,0] S[6,4,0.5]

微信截图_20230215181918.png CockpitL(Sphere)
P[-1,0,0] R[0,0,0] S[1,3,1]
CockpitR(Sphere)
P[1,0,0] R[0,0,0] S[1,3,1]
Wing(Sphere)
P[0,0.5,0] R[0,0,0] S[5,1,0.5]

微信截图_20230215181925.png CockpitL(Sphere)
P[0,1,0] R[0,0,0] S[1.5,1.5,1.5]
Fuselage(Sphere)
P[0,1,0] R[0,0,0] S[2,4,1]
Wing_L(Sphere)
P[-1.5,0,0] R[0,0,-30] S[5,1,0.5)
Wing_R(Sphere)
P[1.5,0,0] R[0,0,30] S[5,1,0.5]

预制体

  • 将游戏对象保存在工程中,在需要的时候创建出来,这就是预制体(prefab).
  • 预制体存储着一个游戏对象,包括游戏对象的所有组件以及其下的所有子游戏对象 微信截图_20230215183603.png

微信截图_20230215183557.png

  1. 游戏开发,尤其是3D 游戏开发,我们在上学时候学到的面向对象编程(OPP) 不同,C++Java编程;最大的不同就是游戏引擎通常是采用组合的形式,或者说是基于对象的形式去游戏的开发,而不是面向对象开发的模式开发;
  2. 它们的区别是面向对象通常是封装、继承、多态的方法去,从一个父类不断的继承,继承出非常多的子类出来;当我们要继承一个主角的飞船和敌人的飞船的时候,一个父类或者说是基类,父类和子类有什么差异,我们会通过继承的方式,往里面塞不同的属性和表现;
  3. 但是在开发游戏当中有非常多的实体的概念比较的多,所以我们通常会使用一种叫EC(entity component)这个结构去开发,这其实更像是玩一盘积木,会往一个沙盒上面去摆不同这样的实体,而每个实体可以添加不同的组件,去修改它不同的表现;
  • 比如我们在主角手上放一把刀,那么主角就可以拿进行挥舞攻击;如果把主角的手上放一把枪,那么它可以去用枪去射击;基于这样的特性,我们更喜欢使用组件(component)挂载到不同的entiry 上,并放到场景上面去;
  1. 开始是时候会制作很多无数份的母体,母体又叫预制体;比如我们要做5种飞船叫Enemy_0 - 4;在游戏中,我们会克隆这么多的母体来,作为实例放到我们的游戏场景上面去。

相机,光照,天空盒

相机实体 | 两种投影方式 | 光照设置 | 天空盒

相机

  1. Clear Flag
  2. 背景颜色。
  3. Culling Mask
  4. 投影(透视、正交)

微信截图_20230215181950.png

微信截图_20230215181957.png

相机就是三维到二维的降维操作

透视投影(Perspective)与正交投影(Orthographic

微信截图_20230216101530.png

  1. 透视投影就是我们现实看到的投影,它是一个近大远小的这样的投影方式;正交投影,就是在近处和远处并没有这样的景深,类似于计算机辅助制图软件CAD工程当中,尤其是室内装修、装潢;你会发现近处和远处的东西是一样大的,为了更好确定家居比例,所以不会采取近大远小的透视关系的,这些一般会用到一些设计游戏,比如说最早期的主题医院
  2. 而偏写实的,不管是第一人称还是第三人称游戏,我们通常采用透视投影;运用的就是一个视锥体,近处会比较小,远处会比较大;这样的视锥体去过滤我们场景当中的元素,这些元素与我们的视锥体相交,因为这是会被相机去看到的,它就会被放到我们的画面上去,而在视锥体以外的元素,放到新的裁剪,因为它们不会被相机看到,而正交投影就是近和远一样大的立方体去裁我们的各种实体

光照

  1. 类型: 点光源、平行光、聚光灯、面积光。
  2. 颜色。
  3. 强度。
  4. 阴影类型

微信截图_20230215183932.png

在开发中,使用的比较多的还是点光源和平行光,而聚光灯和面积光,因为运算的效率通常不是很好;

天空盒

  1. 相机的清除标志设为“天空盒”
  2. 窗口-渲染-照明设置
  3. 环境-天空盒材质。 微信截图_20230216103742.png

微信截图_20230216103750.png

微信截图_20230216103802.png

天空盒就是图片素材

控制与碰撞

实时游戏时序 | 输入管理器 | 碰撞盒 | 标签与层级

396761e1-642a-4794-bd84-5a84652b5924.png

微信截图_20230215184122.png docs.unity3d.com/Manual/Exec…

为主角飞船添加控制逻辑

  1. 添加刚体组件
  • Add Component > Physics > Rigidbody
  • Use Gravity 设置为 false,忽略重力的影影响
  • isKinematic 设置为 true,飞船通过脚本而非力影响运动属性
  • 设置 Constraints ,冻结Z轴位移以及XYZ轴旋转
  1. 添加自定义脚本
  • Add Component > New Script

刚体就是在运动过程当中,不会发生形变的当前物质,如果一个物体受到力之后,它内部的任意两个分子之间的相对距离,距离不会发生变换,那我们就会将它称为刚体;它不会被弹簧拉伸或者是压缩,也不会向橡皮泥一样发生变形,那我们就认为是刚体。

MonoBehaviour

MonoBehaviour 是一个基类,所有 Unity 脚本都派生自该类

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

public class Enemy : MonoBehaviour
{
    // Start is called before the first frame update
    void Start()
    {
    
    }

    // Update is called once per frame
    void Update() {
    
    }
}
  • Start() 在首次调用任何 Update 方法之前在帧上调用 Start.
  • Update() 每帧调用 Update
  • FixedUpdate0 用于物理计算且独立于帧率。具有物理系统的频率:每个固定帧率帧调用该函数
  • LateUpdate() LateUpdate 在每一次调用 Update 函数后调用
  • OnGUI 系统调用 OnGUI 来染和处理 GUI 事件。OnGUI 实现可以每帧调用多次 (每个事件调用一次)。
  • OnDisable() 该函数在对象被禁用时调用。对象销毁时也会调用该函数
  • OnEnable() 该函数在对象变为启用和激活状态时调用

docs.unity3d.com/cn/2020.2/S…

Hero.cs 的代码

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

public class Hero : MonoBehaviour
{
    static public Hero S; // 单例对象
    [Header('Set in Inspector')]
    public float speed = 30;
    // 以下字段用来控制飞船的运动
    public float roolMult = -45;
    public float pitchMult = 30;
    [Header('Set Dynamically)]
    public float shieldLevel = 1;
    // Start is called before the first frame update
    void Start()
    {
       if(S=null) {
           S = this; // 设置单例对象
       } else {
           Debug.LogError('尝试重复设置 Hero 实例');
       }
    }
    // Update is called once per frame
    void Update() {
       // 从 Input(用户输入)类中获取信息
       float xAxis = Input.GetAxis('Horizontal');
       float yAxis = Input.GetAxis('Vertical');
       // 基于获取的水平轴和竖直轴信息修改 transform.position
       Vector3 pos = transform.position;
       pos.x += xAxis * speed * Time.deltaTime;
       pos.y += xAxis * speed * Time.deltaTime;
       transform.position = pos;
       // 位置变化时让飞船旋转一个角度,让飞船更有动感
       transform.rotaion = Quaternion.Euler(yAxis * pitchMult, xAxis * rollMult, 0);
    }
}
[Header('Set in Inspector')]
    public float speed = 30;
    // 以下字段用来控制飞船的运动
    public float roolMult = -45;
    public float pitchMult = 30;
    [Header('Set Dynamically)]
    public float shieldLevel = 1;

上面的代码片段就是代码下面UI界面的数值 0e5c583e-40ce-4b4c-b23d-50e03593f19d.png 下载.gif

Input.GetAxis0 和 输入管理器(InputManager)

  • InputManagerUnity 设置输入响应方式的管理列表,它的位置在 Edit > ProjectSetting > Input 中.

微信截图_20230215190851.png

添加敌机

  • 为每架敌机预制体添加一个刚体。
    • 选中敌机预制体,在菜单栏执行 Component > Physics > Rigidbody
    • 在新添加的刚体组件中,将 Use Gravity 设置为 false
    • isKinematic 设置为 true
    • 打开 Constraints 旁边的三角形展开按钮,冻结Z轴的坐标和XYZ轴的旋转
  • 建立敌机的脚本 Enemey.cs
  • 为每架敌机预制体均添加脚本 Enemy.cs

添加敌机是通过AI进行设计,主角的操作是通过监听用户操作去完成的

Enemy.cs 的代码:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Enemy:MonoBehaviour
{
  [Header("Set in Inspector: Enemy")]
  public float speed = 10f; // 运动速度,以m/s为单位
  public float fireRate = 0.3f; // 发射频率 (暂未使用)
  public float health = 10;
  public float score = 100; // 击毁敌机获得的分数// pos 是一个属性,即行为表现与字段相似的方法
  public Vector3 pos {
    get{
    return(this.transform.position);
    }
    set{
      this.transform.position = value;
    }
  }
  // Update is called once per frame
  void Update()
  {
    Move();
  }
  public virtual void Move(){
    Vector3 tempPos = pos;
    tempPos.y -= speed * Time.deltaTime;
    pos = tempPos;
  }
}

2.gif

随机生成敌机

  • 新建一个名为 MainC# 脚本,绑定到 Main Camera
using System.Collections;
using System.Collections.Generic:
using UnityEngine;
using UnityEngine.SceneManagement; // 用于加载和重载场景
public class Main : MonoBehaviour
{
  private float camWidth; // 游戏界面呈现的相机宽度
  private float camHeight; // 游戏界面呈现的相机高度
  static public Main S; // Main 的单例
  Header("Set in Inspector")]
    public GameObject[] prefabEnemies; // Enemy 预设数组
    public float enemySpawnPerSecond = 0.5f; // 每秒钟产生的敌机数量
    public float enemySpawnPadding = 1.5f; // 填充敌机距离地图右边界的位置// Start is called before the first frame update
  void Start()
  {
    S = this;
    camHeight = Camera.main.orthographicSize; // 只有正交投影下有效
    camWidth = camHeight * Camera.main.aspect; 
    // 调用一次 SpawnEnemy0,默认值是每两秒生成一个
    Invoke("SpawnEnemy",1f/enemySpawnPerSecond);
  }
  
  public void SpawnEnemy() {
    // 随机选取一架敌机预设并实例化
    int ndx = Random.Range(0, prefabEnemies.Length);
    GameObject go =
      Instantiate<GameObject> (prefabEnemies[ndx]);
    // 使用随机生成的x坐标,将敌机置于屏幕上方
    Vector3 pos = Vector3.zero;
    float xMax = camWidth - enemySpawnPadding;
    float xMin = -camWidth + enemySpawnPadding;
    pos.x = Random.Range(xMin, xMax);
    pos.y = camHeight + enemySpawnPadding;
    go.transform.position = pos;
    // 再次调用 SpawnEnemy()
    Invoke("SpawnEnemy",1f/enemySpawnPerSecond);
  }
}

3.gif

  • 敌机与玩家控制的飞船没有碰撞效果.
  • 玩家控制的飞船会移出相机视野之外。
  • 敌机会不断添加进场景中,导致场景中的实体越来越多

设置标签、图层和物理规则

游戏中存在不同类型的游戏对象,它们需要放置在不同的图层中,并与其他游戏对象发生不同的交互。

  • 主角飞船 (Hero) : 会与敌机、敌机炮弹、升级道具碰撞,但不会与主角飞船的炮弹相碰撞。
  • 主角飞船的炮弹 (ProjectileHero) : 与敌机、敌机的炮弹相碰撞
  • 敌机 (Enemy) : 与主角飞船和主角飞船的炮弹相碰撞
  • 敌机的炮弹 (ProiectileEnemy) : 只与主角飞船相碰撞
  • 升级道具 (PowerUp) : 只与主角飞船相碰撞

标签和图层管理器

  1. 菜单栏中执行 Edit > Project Settings > Tags and Layers 命令
  2. 打开 Tags 左侧的三角形展开按钮。单击标签下方的+符号并输入标签名称
  3. 单击 Layers 旁边的三角形展开按钮从 User Layer 8 开始,依次输入图层名称

微信截图_20230215205414.png

微信截图_20230215205420.png

物理管理器

  • 菜单栏中执行 Edit > Project Settings > Physics 命令

微信截图_20230215205506.png

为游戏对象指定合适的图层

  1. 在层级面板中选中_Hero,然后在检视面板中从 Layer 下拉菜单中选择"Hero"选项。Unity 会询问是否将 Hero的子对象也指定到该图层上,选择“Yes, change children”选项。
  2. 在检视面板中,在 Tag 下拉菜单中选择 Hero 选项,为 _Hero设置标签.不需要修改_Hero子对象的标签
  3. 从项目面板中选择这5个敌机预设,设置图层为 Enemy。如果出现提示,同样选择"Yes,change children”选项。
  4. 设置每个敌机预设的标签为Enemy。不需要修改它们的子对象的标签

敌机碰撞主角飞船

  • 为主角飞船和敌机添加碰撞体。球体碰撞检测的效率较高。我们将主角飞船与敌机飞船及其子组件上已有的碰撞盒去掉,每个对象的母体上 Add Component > Sphere Collider

微信截图_20230215205820.png

微信截图_20230215205828.png

添加碰撞代码

在 Hero 类中添加 OnTriggerEnter 函数

void OnTriggerEnter(Collider other) {
    // 可以用下面这行代码在Console窗口输出碰撞对象的名称
    // print("触发碰撞事件:"+ other.gameObject.name);
    if(other.tag == "Enemy"){
        Destroy(this.gameObject);
    }
}

4.gif

玩法逻辑与 UI

实例化预置体 | UI实体 | 场景周期控制

主角飞船增加射击功能

  • 新建一个新的预制体,命名为 ProjectileHero。模型为一个立方体,PositionRotation均为[0,0,0],Scale为[0.25,1,0.5]。保留默认的 Box ColliderBox ColliderSize.z 设置为10.
  • 创建一个名为 Mat Projectile 的新材质,将着色器指定为 ProtoTools > UnlitAlpha,并将新材质应用到 ProjectileHero
  • ProjectileHero 游戏对象添加一个新的刚体组件,设置如下
    • Use Gravityfalse
    • isKinematicfalse
    • Collision DetectionContinuous
    • Constraints 冻结Z坐标与XYZ旋转轴
  • TagLayer 均设置为 ProjectileHero.

子弹设置刚体组件

按下空格后实例化新炮弹

public class Hero : MonoBehaviour
{
 // ...
 public GameObject projectilePrefab;
 public float projectileSpeed = 40;
 // ...
 void Update()
 {
    // ...
    // 按下空格后飞船开火
    if (Input.GetKeyDown(KeyCode.Space)){
    TempFire();
    }
 }
 void TempFire() {
    GameObject projGO = Instantiate<GameObject>(projectilePrefab);
    projGO.transform.position = transform.position;
    Rigidbody rigidB = projGO.GetComponent<Rigidbody>0;
    rigidB.velocity = Vector3.up * projectileSpeed;
 }
 // ...
}

微信截图_20230215210729.png 将刚刚制作的ProjetileHero预制体拖进这里

为子弹添加碰撞事件

public class ProjectileHero:MonoBehaviour
{
  private float camHeight; // 游戏界面呈现的相机高度
  // Start is called before the first frame update
  void Start()
  {
    this.camHeight = Camera.main.orthographicSize;
  }
  // Update is called once per frame
  void Update()
  {
    // 飞出屏幕的子弹自动销毁
    if(transform.position.y > this.camHeight){
      Destroy(this.gameObject);
    }
  }
  void OnCollisionEnter(Collision other) { 
    if(other.gameObject.tag == "Enemy")
      Destroy(other.gameObject);
      Destroy(this.gameObject);
    }
  }
}

5.gif

屏幕右上角显示计分板

  • 在菜单栏中执行 GameObject > UI > Text命令
    • Canvas: 匹配游戏面板尺寸的画布
    • EventSystem: 用于运转按钮、滚动条等交互元素
  • 选中Text对象,修改名称为Score
  • 设置Text对象的AnchorsPivotPosWidthHightTextFont StyleFont SizeColor等显示属性,如右图所示

微信截图_20230215211422.png

微信截图_20230215211428.png

每次消灭敌机为玩家增加50分

using TMPro; // using uNityEngine.UI;
public class Hero: MonoBehaviour
{
    static public Hero S; // 单例对象
    // ...
    public TMP_Text scoreGT; // public Text scoreGT;
    punlic int score;
    // ...
}
public class ProjectHero:MonoBehaviour
{
    private float camHeight; // 游戏界面呈现的相机高度
    // ...
    void OnCollishionEnter(Collision other) {
        if(other.gameObject.tagg == 'Enemy') {
             Destroy(other.gameObject);
             Destroy(this.gameObject);
             
             Hero.S.score += 50;
             Hero.S.scoreGT.text = 'Score:' + Hero.S.score;
        }
    }
}

微信截图_20230215211514.png

刚体子弹碰到敌机就会加分

重新开始游戏

  • 主角飞船被消灭2秒之后重新开始游戏
using UnityEngine.SceneManagement;
public class Main : MonoBehaviour
  {
    // ...
    public void DelayedRestart(float delay){
    Invoke("Restart", delay);
    }
    public void Restart() {
      // 重新加载场景,
      SceneManager.LoadScene("SampleScene");
    }
  }
  public class Hero : MonoBehaviorure{
  static public Hero;S; // 单例对象

  // ...
  void OnTriggerEnter(Collider other) fif(other.tag == Enemy")Destroy(this.gameObject);
  // 2秒后重启游戏
  Main.S.DelayedRestart(2f);
  }
  }
}

6.gif

主角被敌机碰撞,就让主角消失,等待2秒就自动重启游戏

知识点总结

  • 学习在Unity中创建实体,给实体设置位姿、材质、刚体、脚本
  • 学会配置相机、光、天空盒
  • 理解实时游戏update的时序机制。
  • 通过Input.GetAxis()与InputManager监听玩家输入。
  • Instantiate<GameObject>()动态生成游戏实体实例.
  • Camera.main获得相机参数,设置场景中的物体在画面中的位置
  • 利用标签、图层管理器区分实体种类并设置物理规则.
  • 通过 Collider 组件与 OnTrigger 函数添加碰撞事件。
  • 通过图形用户界面 (GUI) 管理游戏界面

推荐课程: GAMES 101 - 现代计算机图形学入门-月令琪 (中级) GAMES 104 - 现代游戏引警入门必修课-王希(高级) GAMES 202 -高质量实时染-门令琪(技美专用)