战斗系统架构:为什么游戏战斗适合ECS架构?
"游戏战斗"是游戏项目中的核心设计。最近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
// 传送门传送Hero
if(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.AttackTarget
entity.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);
});// end
FightWorldMgr.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开始,抖音直播间不见不散。