第一个Unity3D游戏demo

发布于 2024-03-02  3811 次阅读


前言

最近在自学Unity3D开发,学习了Unity中场景、坐标、物体、组件、物理系统、粒子系统等概念。现在想尝试动手做出来一个能玩的demo,玩过的众多游戏类型中最喜欢FPS游戏,所以参考网上的入门案例开发并完善一个FPS游戏Demo来熟悉Unity编辑器的操作和巩固入门阶段知识。

游戏开发过程

地图搭建

使用编辑器3D Object中的cube添加材质贴图使其拥有不同颜色和边框图案。

以这些cube为单位调整尺寸拼接形成地图。

人物相关

素材导入与基本处理

  • 导入外部人物素材资源作为玩家操控的角色
  • 将主摄像机绑定为人物实例的子物体并调整角度实现第一人称射击视野
  • 为人物添加碰撞器组件使其可以与有碰撞体积的物体产生碰撞
  • 为人物添加刚体组件调整参数使其受重力作用
  • 为人物添加AudioSource组件使其能够播放音效
  • 为人物添加Animator组件并创建动画状态机管理动画播放
  • 创建C#脚本挂载到人物身上实现人物的控制逻辑。

人物移动,视角转换和跳跃逻辑具体实现

当玩家按下WSAD键后可以进行前、后、左、右四个方向上的移动,当玩家的鼠标向水平或垂直方向移动时视角也应该相应的变动。为了接收玩家鼠标和键盘在方向上的输入,我们可以使用UnityEngine库提供的Input.GetAxis()方法,这个方法用于获取玩家在键盘或游戏手柄上输入的某个轴的值。
官方文档描述:


比如Input.GetAxis("Horizontal")就可以获取键盘水平方向的输入也就是代表左右方向的移动。

相关逻辑代码:


	//获取键盘水平轴向输入。
        float MoveX = Input.GetAxis("Horizontal");
        //获取键盘垂直轴向输入。
        float MoveY = Input.GetAxis("Vertical");
        //玩家坐标运算。将各轴输入乘以玩家的各轴方向以及Update方法调用的时间间隔再与人物世界坐标求和赋值回去。
        transform.position += transform.forward * MoveY * Time.deltaTime * moveSpeed
        + transform.right * MoveX * Time.deltaTime * moveSpeed;

动画状态机

每一个动画状态机里都可以添加多个动画,我们可以让这些动画建立关联并使用自定义的参数控制动画之间的切换逻辑。


动画状态机内可以定义多种类型的参数作为控制动画状态转换的条件,有Float,Int,Bool,Trigger类型,比如我们想从站立动画过渡到射击动画我们就可以使用Trigger类型参数作为条件,每当鼠标点击射击就触发一次动画过渡。


右键一个动画状态点击Make Transition创建一个过渡,选中这个过渡箭头给这个过渡的Conditions属性添加定义好的参数作为过渡条件。

在站立动作处创建一个动画混合树用来实现站立前后左右四个方向移动动画之间的混合过渡。玩家输入的值组成的坐标越倾向于哪个点的坐标那么动画就越向对应的动画过渡。

射击相关的粒子特效

创建一个空物体作为人物的子物体并调整相对坐标使其能永远位于玩家视野正前方中间位置,以此坐标作为子弹射出点,再定义一个人物的空子物体位于人物的枪口模型处以便于在此处产生枪口火焰特效。射击逻辑是使用Physics.Raycast()方法从枪口处向前发射一条射线,射线碰撞到碰撞体积代表击中,该方法返回一个RaycastHit类型的参数,这个参数中包含了碰撞位置的世界坐标,我们可以接收这个参数并使用Object.Instantiate()静态方法在击中位置创建外部导入的粒子特效预制体实例产生流血,地图破坏等特效。


相关逻辑代码:

//射击击中产生特效逻辑
//参数接收
RaycastHit hit;
//发射射线方法。重载方法接收三个参数,分别为射线的起点、射线的方向以及最大射程。
Physics.Raycast(gunPos.position, gunPos.forward, out hit, 15);
//创建物体实例方法。重载方法接收三个参数,分别为GameObject(创建的对象类型)、Vector3(对象的世界坐标)以及Quaternion(表示旋转的四元数)
Instantiate(bloodEffectGameObject, hit.point, Quaternion.identity);

