一看就懂 - 从零开始的游戏开发

admin 2025-05-29 135人围观 ,发现22个评论

对于开发而言,了解一下如何从零开始做游戏是一个非常有趣且有益的过程(并不)。这里我先以大家对游戏开发一无所知作为前提,以一个简单的游戏开发作为例子

0x00写在最前面

对于开发而言,了解一下如何从零开始做游戏是一个非常有趣且有益的过程(并不)。这里我先以大家对游戏开发一无所知作为前提,以一个简单的游戏开发作为,跟大家一起从零开始做一个游戏,浅入浅出地了解一下游戏的开发

此外,诸君如果有游戏制作方面的经验,也希望能不吝赐教,毕竟互相交流学习,进步更快~

这次的分享,主要有几个点:

EntityComponentSystem思想,以及它在游戏开发中能起的作用(important!)

一个简单的MOBA游戏,是如何一步步开发出来的

EntityComponentSystem:

「由于时间关系内容没有仔细校对,难免存在疏漏,还请各位予以指正~」

文章有点长,建议PC端阅读

制作游戏的开始

在动手做游戏之前,最重要的事情当然是先决定要做一个什么样的游戏。作为一个教程的游戏,我希望它的玩法比较简单,是可以一眼就看出来的;在此基础上,又要有可以延展的深度,这样才利于后面教程后面的拓展

一番思索,脑子里的游戏大致是:

类型:MOBA(MultiplayerOnlineBattleArena)

主要玩法:动作-射击类

画面:2d(因为3d游戏开发需要的前置知识点更多,光渲染都可以出本书了,不太适合作为教程)

之所以这么选择,是因为moba游戏属于比较火的类型,而且玩法上有非常多可扩展的点

游戏开发

在决定游戏类型玩法之后,我们就可以开始动手了。对于上面提出来的需求,实现起来需要:

可以管理复杂的对象交互逻辑的框架

能够检测、处理碰撞的物理引擎

渲染游戏场景、对象所需的渲染器

资源,各种各样的资源,包括美术、音乐等各种各样的方面

0x01创世的开始-引擎/框架与游戏

先说一下为什么要取这么个中二的标题实际上最早的电子游戏(Pong),就是源于对现实的模拟,随着技术的发展,游戏画面越发的精致,游戏系统也越发的复杂,还有像VR这样希望更进一步仿真的发展方向。因此,我觉得,做一个游戏,在一定程度上,可以看做是创造一个世界

首先,要做一个游戏,或者说,要创造一个世界,第一步需要什么?按照一些科学家的说法,是一些最基础的「宇宙常数」(eg:万有引力常数、光速、绝对零度etc)在这些常数的基础上,进一步延伸出各种规则。而这个宇宙,便在这一系列规则的基础上演变,直到成为如今的模样

对于我们的游戏来说,同样如此。我们所选用的游戏引擎与框架,便是我们游戏世界中的法则

游戏引擎框架

那么,什么是游戏引擎/框架呢?其实跟我们平时写前端一样。引擎,本质上就是一个盒子,接受我们的输入提供输出(比如渲染引擎接受位置/大小/贴图等信息,输出图像etc)而框架呢,我个人认为更多的是一种思想,决定我们要如何组织功能

类比一下:我们使用的react框架,可以看作是一套组件化编程的范式,它会为组件生成reactelement;而react-dom则是引擎,负责把我们写的组件转换成HTML,再交由浏览器做进一步的工作

那么,作为从零开始的创世,我们就先从游戏框架这里开始第一步——

框架的选择

对于这个游戏,我决定选用ECS(EntityComponentSystem)框架。ECS的思想早已有之,在17年的GDC上因为BlzOW团队的分享而变得流行。在介绍ECS之前,我们先来与熟悉的OOP对比一下:

ProceduralProgrammingObjectOrientedProgramming

国内很多高校,都是以C语言开始第一门编程语言的教学的,对应的编程范式,一般被称为「「面向过程」」;而到了C++这里,引入了「类/对象」的概念,因此也被称为「「面向对象」」编程

