基于Unity3D的体素沙盒游戏设计与实现(下)

114 篇文章 37 订阅

3.4 对象池

3.4.1 对象池概述

    对于处理需要高频反复使用的对象,如果每次都先创建再销毁,则十分消耗计算机性能。故可以考虑使用对象池存储对象,处理此类资源。比起销毁,可以先暂时禁用掉当前无用的对象,当之后需要使用时再激活,以实现重复利用。它通过避免反复实例化运算,有效减少了大量内存碎片,同时也减少了GC次数,显著提高了运行效率[18]。

    一个对象池应至少包括以下两个基础功能:

    (1) 根据需求从池中取出指定类型的对象;

    (2) 回收对象到池中的对应位置。

3.4.2 创建对象池

    在游戏中通常只需一个对象池,故可以设置成单例模式。故在此创建对象池类并继承单例类。不同的对象列表存放不同类型的对象,为根据输入的关键值key查找相应类型的对象列表,可使用一个字典dict存储。

3.4.3 对象创建

    使用对象创建函数接收关键值、预设对象、生成位置、生成角度并返回相应的对象。首先调用FindPoolKey查找池中有无可用的对象,若有则直接更新该对象的位置和角度并激活对象。若池中无对象,则使用实例化函数复制预设对象,使用Add函数将新对象放入池中相应列表。再将对象调整至父物体ObjectPool下并返回。

3.4.3 对象回收

    当对象完成生命周期后,并不直接销毁,而是调用回收函数禁用此对象,完成资源的循环利用。对于方块粒子对象而言,在创建后使用StarCoroutine函数创建一个协程,执行CollectDalay函数,调用WaitForSeconds函数延迟若干秒,等待粒子效果结束时,再回收粒子对象。

3.5 粒子系统

    粒子系统基于对象池创建和运行。新建一个粒子预设对象BREAK并附带Unity3D的ParticleSystem组件,设置持续时间、起始速度、模拟空间、唤醒时播放等参数,并附上Particle脚本。Particle类初始化函数Awake将创建一个顶点顺序为{0,4,7,3}、4个法向量均为Vector3.back、由两个三角面构成一个方块面的粒子默认网格。播放粒子效果时将根据设置的参数拷贝若干个这样的网格。

图 3.5  ParticleSystem参数设置


    以方块破坏粒子为例,在前面区块更新时提到过,当玩家对方块进行破坏操作时,会调用AddParticle函数在目标方块的位置生成该方块种类的相应粒子。此函数首先调用对象池的CreatObject函数返回一个粒子对象,此对象将与玩家的角度相同,以便绘制出的若干方块面网格均朝向玩家,且受到相同的引力方向运动。接着,调用此粒子对象对应的Particle实例里的UpdateMesh函数,将粒子的网格纹理坐标uv更新为目标方块的粒子纹理坐标uvsParticle,并调用CollectObject函数开启协程延迟回收粒子。

图 3.6  拆除方块所生成的粒子效果


3.6 背包更新

    对于大多沙盒游戏而言,背包是不可或缺的重要功能之一。在玩家更新区块网格的同时,也会更新背包数据。背包系统需要实现的基础功能有:

    (1) 物品的增减。当获取物品时,背包里相关物品数增加,当消耗物品时,需要扣除背包内相应的物品数量。一格内只能存储一种物品。物品数量不小于0,且不大于堆叠上限,当超过叠加上限时,另取一格存储。当背包槽满时,新物品将不能装入背包。

    (2) 背包分为内槽和外槽,外槽的物品可在游戏时被直接选择和使用,内槽只有打开背包界面时才会显示。

    (3) 物品的移动与合并。点击物品使其在内槽和外槽之间切换。当目标槽存在相同类型的物品且合并后不超过叠加上限时则合并。否则新开一格。当其中一槽装满时,则无法转移物品,直至腾出空间。

    关于背包GUI相关内容将会在下一章节详细阐述,这里只侧重描述逻辑部分。玩家背包内有36个格子,使用items数组记录背包每格存储的方块数据,使用itemNums数组记录每格的方块数量。其中编号为0-8为外槽,9-35为内槽。玩家可以通过鼠标滚轮在0-8格之间选择一格作为手持格,按右键后将会使用手持格的方块进行建造。默认方块叠加上限为64。

    根据上述要求,可大致将背包更新分为6种情况,如图3.7所示。

图 3.7  更新背包格的基本思路


    这里使用ItemUpdate函数接收要更新的方块种类item,总增量num(可正可负),held表示是否优先检测手持格(默认为True),a-b为最大遍历区间(默认为0-36)。首先,当held为True时令起始i为手持格编号,否则为a。接着用n记录该格增量。