对于这些被创建出来的特效物体,当特效播放完毕就应该被删除,所以我们创建C#脚本挂载到特效预制体上,在生命周期的Start()方法内调用Destroy()方法延时销毁物体本身,这个延时的时间应该大于特效播放所需时间。
相关逻辑代码:

public class EffectDestory : MonoBehaviour
{
    public float destoryTime;
    // Start is called before the first frame update
    void Start()
    {
        //延时删除
        Destroy(gameObject, destoryTime);
    }

    // Update is called once per frame
    void Update()
    {

    }
}

音效播放

让玩家控制器脚本获取到人物实例上的AudioSource和一些相关的AudioClip,在脚本中添加条件判断语句,在指定的条件达成时播放指定的音效。
相关逻辑代码:

    //音效相关属性获取
    public AudioSource audioSource;

    public AudioClip singleShootAudio;
    public AudioClip autoShootAudio;
    public AudioClip snipingShootAudio;
    public AudioClip reloadAudio;
    public AudioClip hitGroundAudio;
    public AudioClip jumpAudio;
    public AudioSource moveAudioSource;
    ...
    ...
    //当玩家移动时持续播放已经勾选loop的moveAudioSource,当玩家停止移动则停止播放
        if(MoveX != 0 || MoveY != 0)
        {
            if (!moveAudioSource.isPlaying)
            {
                moveAudioSource.Play();
            }
        }
        else
        {
            if (moveAudioSource.isPlaying)
            {
                moveAudioSource.Stop();
            }
        }
    ...
    ...
    //定义枚举代表不同武器,当使用武器射击时播放对应武器的AudioClip。
    public enum GUNTYPE
    {
        SINGLESHOT,
        AUTO,
        SNIPING
    }
    //开火音效只随射击播放一次
    audioSource.PlayOneShot(autoShootAudio);

子弹和换弹

武器的子弹数应该是有限的,玩家应当能主动换弹或在弹匣空了后被动换弹。分别声明一个Dictionary类型的弹匣属性和一个Dictionary类型的后备弹药属性。这两个Dictionary的键都为当前的武器类型GUNTYPE,值分别为弹匣内剩余子弹和背包子弹数。当玩家按下R键时若弹匣不满且有背包子弹剩余则进行换弹并播放相应音效动画。当玩家点击鼠标左键开火时若弹匣已空且有背包子弹剩余则执行换弹逻辑。这里要注意换弹期间不能执行换弹操作,声明一个bool值表示是/否处于换弹状态。
相关逻辑代码:

    //背包子弹数
    private Dictionary<GUNTYPE, int> bulletsBag = new Dictionary<GUNTYPE, int>();

    //弹匣里的子弹数
    private Dictionary<GUNTYPE, int> bulletsClip = new Dictionary<GUNTYPE, int>();

    public int maxSingleShotBullets;
    public int maxAutoShotBullets;
    public int maxSnipingShotBullets;
    /// <summary>
    /// 换弹
    /// </summary>
    private void Reload()
    {
        if (bulletsBag[gunType] <= 0) {
            animator.SetBool("AutoAttack", false);
            return;
        }
            switch (gunType)
            {
                case GUNTYPE.SINGLESHOT:
                if (bulletsClip[gunType] == maxSingleShotBullets) return;
                    if (bulletsBag[gunType] >= maxSingleShotBullets)
                    {
                        if (bulletsClip[gunType] > 0)
                        {
                            bulletsBag[gunType] = bulletsBag[gunType] - maxSingleShotBullets + bulletsClip[gunType];
                            bulletsClip[gunType] = maxSingleShotBullets;
                        }
                        else
                        {
                            bulletsBag[gunType] -= maxSingleShotBullets;
                            bulletsClip[gunType] += maxSingleShotBullets;
                        }
                    }
                    else
                    {
                        bulletsClip[gunType] += bulletsBag[gunType];
                        bulletsBag[gunType] = 0;
                    }
                    break;
                case GUNTYPE.AUTO:
                if (bulletsClip[gunType] == maxAutoShotBullets) return;
                if (bulletsBag[gunType] >= maxAutoShotBullets)
                    {
                        if (bulletsClip[gunType] > 0)
                        {
                            bulletsBag[gunType] = bulletsBag[gunType] - maxAutoShotBullets + bulletsClip[gunType];
                            bulletsClip[gunType] = maxAutoShotBullets;
                        }
                        else
                        {
                            bulletsBag[gunType] -= maxAutoShotBullets;
                            bulletsClip[gunType] += maxAutoShotBullets;
                        }
                    }
                    else
                    {
                        bulletsClip[gunType] += bulletsBag[gunType];
                        bulletsBag[gunType] = 0;
                    }
                    break;
                case GUNTYPE.SNIPING:
                if (bulletsClip[gunType] == maxSnipingShotBullets) return;
                if (bulletsBag[gunType] >= maxSnipingShotBullets)
                    {
                        if (bulletsClip[gunType] > 0)
                        {
                            bulletsBag[gunType] = bulletsBag[gunType] - maxSnipingShotBullets + bulletsClip[gunType];
                            bulletsClip[gunType] = maxSnipingShotBullets;
                        }
                        else
                        {
                            bulletsBag[gunType] -= maxSnipingShotBullets;
                            bulletsClip[gunType] += maxSnipingShotBullets;
                        }
                    }
                    else
                    {
                        bulletsClip[gunType] += bulletsBag[gunType];
                        bulletsBag[gunType] = 0;
                    }
                    break;
                default: break;
            }
        isReloading = true;
        audioSource.PlayOneShot(reloadAudio);
        playerAmmoText.text = bulletsClip[gunType].ToString() + "/" + bulletsBag[gunType].ToString();
        Invoke("RecoverAttackState", 2.667f);
        animator.SetTrigger("Reload");

    }

    private void RecoverAttackState()
    {
        isReloading = false;
    }

