"游戏战斗"是游戏项目中的核心设计。最近10年,基本上我们游戏项目的战斗系统,无论是服务端与客户端,都是基于ECS模式来进行设计。(补充说明: 这里的ECS,不特指Unity (DOTS)或某个引擎的特指工具集,而是一种架构与设计)。游戏战斗是最符合ECS的设计思想与模式的,在一个世界里面来对所有的战斗单元进行迭代计算。
对惹,这里有一个游戏开发交流小组,希望大家可以点击进来一起交流一下开发经验呀
1: 主流游戏战斗系统的需求分析
1: 游戏世界,负责游戏的逻辑管理,游戏的进程管理,负责游戏战斗单元的管理;
2: 游戏世界中的角色:玩家(主角,其它玩家角色,小兵队友等),敌人(近战小怪,远战小怪,精英怪,Boss怪等),道具(戒子,宝石,加速鞋等), 传送门,NPC(任务NPC, 商城兑换NPC等),游戏子弹等。
3: 游戏中的技能与Buff,通过技能来驱动动作动画+伤害计算。通过Buff来改变计算时候的一些属性,能让不同装备,道具等情况下的角色获得不一样的数值体验。
4: 游戏的目标与决策,挑战中敌人如何决策能给玩家造成困难,玩家操作领取特定的任务,完成特定的目标。
5: 游戏核心的战斗数值表:角色数值表,技能数值表,Buff数值,道具数值表等。
以上就是游戏中的主流战斗中的核心需求与核心数据。游戏战斗的本质,移动行走,播放动画+套数值计算公式,过程中获得道具or奖励加Buff来提升数值能力。最后完成目标or任务,一阶段结束。
2: ECS设计模式的概述
概念1: Entity,游戏世界中的角色实体,是由多个不同功能的数据组件组成。(不同于游戏引擎中的GameObject or Node)。
概念2: Component, 组成Entity的数据组件,这里没有任何逻辑,只有特定功能需要用到的数据。
概念3:System,游戏世界中完成特定功能的核心迭代计算。每个不同功能都是一个独立的System迭代计算。
概念4: World: 游戏世界,管理所有的Entity, World的Update中迭代所有的战斗逻辑System的迭代计算。管理好游戏进程,游戏暂停,游戏Entity。World基于某场战斗的一个单例,及每场游戏战斗有只有一个World。
3: 如何基于ECS来设计核心战斗
基于ECS战斗的核心流程:
步骤1: 创建游戏战斗世界对象,用来管理本场战斗;
步骤2: 根据地图 or网络消息,来创建游戏战斗单元与角色;
步骤3:创建游戏玩家控制角色;
步骤4:开启游戏世界的逻辑迭代;
每个战斗单元的Entity设计,每个组件为特定功能迭代需要的数据,伪代码如下:
export class BaseEntity {public worldId: number = -1; // 角色在世界里面的Id;// 各个与战斗相关的组件public uInfo: CharactorInfoComponent; // 角色的相关信息public uTransform: TransformComponent; // 位置旋转public uStatus: StatusComponent; // 玩家的状态数据相关;public uNav: NavComponent; // 玩家导航中需要保存的数据;public uSkillAndBuff: SkillAndBuffComponent; // 玩家释放技能与Buff相关数据;public uRvo: RVOComponent;public uAStarNav: AStarComponent; // 玩家A*寻路的时候需要保存的导航的数据;public uProps: PropsComponent; // 玩家单机游戏需要的战斗+移动数据,帧同步+单机需要;}export class CharactorEntity2D extends BaseEntity {public uAnim: Anim2DComponent; // cocos节点or动画相关;public uThink: ThinkComponent; // 角色做决策思考的组件}
worldId: 唯一表示的角色的ID;
uInfo: 存放游戏角色相关的信息,包括基础数值等;
uTransform: 角色在地图中的位置+朝向;
uStatus: 角色的状态信息;
uSkillAndBuff: 角色技能与Buff的核心数据;
uRvo: 角色做动态壁障需要用到的数据;
uAStarNav: 角色基于AStar地图的寻路与导航所需核心数据;
uProps: 角色在战斗中的一些常用的属性数据值;
uAnim: 与游戏引擎相关显示节点的数据组件,包括GameObject,引擎动画;
...
export class CharactorInfoComponent {public unick: string;public job: number;public sex: number;public charactorId: number;public typeId: number; // 增加一个类型id,不用每次都去计算;public group: number = -1; // 角色属于哪个分组public charactorData: any = null; // 增加一个角色得类型数据,减少string转int这些;}
export enum EntityType {Hero = 1, // 英雄Legion = 2, // 军团MonsterAndTower = 3, // 怪物与塔Tower = 4, // 塔Building = 5, //建筑Transfer = 6, // 传送门}
角色信息组件,包含了数值表中的数据,CharactorData, typeId,group分组,技能与Buff,显示节点等。其中通过CharactorData,typeId来区分不同角色的数值,通过uAnim组件区分角色的外观与显示,由不同的数值(CharactorData)+类型id+技能与Buff(uSkillAndBuff)+动画(uAnim)组合成不同角色。以此来代替传统复杂的继承体系。
在战斗世界World中,对所有的角色Entity进行管理,提供一些访问遍历这些角色Entity的接口函数,方便对战场的整体形式做好数据采集。比如周围的敌人收集等常规战斗算法与计算。方便为特定的System逻辑迭代提供访问战场特定的Entity的数据接口。
接口1: 遍历给定的某种charactorId类型的角色,如遍历所有的"影子刺客"
public ForEachCharactorEntity(charactorId: number, func): void {for (let worldId in this.charactors) {let e: CharactorEntity2D = this.charactors[worldId];if(EntityCtrlUtils.IsEntityInDied(e)) {continue;}// console.log(e, e.uInfo.typeId, entityType);if(e.uInfo.charactorId === charactorId) {let ret = func(e);if(ret < 0) { // 返回-1,停止遍历break;}}}}
接口2: 遍历某种类型的所有角色,如遍历敌人。
public ForEachTypeEntity(entityType: number, func): void {for (let worldId in this.charactors) {let e: CharactorEntity2D = this.charactors[worldId];if(EntityCtrlUtils.IsEntityInDied(e) ) {continue;}// console.log(e, e.uInfo.typeId, entityType);if(e.uInfo.typeId === entityType) {let ret = func(e);if(ret < 0) { // 返回-1,停止遍历break;}}}}
接口3: 遍历某个小团体的所有的角色,如遍历A组的所有怪物:
public ForEachGroupEntity(group: number, func): void {if(group === -1) { // 单独的个体分组,直接返回return;}for (let worldId in this.charactors) {let e: CharactorEntity2D = this.charactors[worldId];if(EntityCtrlUtils.IsEntityInDied(e) ) {continue;}// console.log(e, e.uInfo.typeId, entityType);if(e.uInfo.group === group) {let ret = func(e);if(ret < 0) { // 返回-1,停止遍历break;}}}}
......
在战斗世界World的Update种,迭代计算每个逻辑
// 本地的迭代protected WorldUpdate(dt: number): void {if(this.state !== FightState.Playing) { // 游戏暂停中,不做迭代计算return;}// 处理上一帧的碰撞引擎的碰撞对BulletCalcSystem.Instance.Update(dt);// 军团策略决策LegionCtrlSystem.Instance.Update(dt);// 怪物策略策略MonsterCtrlSystem.Instance.Update(dt);// 本地导航的迭代;this.NavSystemUpdate(dt);// 技能与Buff迭代this.OnSelectSkillUpdate(dt);this.OnSkillAndBuffUpdate(dt);// 子弹迭代this.OnBulletsUpdate(dt);// 同类物体的互斥控制EntityMutexSystem.Instance.update(dt);// 复活需要复活的军团this.OnReviveLegionUpdate(dt);// 角色动画同步迭代this.CharactorAnimStatusUpdate();// 角色血条同步UpdateBloodSystem.update(dt);// 子弹,角色删除回收this.OnDeleteEntitiesUpdate();// end// 传送门传送Heroif(this.isShowTransfer) {this.OnTransferHeroUpdate();}// end}
每个功能的迭代计算,由单独的System来进行迭代和处理,相对独立,互不干扰。每新增一个功能,就增加一个相对功能的System迭代。
以怪物思考的System迭代为例子:
/*** @zh 粗略计算怪物可以的操作。* @example*/public MonsterRoughMarkTaskMask() {// var player: CharactorEntity2D = FightWorldMgr.Instance.GetPlayer();// 标记军团每个成员,让它们根据优先级做决策;FightWorldMgr.Instance.ForEachTypeEntity(EntityType.MonsterAndTower, (entity: CharactorEntity2D)=>{if(entity.uStatus.status === CharactorStatus.Vertigo) { // 有晕眩Buff, 什么也不干return 1;}entity.uThink.taskStateMask = EntityTaskState.BodyIdle;if(entity.uInfo.charactorData.Range <= 0) { // 没有任何攻击属性的建筑;return 1;}// 做一些决策的前置处理this.BeforeRoughMarkForEntity(entity);// console.log(entity.uThink.ActiveAttackEntityId);var warFiledR = this.GetWarFiledR(entity);var dis = Vec3.squaredDistance(entity.uTransform.pos, this.GetWarFiledCenterPos(entity));if((entity.uThink.prevTask === EntityTaskState.GotoSpawnPoint && entity.uStatus.status === CharactorStatus.Run) ||dis > warFiledR * warFiledR) {entity.uSkillAndBuff.comboCount = 0;entity.uSkillAndBuff.comboSkillOptNode = null;entity.uThink.ActiveAttackEntityId = -1; // 已经有敌人了,范围内的,清理仇恨列表entity.uThink.taskStateMask |= EntityTaskState.GotoSpawnPoint;this.EndRoughMarkForEntity(entity, EntityTaskState.GotoSpawnPoint);return 1;}else if(entity.uSkillAndBuff.comboCount > 0) { // 连击没有发完,直接发entity.uThink.taskStateMask |= EntityTaskState.AttackTarget;entity.uThink.taskStateMask |= EntityTaskState.HasEnemySituation; // 有敌情;entity.uThink.ActiveAttackEntityId = -1; // 已经有敌人了,范围内的,清理仇恨列表this.EndRoughMarkForEntity(entity, EntityTaskState.AttackTarget);}else if(EntityCtrlUtils.IsEntityInAttacking(entity)) {entity.uThink.taskStateMask |= EntityTaskState.AttackTarget; // 攻击范围,嘲讽Buff, 警戒范围,仇恨列表entity.uThink.taskStateMask |= EntityTaskState.HasEnemySituation; // 有敌情;entity.uThink.ActiveAttackEntityId = -1; // 已经有敌人了,范围内的,清理仇恨列表this.EndRoughMarkForEntity(entity, EntityTaskState.AttackTarget);return 1;}else if(EntityCtrlUtils.HasEnemyInEntityVisionWithCached(entity, [EntityType.Hero, EntityType.Legion])) {entity.uThink.taskStateMask |= EntityTaskState.AttackTarget; // 攻击范围,嘲讽Buff, 警戒范围,仇恨列表entity.uThink.taskStateMask |= EntityTaskState.HasEnemySituation; // 有敌情;entity.uThink.ActiveAttackEntityId = -1; // 已经有敌人了,范围内的,清理仇恨列表this.EndRoughMarkForEntity(entity, EntityTaskState.AttackTarget);return 1;}else if(entity.uThink.ActiveAttackEntityId !== -1) { // 最后才是单独的处理仇恨列表var target = FightWorldMgr.Instance.GetEntityById(entity.uThink.ActiveAttackEntityId);if(target && !EntityCtrlUtils.IsEntityInDied(target)) {// entity.uThink.taskStateMask |= EntityTaskState.AttackFightMeEntity;// this.EndRoughMarkForEntity(entity, EntityTaskState.AttackFightMeEntity);// console.log("yes it is AttackFightMeEntity");entity.uThink.taskStateMask |= EntityTaskState.AttackTargetentity.uThink.taskStateMask |= EntityTaskState.HasEnemySituation; // 有敌情;this.EndRoughMarkForEntity(entity, EntityTaskState.AttackTarget);return 1;}entity.uThink.ActiveAttackEntityId = -1;}/*else if(EntityCtrlUtils.HasMateInEntityVisionWithCached(entity, [EntityType.MonsterAndTower])) {entity.uThink.taskStateMask |= EntityTaskState.HelpMate;this.EndRoughMarkForEntity(entity, EntityTaskState.HelpMate);return 1;}*/return 1;});// end}/*** @zh 执行军团成员的每个操作。* @example*/public MonsterDoActionUpdate(dt: number): void {FightWorldMgr.Instance.ForEachTypeEntity(EntityType.MonsterAndTower, (entity: CharactorEntity2D)=>{this.DoMonsterEntityHightPriorityAction(entity, dt);});// endFightWorldMgr.Instance.ForEachTypeEntity(EntityType.MonsterAndTower, (entity: CharactorEntity2D)=>{this.DoMonsterEntityLowPriorityAction(entity, dt);});// end}public Update(dt: number) {// 粗略标记敌人可以做的任务决策MonsterCtrlSystem.Instance.MonsterRoughMarkTaskMask();// end// 分析处理是否要帮助队友MonsterCtrlSystem.Instance.MonsterRoughMarkHelpMeta();// end// 执行决策MonsterCtrlSystem.Instance.MonsterDoActionUpdate(dt);// end}
这样就通过System,针对Entity来进行迭代与处理,完成对应的迭代处理逻辑。
4: 基于ECS来设计战斗的优势
优点1: 天然的战斗性能定位分析优势:你可以非常方便的注释掉相关的迭代,用来统计与定位哪些迭代会比较消耗性能与计算,从而更精准的优化;
优点2: 针对战斗种的暂停与恢复,可以非常方便的做到。在World中管理暂停即可;
优势3: 可以从全局的视角来进行计算迭代,而不是从某个角色的视角;
优势4: 组织结构更加的清晰规范,基于不同的迭代,分开代码文件,避免一个代码文件中,N个不相关的代码逻辑,也方便重构与替换。
...
END
今晚20:30直播《肉鸽类商业游戏项目如何落地》,里面有一个环节会向大家展示我们基于ECS来做的核心战斗。欢迎大家来直播间交流。
,时长03:12
本周五(2025年10月17日,20:30分)开始,Blake老师全面直播,分析我们《肉鸽原型项目+方案+教程配套》是如何落地的。全程干货。
直播内容专题如下(每个小专题设置Q&A环节):
1: 立项: 为什么我们会选《肉鸽类》的游戏? Q&A
2: 初步策划案我们是如何来落地的? Q&A
3: 面对策划案的需求,如何做的技术选型与技术架构? Q&A
4: 如何解决原型项目的美术资源与为什么先从原型项目开始?Q&A
5: 如何把别人项目的资源,迁移重构到自己的项目框架中? Q&A
6: 策划使用的地图编辑器我们是如何解决的?Q&A
7: 为什么核心战斗逻辑我们采用ECS架构来实现,有什么好处?Q&A
8: 面对5种英雄,8种军团兵种,25种敌人怪物,37种道具,49种技能,32种局内升级,15级的局外天赋升级,我们是如何做好技能与Buff的架构?Q&A
9:面对秒杀,暴击,复活,无敌,局外局内技能等对数值的影响,我们如何做伤害数值计算。Q&A
10:英雄操控军团的策略,军团与敌人的群体战斗,我们是如何设计与实现的?有限状态机,行为决策树?还是其它方案。Q&A
11: 为什么我们做原型的同时要配套教程?Q&A
12: 美术外包,我们是如何梳理自己的需求的?UI, 角色,场景,特效,ICON等。Q&A
本次主题内容众多,我们将竭尽全力,讲好每一个专题,技术分享毫无保留。我们会根据具体的直播进度,将直播分成连续的若干场,本周五20:30开始,抖音直播间不见不散。
847

被折叠的 条评论
为什么被折叠?