代码清单 3.5  ItemUpdate函数

public int ItemUpdate(BlockType item,int num,bool held=true,int a=0,int b=36){
    var i = held ? itemHeld : a;
    while(i<b){ int n; // 此格增量
        if (items[i] == item){  // 此格为同类
            n = num;
            if (itemNums[i] + n > 64) n = 64 - itemNums[i];
            else if (itemNums[i] + n < 0) n = -itemNums[i];
            num -= n; itemNums[i] += n;
            if (itemNums[i] == 0) items[i] = BlockType.Air;
            … // 更新GUI
        }else if (items[i] == BlockType.Air && num > 0){ // 此格为空且进行"加"操作
            n = num > 64 ? 64 : num;
            num -= n; itemNums[i] += n; items[i] = item;
… // 更新GUI
        }if (num == 0) break; // 剩余总增量为0则中止遍历
        if (held) { held = false;i = a; }
        else i++;
    }return num; // 返回剩余增量
}
    当检测格的方块与item相同,则对该格进行同类增减操作。使n=num。若检测格的方块数量加上n后超过方块对应的叠加上限,则将n设为当前数量与叠加上限的差值。若该格方块数量加上n后小于0,则将n设为当前格数量的负值。更新剩余增量num和该格方块数量。当该格方块数量为0时,将该格方块种类设置为空气。更新该格的UI。

    当检测格为空气且增量大于0,则对该格进行加操作。同样,如果剩余增量超过叠加上限,则使n=叠加上限,反之令n=num。更新剩余增量和此格方块数量,并使该格的方块种类设为item。

    当剩余总增量为0时则中止遍历。反之i加1,检测下一格。最终返回剩余增量num。当num大于0则说明背包已满,无法增加更多此类方块;当num小于0则说明此类方块的需求量高于背包已有方块,需进一步处理。

3.7 本章小结

    本章主要完成了玩家与星球的更新。将每个星球分为6个引力区域,用于方块纹理朝向判定以及修改玩家重力方向。通过玩家第一人称相机的射线检测求出选中的目标方块位置,实现了动态修改了区块的网格并进行相应的优化,完成了自由建造和拆除方块的功能。之后调用基于对象池、协程回收的粒子系统,在破坏方块的位置生成对应的破碎粒子效果,并完成了更新玩家的背包数据的编写工作。使得沙盒游戏的基本功能模块得到了进一步完善。


4 星系与图形用户界面的更新

    在完成玩家与星球的基本更新后,沙盒游戏的基本功能已接近完善。本章将着重讨论星系的创建与动态更新、玩家在星球间的移动,并对图形用户界面(GUI)、设置、存档、大气层渲染等功能进行更详尽的阐述。

4.1 星系的创建

    每个星系由恒星(FixedStar)、行星(Planet)、卫星(Satellite)三种类型的星球组成,这三类星球依次构成逻辑上的父子关系。使用枚举类型StarType表示星球种类。如行星种类有森林Forest、沙漠Desert、冰原IcePlain等。每个星球绑定的Star类实例使用parentStar存储其父对象,child数组存储其子对象,_systems数组存储每个星系对应的恒星对象。使用axisR存储本地自转轴,speeedR存储自转速度,axisA存储本地公转轴,speedA存储公转速度,vAround存储公转半径向量。

    首先使用CreateStar函数创建每个星系的恒星和附于恒星上的参照系system,system不随恒星自转,仅用于变换行星和卫星的公转轴。接着根据行星数量planetNum和其与最外层的行星距离fRadius生成对应的行星。行星公转轴在一定范围内随机生成,并根据当前编号确定公转半径长度,再使用VerticalVec函数获取公转轴的垂直单位向量,得到球面偏移向量d0=垂直公转轴的单位向量*公转半径。行星坐标=恒星坐标+d0。从数组PlanetType中随机抽取行星种类并调用CreateStar函数创建行星。每个行星有一定几率生成若干卫星(初始星必定生成),生成卫星的方式同理,不再赘述。

    CreateStar函数用于创建星球。每个星球则克隆自预设对象starPre,根据输入的starType执行相应的Init函数进行初始化以创建区块网格,根据输入确定每个星球的公转轴、公转半径、所属父级星等,并设置随机的对角自转轴(如Vector3(1,-1,1))和随机初始角度,并返回star实例。

    当星系创建完毕后,将玩家所在星球star设为参照星,将全局参照系refer位置角度同步于star,并把其他星球对象设为refer对象的子级。使直射光light0附在玩家所在星系的恒星下且角度指向star。将star和refer的位置和角度归零,此时也将更新refer的子级星球的位置和角度。只激活玩家所在星系的星球区块渲染网格,且只激活star的区块碰撞网格。将星球摄像机cameraStar父级设为refer,视为star的卫星。最终,完成每个星系的初始化过程。

