3D游戏编程课程总结+untiy结课塔防游戏

目录

塔防游戏简介

塔防游戏整体结构

塔防游戏机制

Skill机制(技能机制)

塔防游戏模型

继承实现游戏模型

组合实现游戏模型

组合实现设计结构

塔防游戏逻辑

塔防消息系统组件实现

观察者模式

对象死亡解引用

塔防游戏对象工厂实现

工厂模式

查询优化

lazy delete

课程回顾与总结

课程总结

本学期的unity3D游戏设计结束了,在这一个学期里,我学习到了很多东西,学会了使用unity这一个主流的游戏设计软件来进行设计和编写游戏。主要学习了各种不同的模型来设计游戏、还学会了粒子效果、UI设计等技术知识,这一段时间的unity3D游戏设计的学习给我带来了极大的兴趣,相信不久的以后还会有使用到unity3D来设计游戏的时候。

特别感谢老师一整个学期的教学,一步一步地指导我们对unity3D进行设计和学习,同时还教会我们自己创建了不同的属于自己地游戏设计,在这个过程中,我们体会到了游戏设计的快乐,以及编程游戏的爱好。虽然这一个过程是艰苦的,但是好在真正地有体验到了游戏设计的编程之美,以及游戏设计的不简单。

我认为我应该课程优秀原因

1.对于UML与设计模式有较深的体会与理解,本次最终的课程游戏,我将前面所学的UML知识充分应用,绘制了大量UML图,使用了工厂模式进行开发,游戏整体设计较好;

2.游戏策划能力较强:虽然课程对于游戏的设计策划讲解较少,但我明白要想开发好一个游戏,游戏的策划是或不可缺的,所以结课游戏我花费了很多精力在游戏模型的设计上面,展现了较强的游戏策划能力;

3.游戏编程能力较强:作为软件工程学院的学生,游戏编程能力是很重要的,游戏开发的编程量较大,但我依然充分学习的游戏编程的技巧,最终的结课游戏代码量也是1k+;

总而言之,我觉得我本课程应该获得优秀,因为我不仅按时上课,而且对老师布置的每一个作业都认真完成和积极改进,课程完成度较高!

塔防游戏简介


游戏类型:塔防+RPG的3D游戏

游戏要素:3D 塔防 英雄 建筑树 搭配

主体玩法:游戏里将会有一波波怪物进攻基地。玩家可以建造塔来防御敌人,同时也可以控制单独的个体英雄角色来攻击敌人。

游戏模式

  • 第三人称视角的RPG模式

  • 上帝视角的建造模式

胜利条件:消灭所有敌人 或者 坚持到时间结束

失败条件:基地生命值为0 或者 英雄死亡

游戏预览

塔防游戏整体结构


一般来说,整个Unity游戏项目整体结构,我比较偏向分为如下5部分:

  • 场景对象 :不会产生互动的可视物体对象,例如地型/建筑/灯光。

  • 游戏对象 :参与互动的游戏对象,例如英雄/怪物/塔。

  • 游戏逻辑 :负责控制游戏的逻辑,其逻辑对象一般是单例的。

  • 非游戏性对象 :负责增强游戏效果,但不是直接的游戏逻辑,例如UI/HUD/特效/声音。

  • 工具 :负责辅助编码,例如日志工具,调试工具。

塔防游戏机制

首先,使用了一个数据类型BuffData,用于完全映射Buff在表格的所有属性:

public class BuffData
{
    public int ID;
    public string Name;
    public int HpChange;              //血量变化
    public double HpChange_p;         //血量百分比变化
    public int AttackChange;          //攻击力变化
    public double AttackChange_p;     //攻击力百分比变化
    public double AttSpeedChange_p;   //攻击速度百分比变化
    public double SpeedChange_p;      //速度百分比变化
    public int HpReturnChange;        //血量恢复数值
    public double HpReturnChange_p;   //血量百分比恢复数值
    public int AddReviveCount;        //增加复活次数
    public bool isDecelerate;         //减速
    public bool isVertigo;            //眩晕
    public bool isParalysis;          //麻痹
    //...等属性
}