敌人相关

素材导入与基本处理

  • 导入外部怪物素材资源作为玩家攻击的敌人
  • 为怪物添加碰撞器组件使其可以与有碰撞体积的物体产生碰撞
  • 为怪物添加AudioSource组件使其能够播放音效
  • 为怪物添加Animator组件并创建动画状态机管理动画播放
  • 创建C#脚本挂载到敌人预制体身上实现敌人相关逻辑。

自动追踪、攻击和受伤

敌人需要能对玩家进行攻击使玩家扣除生命值以及受伤扣除生命值。首先在脚本中定义敌人的HP、攻击CD、脚本还要获取到玩家脚本实例对象以便获取玩家脚本中相关属性如世界坐标。主要逻辑是当敌人与玩家的距离小于一定值时敌人才会进行攻击,同时要执行动画状态的转换以及音效的播放。关于敌人的自动追踪目前跟随教程在编辑器的包管理器引入了一个AI Navigation插件,目前了解到这个插件可以烘焙地图生成可活动区域并且可以让物体在可活动区域向指定坐标方向自动移动。

敌人动画状态机:


引入AI Navigation插件:


我们首先给地图父物体Map添加NavMeshSurface组件,点击组件Bake按钮进行地图烘焙:


可以看到地图网格出现了蓝色区域,这些区域即为AI导航可通过的区域。

为敌人预制体添加Nav Mesh Agent组件,使其具备AI导航功能。