Eg:「我吃午饭」

复制

//ProceduralProgrammingeat(me,lunch)//(lunch)1.2.3.4.

前者强调的是「吃」这个过程,「我」与「午饭」都只是参数;后者强调的是「我」这个对象,「吃」只是「我」的一个动作

对于更复杂的情况,OOP发展出了继承、多态这一套规则,用于抽象共有的属性与方法,以实现代码与逻辑的复用

复制

classPeople{voideat()}classHeextsPeople{}classSheextsPeople{}consthe=newHe()constshe=newShe()()()1.2.3.4.5.6.7.8.9.10.
ECS-三相之力

那么,换作ECS则如何呢?

我们首先需要有一个Entity(它可以理解为一个组件Component的集合,仅此而已)

复制

classEntity{components:{}addComponent(c:Component){[]=component}}1.2.3.4.5.6.

然后,在ECS中,一个Entity能干嘛,取决于所拥有的Component:我们需要标识它可以「吃」

复制

classMouth{name:'mouth'}1.2.3.

最后,需要引入一个System来统一执行「吃」这个动作

复制

classEatSystem{update(list:Entity[]){(e=)}}1.2.3.4.5.

OK,现在ECS三者已经集齐,他们如何组合起来运行呢?

复制

functionrun(){consthe=(newEntity()).addComponent(Mouth)constshe=(newEntity()).addComponent(Mouth)consteatSystem=newEatSystem()([he,she])}1.2.3.4.5.6.7.

说到这里,大家可能都要骂坑爹了:整的这么复杂,就为了实现上面这简单的功能?其实说的没错ECS的引入,确实让代码变得更加多了,但这也正是它的核心思想所在:「组合优于继承」

当然,实际的ECS并没有这么简单,它需要大量的utils以及辅助数据结构来实现Entity、Component的管理,比如说:

需要设计数据结构以方便Entity的查询

需要引入Component的状态管理、属性变化追踪等机制,参考资料:

ECSReactiveSystem:

ECS检测Component状态变化:

ECSSystemStateComponent:

真正工业级的ECS框架还需要优化内存管理机制,用来加速System的执行

这里比比了这么多,只是为了先给大家留下一个大概印象,具体的机制以及实现等内容,后面会结合项目的功能以及迭代来讲解ECS在其中的作用,这样也更有利于理解

ECSProsandCons

长处

「组合优于继承」:Entity所具有的表现,仅取决于它所拥有的Component,这意味着完全解耦对象的属性与方法;另外,不存在继承关系,也就意味着不需要再为基类子类的各种问题所头疼(eg:菱形继承、基类修改影响所有子类etc)

「数据与逻辑的完全抽离」:Entity由Component组成,Component之中只有数据,没有方法;而System只有方法,没有数据。这也就意味着,我们可以简单地把当前整个游戏的状态生成快照,也可以简单地将快照还原到整个游戏当中(这点对于多人实时网游而言,非常重要)

「表现与逻辑的抽离」:组件分离的方式天生适合逻辑和表现分离。通过一些组件来控制表现,以此实现同一份代码,同时运行于服务端与客户端

「组织方式更加友好」:真实的ECS中,Entity本身仅具有id属性,剩下完全由Component所组成,这意味着可以轻松做到游戏内对象与数据、文档之间的序列化、表格化转换

不足之处「System之间存在执行顺序上的耦合」:容易因为System的某些副作用行为(删除Entity、移除Component)而影响到后续System的执行。这需要一些特殊的机制来尽量避免

「C与S之间分离」:导致S难以跟踪C的属性变化(因为S中没有任何状态;可以参考unity引入SystemStateComponent/GlobalSystemVersion等,见「扩展阅读」部分1/2/3)

「逻辑内聚,也更分散」:比如A对B攻击,传统OOP中很容易纠结伤害计算这件事情需要在A的方法还是B的方法中处理;而ECS中可以有专门的System处理这件事。但同样的,System也容易造成逻辑的分散,导致单独看某些System代码难以把握到完整的逻辑

引擎各部分