然后我们就可以用一个 List<BuffData>来存储表示所有Buff种类。
为了读取Excel表,并根据读入的所有Buff种类属性来初始化 List<BuffData>,于是就引入了一个BuffDataBase的全局单例类来负责此事:

//全局单例类
public class BuffDataBase : MonoBehaviour
{
    //读取excel插件生成的json文件
    public TextAsset buffDataJson;
    //存储BuffData的列表
    private List<BuffData> buffDatas;
    
    //全局单例实现
    //...
    
    //根据ID获取相应的BuffData对象
    public BuffData GetBuffData(int ID){
      //...
    }
}

为了表示游戏对象得到/失去一个Buff而从BuffDataBase找到并拷贝一份BuffData对象/释放掉一份BuffData对象显然是不明智的。(BuffData所占空间大,开销大)
正确的做法应该是使用索引/引用的方式,例如某个游戏对象持有3号索引,则表示它当前受一个ID为3的Buff影响。

为了引入Buff的时间有效性,则进一步封装索引,于是编写了下面一个Buff类:

public class Buff
{
    public int ID;              //BuffData的ID(索引)
    
    public double time;         //持续时间
    public int repeatCount;     //重复次数
    public bool isTrigger;      //是否触发类型
}

因为每个Buff的时间有效性都有所不同:有些Buff是一次性触发Buff;也有一些是持续性Buff,持续N秒;还有一些是被动buff,永久生效。

所以我这里就总结了个规则,Buff主要分为两种类型:

  • 持续型(Non-Trigger):开始对属性造成生效影响一次,有效时间结束时造成失效影响一次。例如一段时间内增加攻速Buff
  • 触发性型(Trigger):有效时间内,每一帧对属性造成生效影响一次。例如一次性伤害Buff,光环Buff。

然后Buff的有效时间取决于2个属性:

  • 持续时间(time):每帧持续时间减少DeltaTime
  • 触发次数(repeatCount):每帧触发次数减一

当一个Buff对象,持续时间 <= 0 并且 触发次数为0,则应视为失效。特殊地,触发次数为-1时,表示无限时间。

这样Buff/BuffData/BuffDataBase基本构造就出现了:
整个游戏同种类Buff只用存储一份BuffData;但是可以有很多个对象持有索引/引用,指向这个BuffData。
游戏对象持有Buff对象,通过BuffDataBase访问BuffData的数据,然后利用这些数据对游戏对象属性造成影响。

看到这里,可能会有人想到前面有个问题:对于任意一种Buff,它往往有很多属性是false或者0,使用这种完全映射会不会很影响空间占用或者效率。

  1. 首先,空间占用绝对不用担心,因为前面BuffDataBase机制保证同种Buff只有唯一BuffData副本,其所有BuffData总共占用量不过几kb而已。
  2. 其次,至于效率,例如说某个Buff对某个游戏对象造成影响,因为是完全映射,所以需要对该游戏对象每个属性都要进行更新,其实这也并不是太糟糕。
    而且只要游戏对象有比较好的Buff计算方式,可以让一个Buff对象的整个有效周期只对对象造成两次影响计算(生效影响,失效影响),避免每帧出现影响多余的计算,这样就很不错了。

Skill机制(技能机制)

可以说技能是我比较头疼的部分。
看到那千奇百怪的Skill需求时,然后才总结出大概这几个分类:

  • 主动Buff技能 = 主动释放,生成一个Buff
  • 被动Buff技能 = 初始化时,生成一个Buff
  • 召唤技能 = 生成一个游戏对象
  • 指向性技能 = 主动释放,对锁定的目标生成一个Buff

最后我决定使用继承接口的方式来实现Skill:

需要注意的一点是,技能并不是主动释放时调用一个自定义的技能函数即可完事:
例如持续性的范围技能,需要每帧调用散发Buff的函数。
所以一个ISkill对象 该有这3种重要的接口方法:初始化/主动释放/每帧更新

下面是其中一个派生类的具体实现:

由于一开始设计考虑不足,Buff技能类暂时包含了ActiveBuff技能类和PassiveBuff技能类的功能。

// 示例:Buff技能类
public class BuffSkill : ISkill
{
    public int buffID;               //目的Buff
    public bool isAura = true;       //光环
    public bool releasable = true;   //是否主动释放
    public float range = 0.01f;      //范围

    private float coldTime = 5.0f;  //冷却时间
    private float timer = 5.0f;     //冷却计时

    //构造方法
    public BuffSkill(int buffID,bool releasable = true,bool isAura = true, float range = 0.01f)
    {
        this.buffID = buffID;
        this.isAura = isAura;
        this.range = range;
        this.releasable = releasable;
    }
    
    //初始化技能
    public void InitSkill(Individual master)
    {
        //非光环的被动buff
        if (!releasable && !isAura)
        {
            var individual = master.GetComponent<Individual>();
            master.GetComponent<MessageSystem>().SendMessage(2, individual.ID,buffID);
        }
    }
    
    //释放技能
    public void ReleaseSkill(Individual master)
    {
        //主动buff
        if (releasable && IsColdTimeEnd())
        {
            timer = 0.0f;

            Factory.TraversalIndividualsInCircle(
                (individual) => { master.GetComponent<MessageSystem>().SendMessage(2, individual.ID, buffID); }
                , master.transform.position, range);
        }
    }
    
    //技能每帧更新
    public void UpdateSkill(Individual master)
    {
        //增加计时
        timer =Mathf.Min(timer+Time.deltaTime, coldTime);
        
        //光环被动buff:每帧向周围range范围内的对象散发buff
        if (!releasable && isAura)
        {
            Factory.TraversalIndividualsInCircle(
                (individual) => { master.GetComponent<MessageSystem>().SendMessage(2, individual.ID, buffID); }
                , master.transform.position, range);
        }
    }
    
    //得到冷却时间百分比
    public float GetColdTimePercent()
    {
        if (!releasable) return 1.0f;

        return timer / coldTime;
    }

    //冷却时间是否结束
    public bool IsColdTimeEnd()
    {
        return timer > coldTime;
    }
}

派生类的构造函数很重要,这样即使硬编码了4个技能派生类,通过不同的数据参数传入,也能产生更多不同的技能对象。

最后还应该再写一个SkillDataBase全局单例类,它负责读取策划写的技能配置文件,来初始化出来一些ISkill对象,以供游戏对象使用。


public class SkillDataBase : MonoBehaviour
{
    //技能json文件
    public TextAsset skillDataJson;
    
    //全局单例实现
    //...
    
    //读取文件,初始化ISkill对象给英雄使用
    public void InitSkillsData(){
      //...TODO:读取文件来初始化英雄的技能
      
      //目前硬编码给英雄赋予3个技能
      HeroSkills.Add(new BuffSkill(6, true, true, 5.0f));   //主动技能:嘲讽Buff
      HeroSkills.Add(new BuffSkill(0, false, false));       //被动技能:回血buff
      HeroSkills.Add(new BuffSkill(14, true, false));       //主动技能:攻速戒指buff
    }
}

以后的话,SkillDataBase的初始化函数应该是读取某种配置文件,然后生成若干个对应的技能对象分配给游戏对象使用:

塔防游戏模型

继承实现游戏模型

最初想到的是使用继承的方式来实现这些游戏模型(如图):

然而考虑到现在的英雄/怪物/陷阱/塔类型已经足够太多了,而且以后还可能会扩展更多。
若用继承的方式,其派生类数量将到达一个小团队难以维护的地步。