相关逻辑代码:

        //在Update方法中执行了如下逻辑
        //当敌人与玩家距离大于7则不予追踪
        if (Vector3.Distance(transform.position, pc.transform.position) > 7) return;
        if (Vector3.Distance(transform.position, pc.transform.position) > 1.5)
        {
            if(!audioSource.isPlaying) { audioSource.Play(); }
            agent.isStopped = false;
            animator.SetFloat("MoveState", 1);
            //该方法用于更新导航目标,我们实时获取玩家坐标提供给敌人追踪
            agent.SetDestination(pc.transform.position);
        }
        else
        {
            //小于一定距离停止追踪
            agent.isStopped = true;
            //判断是否处于攻击CD,未处于CD则执行攻击逻辑
            if (Time.time - attackTimer > attackCD)
            {
                if(audioSource.isPlaying) { audioSource.Stop(); }
                //攻击音效
                Invoke("DelayPlayAttackSound", 1.05f);
                //移动动画状态条件
                animator.SetFloat("MoveState", 0);
                //攻击动画状态触发器
                animator.SetTrigger("Attack");
                attackTimer = Time.time;
            }
        }
    //延时播放攻击音效使攻击动画与音效对齐
    private void DelayPlayAttackSound()
    {
        //调用玩家脚本的受伤方法
        pc.TakeDamage(10);
        //音效播放
        audioSource.PlayOneShot(attackAudio);
    }
    //受伤逻辑
    public void TakeDamage(int attackValue)
    {
        //若敌人已经死亡则不不执行以下逻辑
        if (isDead) return;
        //受伤动画状态触发器
        animator.SetTrigger("Hit");
        //生命值扣除
        HP -= attackValue;
        //生命值为0,执行死亡逻辑
        if(HP <= 0)
        {
            animator.SetBool("Die", true);
            isDead = true;
            audioSource.PlayOneShot(dieAudio);
            Invoke("DelayPlayDieSound", 2.5f);
        }
    }

UI相关

为了显示游戏中玩家的武器、子弹数、生命值等信息,我们需要使用Unity的UI系统。选择使用Unity的UGUI系统来做我们游戏的UI绘制。

下面是UGUI及其组件的介绍:

UGUI(Unity GUI)是Unity游戏引擎中用于创建用户界面(UI)的一套系统。它提供了许多组件,用于创建各种UI元素,如按钮、文本、图片等。以下是一些常用的UGUI组件:
Text(文本):用于在UI界面上显示文本内容。可以通过设置其样式属性(如位置、大小、字体、字号、颜色等)来定制文本的外观。此外,还可以通过脚本控制Text组件显示的文本内容,实现动态更新文本信息,如显示计时器、得分等实时变化的信息。
Image(图片):用于在UI界面上显示图片,常用于显示角色头像、道具图标、背景图片等。Image组件具有多种属性,如Source Image(指定要显示的目标图片资源)、Color(设置图片的颜色属性)、Material(设置用于渲染图片的材质球)等。
Button(按钮):用于创建可交互的按钮。通过为Button组件添加事件监听器,可以响应用户的点击操作,并执行相应的函数或方法。
InputField(输入框):用于接收用户的输入。用户可以在输入框中输入文本,InputField组件会捕获这些输入,并允许开发者在脚本中访问和处理这些输入数据。
Slider(滑动条):用于显示和控制数值的滑动条。用户可以通过拖动滑动条来改变其值,Slider组件会提供这个值供开发者在脚本中使用。
Toggle(复选框):用于创建复选框。用户可以通过点击复选框来切换其选中状态,Toggle组件会提供这个状态供开发者在脚本中使用。
Scrollbar(滚动条):用于控制可滚动内容的滚动条。当UI元素的内容过多,无法完全显示在屏幕上时,可以使用滚动条来查看剩余的内容。
ScrollRect(滚动视图):用于创建可滚动的视图区域。ScrollRect组件可以包含多个子元素,并允许用户通过滚动来查看这些子元素。
Dropdown(下拉菜单):用于创建下拉菜单。下拉菜单允许用户从一组选项中选择一个值,Dropdown组件会提供这个选定的值供开发者在脚本中使用。
Canvas(画布):用于创建UI元素的容器。在Unity编辑器中创建一个Canvas对象后,可以为其添加各种UGUI组件来构建UI界面。
Canvas组件的工作原理
Canvas组件通过渲染器将UI元素绘制到屏幕上。它使用层级结构来管理UI元素的显示顺序,可以通过设置UI元素的层级来控制它们的显示顺序。Canvas组件还可以设置渲染模式,包括屏幕空间、世界空间和摄像机空间等。
Canvas组件的常用属性
Render Mode(渲染模式):设置Canvas的渲染模式,包括屏幕空间、世界空间和摄像机空间等。
Sorting Layer(排序层级):设置Canvas的排序层级,用于控制UI元素的显示顺序。
Order in Layer(层级顺序):设置UI元素在排序层级中的显示顺序。
Pixel Perfect(像素完美):启用像素完美模式,可以确保UI元素在不同分辨率下的显示效果一致。
Reference Pixels Per Unit(参考像素单位):设置参考像素单位,用于计算UI元素的大小和位置。
Canvas组件的常用函数
SetRenderMode(RenderMode mode):设置Canvas的渲染模式。
SetSortingLayerName(string name):设置Canvas的排序层级名称。
SetOrderInLayer(int order):设置UI元素在排序层级中的显示顺序。
SetPixelPerfect(bool pixelPerfect):设置是否启用像素完美模式。
SetReferencePixelsPerUnit(float pixelsPerUnit):设置参考像素单位。