4.2 星系的更新

4.2.1 以星系参照系为全局参照系

    以星系参照系system(即恒星的位置)为参照系更新星球运动逻辑较为简单,即在每个星球的Update函数里附上对应的自转和公转函数即可。由于玩家被设为所在星球的子级,玩家将跟随所在星球运动而运动。玩家在星球上的运动、建造拆除方块等操作只需要使用TransformDirection或TransformPoint函数将相应三维向量由所在星球的本地空间变换为世界空间即可。但这样可能会遇到浮点误差问题。

    Unity游戏对象Transform组件里的浮点值限制为7个有效数字,当物体距离原点过远时,可能会失去浮点精度会导致诸如不准确的物理现象或是照相机抖动。所以大多数游戏都试图将事物尽可能保持在接近原点位置[19]。当玩家位于星系边缘的星球,或星球的自转公转速度过快,玩家行为出现偏差的频率将上升。随着星球附加的游戏对象的增多,这种现象会更加明显。故将所在星球设为参考系的更新方式更加适合。

4.2.2 以玩家所在星球为全局参照系

    为避免上述情况,故采用以玩家所在星球star为静止参照系的星系更新方式。其思路为:对所有星球进行变换更新,再归位参照星。

    首先更新全部星球的位置和角度。其中恒星只进行自转,行星和卫星进行公转和自转。其中自转函数仅改变对象的角度,公转函数仅改变对象的位置。

    以行星的公转函数AroundP为例,公转前先更新与其子星的公转半径向量vAround,以确保子星之后能随其同步移动。由于这种更新方式会导致星系整体角度变化,如果仍使用原有公转轴则会造成公转轴与公转半径向量不再构成90度,造成公转结果与预期出现巨大偏差,故在公转前还需要把行星的公转轴由星系参照系system本地空间转换为世界空间。接着便可以使行星以换算后的公转轴围绕恒星的公转speedA * Time.fixedDeltaTime度。卫星公转前则需要先同步与父星的位置,再进行公转计算。CameraStar仅在激活时更新。

    各级星球使用的自转函数均为Rotate,由于其自转轴在进行相对星球本地空间的旋转时保持不变,故不需要考虑自转轴的参照系换算问题。

代码清单 4.1  公转和自转函数

private void AroundP(){ // 行星公转
    if (childNum > 0) // 公转前先更新子星的公转半径向量
        for (var k = 0; k < childNum; k++)
            child[k].vAround = child[k].transform.position-transform.position;
    transform.position = parentStar.transform.position + Quaternion.AngleAxis(speedA * Time.fixedDeltaTime, system.transform.TransformDirection(axisA)) * (transform.position - parentStar.transform.position);}
private void AroundS() { // 卫星公转
    transform.position = parentStar.transform.position + vAround; // 卫星随行星公转而同步移动
    transform.position = parentStar.transform.position + Quaternion.AngleAxis(speedA * Time.fixedDeltaTime,system.transform.TransformDirection(axisA)) * vAround;}
private void Rotate(){ // 星球自转
       transform.Rotate(axisR * speedR * Time.fixedDeltaTime); }
    接着备份除参照星star之外其余星球的当前位置和角度。之后使全局参考系refer的位置角度同步于star。由于Unity中父对象变换的改动会同时更新其子级对象,故应还原其子星球的位置角度,使得本次等价于只更新了refer。

    最后归零refer和star的位置角度,这将再次更新其余星球的变换,完成以star为参照系下的星系更新。

图 4.1  以玩家所在星球(最左侧)为参照系的星系运动更新


4.2.3 全局参照系的切换

    在游戏中按F键可以切换至航行模式,此时玩家将不受重力制约,并可以朝目视前方tranform.forward高速移动。当玩家所在层级低于参照星star最大层级levelMax时,进行区域判断AreaJudge(详见第三章节)。反之则调用StarJudge函数判断是否更新参照星。使用Vector3.Distance判断玩家与star的距离,若超过当前星球的最大引力半径radiusMax,则遍历本星系的星球,判断玩家是否在其引力半径之中,若是则使用SwitchRefer函数将参照星star切换到对应星球上。若均不满足则将star设为该星系的参照系system。

    SwitchRefer函数用于切换参照星。首先禁用原参照星的区块碰撞网格,并将原参照星设为refer的子对象。将star设为满足条件的星球,并清除其与refer的父子关系。接下来的步骤与星系更新相类似,在不改动其子星情况下将refer的变换同步于star。将玩家的父对象设为star,再归零star和refer,更新其余星球和玩家的位置角度。并激活star的区块碰撞网格,实现全局参照系的切换。