相比负责游戏逻辑的框架,引擎更多的是注重提供某一方面的功能。比如:

渲染引擎

物理引擎

AI引擎

etc

这些引擎,每一部分都很复杂;为了省事,我们这个项目,将使用现成的渲染引擎以及现成的资源管理加载器(Layabox,一个JS的H5游戏引擎)

这里各部分的内容,跟游戏本身的内容关联比较紧密,我会在后面讲到的时候详细说明,这里就先不展开了。免得大家带着太多的问题,影响思考

0x02创世的次日

在整个游戏世界的基础确定了之后,我们可以开始着手游戏的开发了。当然,在这之前,我们需要先准备一些美术方面的资源

大地与水-Tilemap

作为一个moba游戏,地图设计是必不可少的。而没有设计技能,没有美术基础的我们,要怎么才能比较轻松的将脑子里的思路转换为对应的素材呢?

这里我推荐一个被很多独立游戏使用的工具:TilemapEditor。它是一个开源且免费的tilemap编辑器,非常好用;此外,整个图形化的编辑过程也非常的简单易上手,资源也可以在网上比较简单的找到,这里就不赘述过多

TilemapEditor:

如此这般,一番操作之后,我们得到了一个简单的地图。现在我们可以开始整个游戏开发的第一步了

场景角色-大地创生

我们需要有两个Entity,其中一个对应场景——initArena,一个对应我们的人物——initPlayer,核心代码:

复制

functioninitArena(){constarena=newEntity()(('position',{x:0,y:0}).addComponentRectangularSprite('sprite',{width,height,texture:resource}))}1.2.3.4.5.6.7.8.9.10.11.12.

复制

functioninitPlayer(){constplayer=newEntity()('player').addComponentPosition('position',newPoint(64*7,64*7)).addComponentRectangularSprite('sprite',{pivot:{x:32,y:32},width:64,height:64,texture:_TANK})(player)}1.2.3.4.5.6.7.8.9.10.11.12.13.14.

在把这两个Entity加入游戏之后,我们还需要一个System帮助我们把它们渲染出来。我将它起名为RerSystem,由它专门负责所有的渲染工作(这里我们直接使用现成的是渲染引擎,如果大家对这方面有兴趣的话,回头也可以再做一个延伸的分享与介绍渲染其实也是很有意思的事情并不)

复制