画布创建

在Hierarchy中右键选择UI->Canvas创建一个画布。

配置Canvas的Canvas Scaler组件的UI Scale Mode为Scale With Screen Size使画布尺寸随屏幕尺寸增大而增大;配置Screen Match Mode为Match Width Or Height并配置Match为0使画布适配宽度。将宽度设置为屏幕宽度,并保持默认尺寸比例不变;配置Render Mode为creen Space - OverLay让UI优先显示。

Canvas的Render Mode还包括以下模式:
Screen Space - Overlay(屏幕空间 - 覆盖):最简单的Canvas渲染模式,适用于大多数2D游戏和简单的用户界面。在此模式下,UI元素会在场景中居于一个独立的图层中,不与场景中的其他3D物体发生交互。当相机移动时,这一层UI元素会一直停留在前方。
Screen Space - Camera(屏幕空间 - 相机):在此模式下,UI元素仍然是渲染在屏幕上的,但是相对于一个指定的摄像机。
World Space(世界空间):在此模式下,UI元素被视为场景中的普通对象,它们的位置是相对于世界空间的。UI元素可以随着场景中的物体移动、旋转和缩放,并与场景中的其他对象进行交互。这种模式适用于需要将UI元素作为游戏中的物体进行交互的情况,例如在游戏世界中显示交互式地图或可拾取的物品。

点击scene上方工具栏的2D切换至2D视图并选中canvas物体,这样我们就可以直观编辑我们的画布了。

Canvas画布中可以添加很多种组件,比如我们想显示一段文本像我们的子弹、生命值这些我们就可以选择text组件添加至画布,或者我们想添加图片表示当前武器类型,HP图标等我们就可以使用Image组件显示一张图片,这些组件物体同样可以具有父子关系,比如上图我添加的表示HP、弹药的图标和文本就是父子关系,方便固定他们的相对位置。

在层级面板中右键可以选择创建UI组件。

在层级面板中组件靠下的将会覆盖靠上的组件显示,所以将流血特效Blood放在HP等组件之上,这样受伤时流血特效就不会覆盖掉子弹HP等组件。

在游戏过程中对于子弹数和HP值我们需要实时更新显示。在脚本中获取两个Text类型的对象分别是Text_HP和Text_Ammo。在射击和换弹逻辑中添加修改Text逻辑使UI文本随数据变化实时更新。

    public Text playerHPText;
    public Text playerAmmoText;
    //弹药文本更新逻辑。将弹匣剩余弹药数和后备弹药数拼接成字符串显示在UI界面。
    playerAmmoText.text = bulletsClip[gunType].ToString() + "/" + bulletsBag[gunType].ToString();
    //HP值文本更新逻辑。直接将HP转成字符串赋值即可。
    playerHPText.text = HP.ToString();

游戏结束与重新游戏

游戏结束的逻辑是当角色生命值降为0则触发死亡画面结束游戏并提供按钮点击重新游戏。我们在Canvas下添加一个子组件Panel_Over用来存放游戏结束相关UI,该组件中包括一个重新游戏的按钮和一行文本提示游戏结束,Panel_Over默认是隐藏的,当角色生命值为零时才触发。

UGUI中Panel的介绍:

Unity的UGUI系统中的Panel,是一个容器组件,也被称为面板组件。你可以将其理解为是一个更小的Canvas,类似于UI控件最上层的父节点。在Unity中开发游戏或应用时,UGUI Panel用于管理当前UI模块的最上层的父节点空物体。例如,商店模块的最上层可能是ShopPanel,而ShopPanel又是Canvas的子物体。Canvas负责管理所有的UI子模块,商店模块就是其中之一。
在Unity的UGUI系统中,Panel是一个常用的组件,用于创建和管理UI界面。当你创建一个Panel时,Canvas编辑框会自动生成。因此,在UI工程搭建过程中,通常会先创建Panel面板,然后在其上进行后续的UI搭建工作。

在脚本中添加逻辑,当角色生命值为零时显示Panel_Over组件,添加按钮的OnClick事件触发时调用的方法,方法中通过调用SceneManager.LoadScene()方法重新加载当前场景从而达到重新开始游戏的效果。

相关逻辑代码:

    //获取Panel_Over实例对象
    public GameObject gameOverPanel;
    /// <summary>
    /// 受伤
    /// </summary>
    /// <param name="value"></param>
    public void TakeDamage(int value)
    {
        bloodUIGo.SetActive(true);
        //当HP不足以承受伤害时HP归零,将Panel_Over置为显示并解除鼠标光标锁定隐藏。
        if (HP <= value)
        {
            HP = 0;
            gameOverPanel.SetActive(true);
            Cursor.lockState = CursorLockMode.None;
            Cursor.visible = true;
        }
        else HP -= value;
        playerHPText.text = HP.ToString();
        Invoke("HideBlood", 1f);
    }
    /// <summary>
    /// 重新开始游戏
    /// </summary>
    public void Replay()
    {
        SceneManager.LoadScene(0);
    }

将脚本挂载到Button_RePlay按钮的On Click ()上并选择脚本的Replay方法作为事件函数。选择Runtime Only让函数只在运行时生效。

怪物自动生成

案例中并没有提供怪物的生成逻辑,地图中放置了几个怪物就只有几个,打完就没得玩了,所以根据自己的想法实现了怪物自动生成逻辑。

创建一个空物体专门用于怪物生成,创建脚本EnemyCreator挂载到物体上实现怪物生成逻辑。

我希望地图中的怪物数是不少于某个固定值的,每当有怪物死亡就Destroy自己并动态生成新怪物直到达到指定上限防止内存溢出。那么EnemyCreator就应该在Update方法中不断检测此时场景里怪物数判断是否小于最大值,若小于则补充。怪物的死亡伴随着大约四秒的倒地动画,我想实现的效果是怪物播放完倒地动画后几秒钟执行Destroy销毁自己并在几秒后在地图指定位置生成新怪物,所以这里的Destroy()方法和怪物生成方法都要做延时调用。延时调用我们可以用协程或者Invoke()方法,这里我使用Unity的协程实现延时调用,在Enemy脚本中实现销毁协程函数并在方法中获取EnemyCreator调用它的Create()方法生成新怪物,EnemyCreator中的生成方法也是开启协程延时调用的。

相关逻辑代码:


     // 怪物死亡后延迟销毁的时间
    public float deathDelay = 4.0f;  
    // 标记销毁协程是否正在运行
    private bool isDestroyCoroutineRunning = false;
    // 当怪物受到致命伤害时调用此方法  
    public void TakeFatalDamage()
    {

        // 如果销毁协程没有运行,则开始它  
        if (!isDestroyCoroutineRunning)
        {
            StartCoroutine(DelayedDestroy());
        } 
    }

    // 协程函数,用于延迟销毁怪物  
    IEnumerator DelayedDestroy()
    {
        // 设置销毁协程正在运行的标记
        isDestroyCoroutineRunning = true;
        // 等待指定的时间  
        yield return new WaitForSeconds(deathDelay);
        // 销毁后重置标记   
        isDestroyCoroutineRunning = false;
        GameObject enemyCreator = GameObject.Find("EnemyCreator");
        EnemyCreator creator = enemyCreator.GetComponent<EnemyCreator>();
        creator.Create();
        // 销毁怪物
        Destroy(gameObject);   
    }