4.3 天空盒与大气层的显示

4.3.1 天空盒的更新

    根据玩家所处位置准备了两套天空盒:地面skybox1和星空skybox2。仅当玩家处在有大气层的星球上且层级小于levelMax时显示skybox1。其余情况均显示skybox2。在levelMax附近将会修改天空盒的亮度以实现渐变过渡。

图 4.2  通过向量夹角_angle判断玩家所在点的时刻


    如图4.2,当天空盒为skybox1时,使用Vector3.Angle计算玩家至恒星的向量与玩家法向量构成的夹角_angle,动态修改天空盒的颜色。当_angle为0时,为正午;当_angle为70时,为黎明后或黄昏前;当_angle为110时,为黎明前或黄昏后;当_angle为180时,为午夜。每种含有大气层的星球都有各个角度对应的天空颜色skyColor数组,根据_angle对当前天空盒的顶层和底层颜色进行线性插值即可。

图 4.3  玩家处于不同位置下的天空盒


4.3.2 大气层的更新

    星球的大气层使用高光系统插件HighlightingSystem绘制。每个星球对象绑定HighlightableObject脚本,不同种类的星球可设置不同的高光颜色constantColor,对于无大气层的星球使用则ConstantOff Immediate函数立刻关闭其高光效果,并进入遮光板模式。遮光板模式下的物体能遮蔽其他物体的高光。给摄像机绑定HighlightingEffect脚本为屏幕中所有激活高光效果的星球绘制大气层,并设置模糊迭代、模糊强度等参数。当天空盒为skybox1时,使用ConstantOff逐渐关闭除恒星外其他星球的高光效果。当天空盒为skybox2时,则使用ConstantOn逐渐开启有大气层星球高光效果并关闭遮光板。

4.4 用户图形界面

4.4.1 界面概述

    用户图形界面(Graphical User Interface,GUI)允许用户使用鼠标或键盘等输入设备操纵屏幕上的图标选项完成一系列功能。主要包含主菜单、设置、存档、游戏、游戏菜单、背包这几个界面并附在Canvas对象之下。每个界面都有若干个按钮或图像(Image),运行时每次只激活其中一个界面,有些界面激活时会同时激活灰色蒙版Mask或渐变蒙版对象Transition。

    每个按钮包含Text组件和Button脚本。其中Button类继承IPointerEnterHandler, IPointerExitHandler, IPointerDownHandler,并使用泛型委托Action类型的静态数组Functions存储若干个按钮功能的静态函数,在每个按钮执行初始化函数Start时以自身编号为索引从Functions中获取相应的函数作为自身的功能函数_function。当光标移入按钮的范围时,调用OnPointerEnter加载移入音效并使按钮文字颜色转为黄色。移出同理。当光标点击按钮时调用OnPointerDown,若按钮为允许点击状态则播放点击音效、文字颜色改为白色并执行按钮对应功能函数_function,反之则播放警告音效。

4.4.2 基础功能

图 4.4  主菜单界面


    主菜单包含图鉴、设置、存档、开始探索、退出五个按钮以及游戏图标。前四个按钮使用垂直布局组(Vertical Layout Group)。处于主菜单时会激活围绕参照星运动的星球摄像机CameraStar,此时可以很清楚地看到所处星球全貌和建筑成果。如图4.5所示。

    点击图鉴会显示当前星球的种类、自转速度、资源总量等各项参数。点击设置则可以对全屏、音效、音乐等参数进行调整。点击开始探索则会使CameraStar位置逐渐靠近参照星并激活渐变转场,切换到玩家第一人称摄像机CameraFirst、隐藏并锁定光标至屏幕中心并激活游戏界面Game。

图 4.5  设置和游戏界面


    游戏界面包含以下图像:用于对准目标方块的十字准心CrossHair,显示背包外槽物品的快捷栏ShortcutBar以及用于标记当前手持格的选中框SelectGrid。使用滚轮可使SelectGrid在不同格子间切换。在此界面下,按下E键进入背包界面,按下ESC则进入游戏暂停界面。