至于之前设计Skill机制的时候,为什么反而采用继承的方式,原因如下:

  1. 策划案里,Skill的种类只有8种,所以需要编写的派生类比较少,而英雄/怪物/陷阱/塔所有种类总共加起来有二十多种。
  2. Skill不是GameObject,没有Unity提供的GameObject-Component机制,不太方便接纳组件(除非自己再实现一套组件模式)。
  3. 实际上,还有个设计Skill的思路就是把Skill设计成一个行为树,通过组合节点来生成一个Skill。然而因为当时急于实现,于是抛弃了这个想法。

首先为了统一术语,避免游戏模型和Unity的GameObject弄混淆,我们定义了一个称之为 个体(Individual) 的名词,来表示一个游戏模型单位。

组合实现游戏模型

再想到Unity的GameObject-Component机制,于是最后我采用组合组件的方式来设计这几个游戏模型。

那么如何表示一个个体游戏对象呢?
首先我们需要编写一些个体游戏对象必要的组件脚本类。

对于一个个体游戏对象,它可能由如下图构成:

一般来说行为和输入都应该放在一起统称为控制器,然而实际上在游戏里,输入来源可能是玩家,也可能是AI,因此把个体对象行为和输入分离是个好的选择。

也就是说它得有属性,行为,操控行为的输入,还得可以容纳Buff机制,Skill机制和装备机制。

根据这些需求分化出来不少组件类:

然后为了解耦各组件的依赖关系,特别是跨游戏对象的组件依赖,于是还额外引入了一个 消息系统组件 ,实际上就是用于实现观察者模式。
每个个体对象都必须带一个消息系统组件,且其他编写的组件类基本上都依赖这个消息系统组件。

例如,A个体用指向性技能对B个体进行释放实际上的行为是:
由A个体的 技能系统组件 发送消息给A个体的 消息系统组件 ,
然后A个体的 消息系统组件 把消息再转发给B个体的 消息系统组件
B个体的 消息系统组件 再把消息通知给 Buff系统组件 ,从而让B个体受到该Buff影响。

组合实现设计结构

最终个体游戏对象的组件依赖关系图:

塔防游戏逻辑


先说明一下,全局游戏逻辑的全局并不是指变量的全局暴露,而是说负责游戏世界的整体逻辑。
全局游戏逻辑设计的话相对轻松一点:

  1. 首先为了更好管理个体游戏对象,引入了 对象工厂 来控制个体有对象的生命周期。
  2. 金钱管理器 负责玩家的金钱数据管理,例如击杀奖励,关卡结算奖励。
  3. 塔管理器 负责用规则限制塔的逻辑,例如建造一个塔的位置限制,建造塔的金钱消耗。
  4. 关卡管理器 负责生成每波怪物。

为了辅助这些逻辑,还额外引入了消息系统组件路径管理器怪物生成器三个脚本。

构造如下:

《ATD》游戏对象目录设置:

游戏逻辑比较多脚本都需要读入配置文件数据的功能,方便动态更新游戏。

此外,脚本应在Inspector面板应提供一些可调的逻辑参数,方便调试全局逻辑(例如金钱数调99999999)。

塔防消息系统组件实现


观察者模式

观察者模式 是一个常见的设计模式,其定义对象间一种一对多的依赖关系,使得每当一个对象改变状态,则所有依赖它的对象都会得到通知并自动更新。有关该设计模式的更具体内容,本文就不多讲述。

在塔防游戏里, 消息系统组件 接受任何 消息中心 转发的消息。
各个依赖 消息系统组件 的模块需要在 消息系统组件 订阅自己关心的消息类型(注册委托)。
任何地方都可以向 消息中心发出消息,消息中心 接着再转发该消息给各个 消息系统组件 ,接着 消息系统组件 根据消息类型,执行关心该类型的已注册委托对象。

实际上这个设计模式很重要,常常用于UI与逻辑的交互。
而在塔防游戏里,被改造成一个新的消息机制,用于模型之间的交互和模型与全局逻辑之间的交互。