public class EnemyCreator : MonoBehaviour
{
    // 获取一个怪物实例用于创建新怪物实例
    public GameObject enemy;
    // 用于统计死亡怪物数量
    public int count = 0;
    // 怪物死亡后延迟销毁的时间
    public float createDelay = 5.0f; 
    // 标记生成协程是否正在运行
    private bool isCreateCoroutineRunning = false; 
    // Start is called before the first frame update
    void Start()
    {

    }

    // Update is called once per frame
    void Update()
    {
        if(count > 0 && !isCreateCoroutineRunning)
        {
            TakeCreate();
            count--;
        }
    }

    // 当怪物受到致命伤害时调用此方法  
    public void TakeCreate()
    {
        // 如果生成协程没有运行,则开始它  
        if (!isCreateCoroutineRunning)
        {
            StartCoroutine(DelayedDestroy());
        } 
    }

    // 协程函数,用于延迟生成怪物  
    IEnumerator DelayedDestroy()
    {
        // 设置生成协程正在运行
        isCreateCoroutineRunning = true;
        // 等待指定的时间
        yield return new WaitForSeconds(createDelay); 
        // 生成完毕后重置标记   
        isCreateCoroutineRunning = false;
        CreateEnemy();
    }

    public void Create()
    {
        count++;
    }
    private void CreateEnemy()
    {
        // 在指定位置创建怪物实例
        GameObject newEnemy = Instantiate(enemy, new Vector3(25, 5, 15),Quaternion.identity);
    }
}

我在EnemyCreator的Update()方法中做了判断,如果当前怪物死亡数大于0且协程未在执行就走生成逻辑。这里是考虑了线程安全问题(web写太多导致的),实际上Unity本身是单线程的,协程实际上也是顺序执行。

游戏构建发布与上传服务器

现在这个小demo的开发告一段落了,我已经迫不及待地准备打包开玩了!我的预期是构建为WebGL项目并将游戏上传至我的云服务器,这样就能实现打开浏览器访问即玩了!

游戏构建发布

首先在Unity Hub安装对应平台的Build Support,安装完成后回到Unity编辑器在上方选择File->Build Settings进入构建设置,Platform栏选择WebGL发布为Javascript程序。

点击左下角的Player Settings对构建参数进行设置,这里配置项很多可以查阅资料根据实际情况进行设置。

设置完成后点击Build发布,如果想发布后直接运行就点Build And Run。

发布完成后我们就得到了下图的文件:

目前我们不能直接浏览器打开html开玩,会报错:

报错信息说该浏览器不支持在没有web服务器的情况下通过路径加载网页。那我们就开一个web服务好了。先在本地做个测试,在游戏文件目录打开终端使用Python内置的http.server快速启动一个web服务运行游戏。

现在让我们通过浏览器访问localhost:8000运行游戏:

游戏已经成功运行!

游戏上传至云服务器

现在将打包好的游戏上传至云服务器。我的腾讯云服务器已经安装了nginx服务,现在使用Xftp将游戏项目文件上传至云服务器:

文件上传成功后我们要修改nginx配置,找到服务器的nginx.conf并打开:

编辑conf文件包含外部配置文件路径:

include /etc/nginx/sites-enabled/*;

在sites-enabled目录下创建配置文件:

配置文件中添加webgl服务配置:

server {  
    listen 8868;  # 服务端口
    server_name "服务器ip或域名";  
 
    root /usr/share/nginx/html/FPSDemo;  # WebGL文件路径  
    index index.html;  # 指定应用入口  
 
    location / {  
        try_files $uri $uri/ =404;  
    }   
 
    # 错误处理  
    error_page 500 502 503 504 /50x.html;  
    location = /50x.html {  
        root /var/www/html;  
    }  
}

保存文件后重启nginx服务,现在通过我的服务器ip:端口访问服务:

运行成功,现在可以随时随地开玩了!

补充

经过测试发现游戏中会出现UI中文字体丢失情况,解决办法是从windows系统中找一个中文字体拖进项目,然后在UI的Text组件的Font属性中选中你导入的字体。

不知道叫啥,写就完了。
最后更新于 2024-04-15