classRerSystemextsSystem{update(){constentities=('position','sprite')for(constiinentities){constentity=entities[i]constposition=newPoint(('position'))constsprite=('sprite')if(!){//initlayaspriteignore}const{layaSprite}=spriteconst{x,y}=(x,y)}}}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.
PositionSprite

上面的代码,其实就是ECS思想的体现:Position储存位置信息,Sprite储存渲染相关的宽高以及贴图、轴心点等信息;而RerSystem会在每一帧中遍历所有具有这两个Component的Entity,并渲染他们

然后,我们有了E与S,还需要一个东西把它们串联起来。这里引入了一个World的概念,E与S均是W里面的成员。然后W每一帧调用一次update方法,更新并推进整个世界的状态。这样我们整个逻辑就能跑通了!

复制

classWorld{update(dt:number){(s=(dt))}addSystem(system:System){}addEntity(entity:Entity){}addComponent(component:Component){}}1.2.3.4.5.6.7.8.9.

万事俱备,让我们来运行一下代码:

这样,我们创造游戏世界的第一步:简单的场景+角色就渲染出来了~

输入组件-赋予生命

要实现这点,我们离不开输入。对于moba游戏而言,比较自然的操作方式是「轮盘」。轮盘其实可以看做是虚拟摇杆:处理玩家在屏幕上的触控操作,输出方向信息

对于游戏而言,这个轮盘应该只是UI部分,不应该与其他游戏逻辑相关对象存在耦合。这里我们考虑引入一个UIComponent的全局UI组件机制,用于处理游戏世界中的一些UI对象

摇杆组件

复制

abstractclassJoyStickextsUIComponent{protectedtouchStart(e:TouchEvent)protectedtouchMove(e:TouchEvent)protectedtouch(e:TouchEvent)}1.2.3.4.5.

虚拟摇杆主要的逻辑是:

其中我们需要:

从屏幕对应的全局坐标系转换到摇杆的局部坐标系(线性变换)

判断落点是否在摇杆内(点在圆内)

跟手移动(向量缩放)

通过一些简单的向量运算,我们可以获取到玩家触控所对应的摇杆内的点,并实现摇杆的跟手交互

但是,这离让坦克动起来,还是有点差距的。我们要怎么把这个轮盘的操作转换成小车的移动指令呢?

事件系统-控制的中枢

因为游戏是以固定的帧率运行的,所以我们需要一个实时的事件系统来收集各种各样的指令,等待每帧的update时统一执行。因此我们需要引入名为BackgroundSystem的后台系统(区别于普通系统)来辅助处理用户输入、网络请求等实时数据

复制

classBackgroundSystem{start(){}stop(){}}1.2.3.4.

它与普通System不同,不具有update方法;取而代之的是start与stop。它在整个游戏开始时,便会执行start方法以监听某些事件,并在stop的时候移除监听

复制

classSCMDSystemextsBackgroundSystem{start(){(_CMD,)}stop(){(_CMD,)}sCMD(cmd:any){constqueue:any[]=('cmdQueue')//离线模式下直接把指令塞进队列if(!){(cmd)}else{//走socket把指令发到服务端}}}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.

(此处留待之后做在线模式扩展用)

注意,我们在这里引入了「全局组件」的概念,某些Component,比如这里的命令序列,又或者是输入组件,它不应该从属于某个具体的Entity;取而代之的,我们让他作为整个World之中的单例而存在,以此实现全局层面的数据共享

复制

classRunCMDSystemextsBackgroundSystem{start(){(_CMD,)}stop(){(_CMD,)}runCMD(){constqueue:any[]=('cmdQueue')()}handleCMD(cmd:any){consttype:Command=:CMDHandler=CMD_HANDLER[type]if(handler){handler(cmd,)}}}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.

由于指令可能会非常多,因此我们需要引入一系列的helper来辅助该系统执行命令,这并不与ECS的设计思路有冲突

另外,虽然为了执行指令而引入这两个BackgroundSystem的行为看似麻烦,但长远来看,其实是为了方便之后的扩展~因为多人游戏时候,我们的操作很多时候并不能马上被执行,而是需要发送到服务器,由它收集排序之后返回给客户端。这时候,客户端才能依次执行这序列中的指令

复制

classJoyStickextsUIComponent{touchMove(e:TouchEvent):Event|undefined{//ignoreconstpoint=(changedTouches[0])if(===changedTouches[0].identifier){//ignore}returnundefined}}1.2.3.4.5.6.7.8.9.10.

指令有了,再加入攻击指令的处理方法:

复制

//增加以下代码if(('bullet')){const{range,origin}=('bullet')if(range*(origin)){('destroy')}}1.2.3.4.5.6.7.

超出了射程范围的子弹,应该被移除其实这个逻辑,应该另外再加一个BulletSystem之类的系统用于处理的,这里我偷懒了我们会给超出了射程范围的子弹加一个Destroy的标记,之后销毁它。原因在下面的DestroySystem处有提到

复制

functioncreatureBullet(entityA:Entity,entityB:Entity,world:World){constaIsBullet=('collider').type====aIsBullet?entityA:entityBconstcreature=aIsBullet?entityB:entityAconst{generator:generatorID}=('bullet')if(generatorID===){return}('destroy')}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.

与障碍物/角色碰撞的子弹,也需要移除。但是忽略子弹与自身的碰撞(因为子弹是从角色当前位置被发射出去的)

复制

classDestroySystemextsSystem{update(){constentities=('destroy')for(constiinentities){(entities[i])}}}1.2.3.4.5.6.7.8.

这里做的还比较简单,如果是完整的实现,还可以补充上子弹销毁时候的「爆炸动画效果」。我们可以借助ECS中的Entity上面的removeFromWorld回调实现之

*ps

:这里的DestroySystem执行顺序应该位于所有System之后。这也是ECS应该遵循的设计:推迟所有会影响其他System的行为,放在最后统一执行

**

pps

:这里可以再增加一个池化的机制,减少子弹这类需要反复创建/销毁的对象的维护开销

AI的引入

到目前为止,我们已经有一个比较完整的地图,以及可自由移动、攻击的角色。但只有一个角色,游戏是玩不起来的,下一步我们就需要往游戏内加入一个个的AI角色

我们将随机生成Position(x,y)的位置,如果该位置对应的是空地,那么则把AI玩家放置在此处

#2

复制

functioninitAI(world:World,arena:TransformedArena){for(leti=0;icount;i++){letx,ydo{x=random(left,right)y=random(top,bottom)}while(tilemap[x+y*width]!==-1)constenemy=generatePlayer({player:true,creature:true,position:newPoint(cellPixel*x,cellPixel*y),collider:{/**/},speed,sprite:{/**/},hp:1})(enemy)}}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.

但是,这些AI角色,他们都莫得灵魂!

在我们创造AI角色之后,下一步就需要给他们赋予生命,让他们能够移动,能够攻击,甚至给他们更加真实的一些反应,比如挨打了会逃跑,会追杀玩家etc。要实现这样的AI,让我们先来了解一下游戏AI的一种比较常用的实现方式——决策树(或者叫行为树)

行为树

整个行为树,由一系列的节点所组成,每个节点都具有一个execute方法,它返回一个boolean,我们将根据这个返回值来决定下一步的动作。节点可以分为以下几类:

选择节点:执行所有子节点,当遇到第一个为true的返回值时结束

顺序节点:执行所有子节点,当遇到第一个为false的返回值时结束

条件节点:一般用来作为叶子节点与顺序节点、行为节点组合,实现条件执行动作的功能

行为节点:具体执行动作的节点,比如移动、攻击etc

更具体的解释可参考

这里我们构建了几个AI最基本的动作,作为叶子节点

移动

索敌

攻击

省略了大部分逻辑相关代码,具体可见systems/ai目录下相关文件

复制

classRandomMovingNodeextsActionNode{execute(){//寻路returntrue}}classSearchNodeextsConditionNode{condiction(){//检测范围内是否存在敌人}}classAttackNodeextsActionNode{execute(){//向敌人发起攻击returntrue}}//TreeComponent有方法,不太好,想想怎么改exportclassTankAITreeextsBehaviorTree{constructor(world:World,entity:Entity){=newParallelNode(this).addChild(newRandomMovingNode(this),newSequenceNode(this).addChild(newSearchNode(this),newAttackNode(this)))}}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.

在这几个基础的叶子节点上,搭配上文提到的并行、顺序等节点,就可以组成一棵简单的AI行为树:AI一边随机移动,一边搜索当前范围内是否存在敌人

然后我们把行为树附加到AI角色身上,他们就可以动起来了!

运行展示一下

0x04总结

到这里,我们已经做出来一个简单的游戏了!第一部分的内容,到这里就暂告一段落了。回顾一下,在这部分里面,我们:

实现了一套逻辑层相关的ECS框架,用于管理复杂的游戏对象的更新交互逻辑

实现了简单的事件系统,以及UI组件相关逻辑

简单实现了游戏中的大部分逻辑:移动、攻击、相机跟随

当然,它也还差一些未完成的部分:

多人游戏支持

游戏选单(GameMenu):包括重新开始、退出游戏等

更丰富的玩法:比如守家/占点/夺旗多种模式

更多的游戏元素:技能、升级成长、地形

这只是一个作为教程的示例,并不能做到尽善尽美,但还是希望大家能在整个分享里面,对「如何从零开始做一个游戏」这件事,有一个或多或少的认知。如果能让大家感觉到,「做一个游戏,其实很简单」的话,那今天的分享就算是成功了~

说起来后面如果有时间,可以把这些点都补充上去,实际上,都还挺有趣的..

猜你喜欢
    不容错过