塔防游戏的核心思想是:一切基于消息驱动。攻击是给敌人对象发送一个攻击类型的消息,上Buff是给自己这个个体对象发送一个Buff类型的消息...几乎每一个行为都是通过消息来驱动的。这在以后做高度定制的成就系统更是有潜在的帮助,毕竟成就系统也订阅其中几个感兴趣的类型消息即可获取想要的数据,而不会造成更多的耦合。

对象死亡解引用

在实际的实现中,发现个体对象死亡而引发的引用丢失问题非常多。
一个解决方法是:依赖个体对象引用的代码都需要使用一个 消息系统组件 从而对死亡类型的消息进行监听,当听到自己依赖的对象死亡时,则立即解除引用。这方法工作的很好。

塔防游戏对象工厂实现


工厂模式

工厂模式 是一个常见的设计模式:工厂往往是一个全局单例,用来管理对象的生命周期。

不过在塔防游戏项目里, 对象工厂 的职责是:管理所有个体对象。

但需要生成个体对象时,必须使用 对象工厂 提供的生成对象接口。

至于销毁个体对象,一个要注意的问题是,游戏对象销毁和个体死亡是两种不同的概念:
一个个体对象受到伤害,血量低于0时,即可被判定为个体死亡,然而由于游戏效果需要保留尸体(例如用作死亡动画),所以此时游戏对象不应被销毁。除非直到该游戏对象的控制器组件认为该销毁游戏对象。

 

也就是说当个体组件死亡时,这个个体游戏对象不应存在于游戏逻辑中,而是相当于变成了一个游戏场景的摆设物。

所以 对象工厂 应该至少有两个存储容器:
一个存储表示所有个体对象,另一个存储表示个体存活的个体对象。

  • 个体对象被判定个体死亡时,对象工厂 应该注销该个体的存在。
  • 个体对象被判定为游戏对象销毁时, 对象工厂 应该销毁该游戏对象。

查询优化

前面说到 对象工厂 至少使用两个容器的原因,实际上还有另一个原因是游戏逻辑有很多需要查询游戏个体的操作。
而仅使用存储对象的容器是不够优的,因为很可能遍历到一些个体死亡而对象存在的个体对象,浪费效率。

实际上,《ATD》的 对象工厂 还专门用第三个容器来表示存活怪物对象,这是因为许多塔的行为树攻击行为都需要遍历所有怪物个体对象,而不需要遍历到其他个体对象。

额外:说到查询,就不得不提一下 世界查询器,它是一个全局单例类,职责是提供查询接口,例如:

  • 实现爆炸效果,需要查询某点方圆半径10米的所有对象,从而对查询的每个对象造成爆炸影响。
  • 指向性定位目标对象,查询某点发出一条射线碰到的第一个对象,并定位之。
  • 由于某个区域内发生警报,需要查询该区域内的所有对象来逐个通知。

实际上由于急于实现,《ATD》的对象工厂的实现包含了简单的世界查询器的功能。
在以后的扩展,最好这两者需要分离开,对象工厂只负责对象的生命周期,而世界查询器作为一个辅助工具,内维护各种数据结构以加速查询。

lazy delete

当一个个体对象向 对象工厂 请求摧毁该对象本身时, 对象工厂 并不立即Destroy该对象,而是将其SetActive(false),并添加到死亡对象列表。

当 对象工厂 接到一个新的个体对象构造请求时,若死亡列表有对象,从死亡对象列表中选一个个体对象进行属性的覆写,然后再将其SetActive(true);若死亡列表为空,才使用生成函数,真正生成一个新的个体对象。

这是个常见的操作,通过属性的覆写就能“生成”一个新的对象,可以极大的减少new/Destory对象的开销(特别是在这个塔防游戏里,个体对象的生成/死亡十分频繁)。

  • 22
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值