4.4.3 存档系统

    以序列化的方式存档,反序列化读档。存档保存在Assets\Resources\Save且命名为“save+编号”。启动游戏时,先调用Deserialize反序列化读取配置文件_config,获取上次退出前使用的存档编号select,再调用LoadGame反序列化读取该存档。若找不到该存档,则调用SaveGameNew新建一个编号为select的初始存档并加载。每个存档记录玩家的背包数据、星球方块数据等信息。当退出游戏时,调用SaveGame函数,使用Serialize序列化保存当前编号的存档,并更新配置文件。

    游戏有3个存档槽。由主菜单进入存档界面时,会逐个检测save文件夹下是否存在对应编号的存档,若存在则对应按钮文字显示为“星区+编号”,反之则显示为“无存档”。点击后者会创建一个新档并自动载入,点击前者则切换至另一界面,该界面具有载入和删除两个功能的按钮。

图 4.6  存档界面


    点击载入时,若当前存档编号select与将要载入的存档编号_selecting不一致,则调用SaveGame保存老存档,调用LoadGame读取新存档,更新select,并将界面切换回主菜单。点击删除时,将清除选中存档文件。若删除的是当前加载的存档,则查找是否有其他存档,若有则载入。否则新建一个存档并载入。

代码清单 4.2  存档删除

private static void B17(Button b=null) {
    File.Delete("Assets/Resources/Save/save" + _selecting + ".save");
    if (_selecting ==  core.select){  // 如果删的是当前存档
        var i = 1;
        while(i<=3){
            if (File.Exists("Assets/Resources/Save/save" + i + ".save"))
                break;
            i += 1;
        }if (i <= 3){  // 还有其他存档则载入
            core.select = i;
            core.LoadGame();
        }else{  // 没有存档则新建一个并载入
            core.select = 1;
            core.LoadGame();
    }}SwitchScene(5,0); // 切换回主菜单
}
4.4.4 背包交互

    背包界面GameBag包含:背包图像Bag,选中格蒙版SelectMask,2个网格布局组(Grid Layout Group)Grid0和Grid1。背包格预制对象SLOT_BAG包含源图像RawImage组件和Slot脚本,其子对象Num挂载Text组件用于显示方块数量。RawImage的纹理坐标uvRect由该格的方块种类决定。

    启动游戏时调用CreateSlot将SLOT_BAG克隆36份,并用slots数组存储每格所绑定的Slot实例,再将编号为0-8设为Grid0的子级对象,9-35设为Grid1子级对象。将SelectMask设为第0格slots[0]的子级对象并将其本地坐标归零。游戏界面中快捷栏ShortcutBar的图像格SLOT_PICTURE创建也同理。

图 4.7  背包和快捷栏更新


    当光标移入某个背包格时,该格的Slot实例将调用OnPointerEnter激活SelectMask并以保留原来本地坐标的方式将其父对象更新为此格。当光标移出该格时,调用OnPointerExit禁用SelectMask。当光标按下时,调用OnPointerDown转移方块。当本格方块为空时则结束。若当前格位于外槽,则调用ItemUpdate函数(见前一章节)尝试复制相同数量的方块到内槽,反之复制到外槽。根据返回的剩余增量num0扣除原格方块数量(itemNum-num0)。在执行ItemUpdate过程中同时调用UpdateSlot函数更新对应背包格的纹理坐标uvRect和子对象Num的文本,若该格方块数量不超过1则禁用Num。

代码清单 4.3  转移该格的方块

public void OnPointerDown(PointerEventData eventData){ // 光标按下,物品转移
var item = player.items[id];
    if (item == BlockType.Air) return; // 本格为空则结束
    var itemNum = player.itemNums[id];
    var num0 = id < 9
        ? player.ItemUpdate(item, itemNum, false, 9) // 将item复制到9-36区间
        : player.ItemUpdate(item, itemNum, false, b: 9); // 0-9
    player.ItemUpdate(item, -itemNum+num0, false, id);  // 根据复制结果扣除原格item数量
}
4.5 本章小结

    本章主要完成了星系和GUI系统。在完成每个星系随机生成的自转向量、公转向量、星球种类等参数的星球后,以玩家所属星球为静止参考系,动态计算并更新其他星球的位置,并通过允许玩家在不同的星球间飞行转移。通过计算玩家与恒星的夹角来更新天空盒,并使用高光Shader以营造星球的大气层效果。点击设置可以调节音乐、音效、是否全屏等属性。点击图鉴可以查看当前玩家所处星球的资源数量等各项参数。通过序列化方式存取玩家数据实现存档系统等。在完成本章工作后,游戏的预期功能目标已基本达成。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值