当前位置: 首页 > news >正文

游戏引擎学习第288天:继续完成Brains

回顾并为当天内容做准备

现在是星期五,我们之前对敌人系统做了一些工作,但还没完全完成。我们实现了一个新的系统,想尝试用它来为比单一简单单位更复杂的实体创建控制代码。今天的目标不是完全完成它,而是至少把它推进到可以进行测试的阶段,因为虽然我们实现了基础部分,但还没完成整个系统。

如果想跟着系列学习,这是个不错的起点。项目现在已经能编译了,但只是名义上的编译,实际上还没有任何功能,比如现在还不能控制主角。游戏依然会正常启动,实体也会出现在正确的位置,但控制器代码还没执行。

这是因为脑(brain)代码还没真正实现。虽然我们已经在实体中设置了脑的类型和ID,也创建了相应的槽位,但负责加载这些脑的哈希表还没实现,所以目前脑不会被激活,也不会在脑数组中循环触发任何逻辑。

现在已有脑哈希的概念,定义了最大脑数量和脑的相关结构,我们有了存储和执行脑的框架,但还没有具体的脑代码实现。接下来要做的是开始实现这些脑,让它们开始运作。

接下来准备查看相关的代码模块,调整好坐椅,继续深入开发。

game_sim_region.cpp:引入 GetOrAddBrain 函数

在查看 tsim region 代码时,我们发现这里有一段是往实体列表中添加实体的操作,类似地,我们也需要实现往脑(brain)列表中添加脑的功能。现在的设计规则是,当调用获取脑的函数时,默认调用者已经通过哈希表查找过,且没找到对应的脑才会调用这个函数。这个逻辑有点奇怪,因为我们实际上已经在调用处做过哈希查找了。

脑与实体不同,实体是唯一固定地存在于世界中的,而脑是基于一组实体的模式动态生成的。当我们看到一个实体具备某个脑类型和ID时,会尝试创建对应的脑,但可能该脑已经存在于模拟中,我们这时只是想把实体关联到已有脑上。因此,脑的添加函数需要具备“如果存在则返回,否则创建”的功能,既是添加脑也是获取脑的接口。

对于函数参数,有个疑问:为什么要传入整个world mode?实际上脑管理只需sim region就够了,目前先去掉world mode参数,保持简洁,以后需要时再加回来。

创建脑时,脑其实就是ID、类型和一组槽(slots)。目前还没实现槽的细节,但脑ID和脑类型至少是必传参数。函数逻辑是先用哈希表查找是否已有对应脑,如果有则直接返回,如果没有则从脑的栈上分配一个新的脑,初始化它的ID和类型等数据,并插入哈希表。这样后续查找可以直接返回对应脑。

目前脑的存储依旧是基于栈结构的,类似于实体的存储方式。以后可能为了支持动态扩展,改成动态内存分配,不过目前不需要。为了避免脑数量超过最大限制,会加入断言,防止堆栈溢出造成难以调试的BUG。

总的来说,脑的创建和获取设计成一体化函数:传入脑ID和脑类型,尝试查找脑,找不到就创建新脑,初始化后返回,并保证在哈希表中有对应指针。这样既方便动态生成脑,也方便后续实体和脑之间的绑定操作。
在这里插入图片描述

在这里插入图片描述

game_sim_region.cpp:为 brain_id 引入 GetHashFromID

现在面临的唯一问题是,我们还没有为脑(brain)创建专门的哈希表。这个哈希表跟实体用的哈希表很像,本质上是同一种结构,只不过存储的对象不同。虽然可以尝试用模板或泛型代码来统一实现,但由于代码本身比较简单且不多,且以后可能会有特殊需求,需要对脑和实体的哈希表进行不同的特殊处理,所以不打算用模板化的方案。

脑的哈希表代码和实体哈希表几乎一样,没有特别的区别。当前命名方面,脑哈希表用的是ID这个名字,实体哈希表也应该统一使用ID命名,不过这些命名细节可以留待后续清理代码时再调整。

另外,对add entity函数做了清理,移除了其中传入的world mode参数,使代码更加简洁。

接下来,每当添加一个实体时,就会在这里处理相关逻辑。目前唯一调用添加实体的地方是add enemy函数,理论上也可以考虑把add enemy的逻辑整合到这个添加实体的地方,让代码更统一流畅,但目前还没决定是否这么做。

总结来说,关键步骤是为脑创建一个哈希表,结构与实体哈希表类似,方便查找和管理脑。实现过程中避免过度模板化,保持代码简单直观,后续再根据需要做命名和结构的优化。添加实体时同步维护脑的相关数据,确保实体和脑的关联正确。
在这里插入图片描述

game_sim_region.cpp:移除 AddEntityRaw,并将 AddEntity 的功能内联到 BeginSim 中

我们发现entity raw这部分代码其实不清楚具体作用,怀疑它根本没用,应该直接去掉。identity这部分代码其实只会在解包(unpack)过程中调用,因此考虑把这部分代码直接内联到解包函数里,这样代码会更直接、更简洁。

解包函数的作用是将世界状态转换成模拟状态。现在的想法是,当添加一个实体(entity)完成后,如果该实体是可更新的,我们希望它能拥有一个大脑(brain)控制器。即使实体不可更新,也希望大脑能够感知到它的存在,只是不进行模拟。目前关于“可更新”状态的处理还不完善,后续可能会调整。

具体操作是:在解包函数中,检查实体的脑类型(brain type),如果脑ID非零(即该实体有脑),就调用添加脑(ADD brain)的函数,传入模拟区域(sim region)、脑ID和脑类型,从而创建对应的大脑。具体参数顺序暂时不确定,但编译器会帮助确认。

总结来说,解包时将实体添加后,基于实体的脑ID判断是否需要创建大脑,调用对应的创建函数,把脑和实体关联起来,确保模拟时大脑能够控制或感知这些实体。同时优化代码结构,把之前分散的identity部分代码直接合并进解包流程,使整体更简洁高效。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

game_entity.h:将 struct brain 从 game_sim_region.h 中移入,添加一个 Parts 数组,并引入 brain_hero_parts

目前关于大脑(brain)的存储结构还没有最终确定,先做一个简单的实现来让系统能工作起来。我们暂时在模拟区域(sim region)里维护一个大脑槽(brain slots)的集合,这些脑槽可能以后会移到其他地方,比如单独的brain.h文件。

每个大脑会有自己的结构,大致会包含若干“部件”或者成员(members/parts),具体叫什么还不确定。大脑的部件数量有上限,具体多少还不确定,结构大概是类似一个数组或者集合。我们可能会用union联合体来存储不同类型的指针,比如某个脑槽里存的是实体指针(entity pointers),还有可能会根据脑的类型不同,有不同的成员,比如英雄脑可能有头部、身体这样的具体实体指针。

这样设计的好处是,既能用一个通用的列表存储不同脑的部件,也可以根据具体脑的类型,通过命名字段来访问特定部件,比如entity head、entity body等等。

每个实体都拥有一个脑槽索引(brain slot),用来指明它对应存储在哪个脑槽里。添加脑的时候,会先确保索引没越界,再把实体放入对应脑槽,真正构建这些脑控制器。

目前还没有实现脑槽的动态分配,在创建模拟区域时,也没有设置最大脑数量(max brain count),这需要后续根据实际需求调整。现在只能先临时指定一个数量用于测试,具体的最大数量未来会根据游戏整体情况再做分析和修改。

总结来说,虽然细节还未敲定,但已经开始搭建脑的存储和管理框架。大脑作为实体控制器,会有自己的部件集合,每个实体会关联到特定脑槽中,这样就能真正开始构建和使用脑来控制游戏实体了。未来会继续完善脑的结构和数量管理,使其更加合理和灵活。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

运行游戏,发现没有任何反应

目前虽然尚未在逻辑上实际使用这些大脑(brains),但理论上它们已经被成功创建。从测试效果来看,程序运行中确实没有表现出任何新行为,这是预期的,因为我们尚未对大脑进行实际调用或模拟,但至少现在大脑的数据结构和创建流程已经就位。

例如,主角(hero)应该已经被分配了一个大脑。这意味着在模拟阶段成功创建了大脑实例,并与对应的实体建立了关联。接下来我们可以跳转回世界模式(world mode),检查处理主角大脑的部分逻辑,比如 brain_hero 函数,以进一步验证或扩展这些大脑的功能。

此时,在 world mode 的处理流程中,我们已经能够从已有的实体信息中获取到与之绑定的大脑,之后就可以根据实体的类型(例如主角)执行对应的大脑行为逻辑,比如路径规划、目标选择或是交互决策等。

这标志着整个大脑系统已经完成了从数据结构定义、内存分配、哈希查找、绑定实体到模拟阶段初始化的一整套流程。虽然目前功能尚未完全开发,但基础框架已经搭建完成,下一步就是开始在模拟或逻辑执行阶段真正使用这些大脑来控制游戏行为。这个阶段的工作为后续 AI 控制系统的实现打下了坚实的基础。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

game_world_mode.cpp:在 AddPlayer 中为 BrainHero 设置 Head 和 Body

目前我们注意到,大脑结构虽然被成功创建,但其内部的数据成员(例如主角的 head 和 body)尚未被填充。为实现其功能,必须从已创建的大脑中提取对应的实体引用,并赋值给具体的大脑结构成员。

我们所做的第一步是进入对应的大脑控制逻辑,比如主角大脑的处理函数。在这里,我们从大脑结构中提取 head 和 body 这两个成员,即:

  • brain_hero.head 表示主角头部实体;
  • brain_hero.body 表示主角身体实体。

我们将这两个成员设置为之前绑定到该大脑的实体列表中对应的指针。这一步将大脑与具体的实体建立了明确的功能性关联。这样,大脑控制逻辑就可以直接访问并操作这些实体数据,从而实现更复杂的行为处理。

随后我们运行程序进行验证,确认主角的头部和身体已经被正确识别并处理。从运行结果可以观察到角色正确地“站立”在目标实体上,说明控制逻辑已开始实际运行。因为此类逻辑不会在其他地方触发,因此可以基本确定这些行为正是由大脑系统首次激活后开始执行的。

接下来的工作重点是进一步验证当前代码是否确实已经进入了预期的控制路径。通过设置断点或打印日志,我们可以确认是否确实进入并执行了相关大脑控制代码,确保逻辑链完整可靠。

整体来看,目前已经实现了以下几点:

  1. 在模拟区域中成功创建了大脑对象;
  2. 将实体正确绑定到大脑的 slots 中;
  3. 在主角控制逻辑中成功读取这些绑定信息;
  4. 控制逻辑开始运行,并对游戏状态产生可见影响。

虽然功能仍处于初期阶段,但大脑控制系统的骨架已经初步成型,后续只需逐步扩展逻辑即可实现更复杂的 AI 行为。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

运行游戏,生成大量英雄实体

我们目前已经确认,大脑控制逻辑的代码确实开始运行,这可以通过我们向世界中不断生成实体时观察到他们被放置在最近的非占用点这一行为得出结论。这一机制原本是为了快速放置实体而设计的,但具体为什么以这种方式实现,目前也没有明确的原因。

同时,旧有代码中存在的一些遗留无用函数如 identity 已被清理,以避免不必要的干扰。

在当前阶段,我们虽然实现了大脑与实体的绑定和基本初始化,但还需要确保大脑真正具备控制行为的能力。其中最主要的问题在于:

旧的移动机制存在设计缺陷:

原来的系统中,实体的移动是通过直接调用 move_entity() 并传入实体自身的加速度实现的。这种方式在逻辑上是一种“即时响应式”的操作,缺乏对输入意图的清晰分离。

新的行为机制设计目标:

我们更希望采用一种“行为规划+执行分离”的机制,也就是说:

  • 大脑只负责给每个部件分配动作意图(例如加速度)
  • 后续由独立的物理处理阶段统一执行这些动作意图

这样做的好处是:

  1. 逻辑清晰:大脑控制逻辑专注于做决策,而不是立即执行;
  2. 便于预测和优化:提前知道所有物体的动作意图,有助于进行物理模拟、碰撞检测等;
  3. 支持打包和延迟执行:这种结构与后续的打包/解包处理(例如网络同步或存档重现)更加兼容;
  4. 避免依赖旧逻辑:旧逻辑中的“直接移动”会导致行为和输入混杂,不利于后期维护和扩展。

举例说明:

一个实际问题是,如果我们在碰撞检测阶段不知道物体即将朝哪个方向加速,那么就无法准确判断是否会发生碰撞。提前设定好加速度意图,可以让系统在模拟开始前就做好物理准备,从而避免错误的物理反应或碰撞穿透。

总结当前进展:

  • 成功初始化了实体与大脑的绑定;
  • 已能通过大脑控制逻辑影响实体状态(如放置位置);
  • 清理了一些无用遗留代码;
  • 下一步将重构实体移动系统,从“立即移动”改为“延迟执行”,由物理系统统一处理;
  • 为后续添加更复杂 AI 行为(如路径规划、状态机)奠定基础。

整体思路正在朝模块化、解耦、数据驱动的方向稳步推进,后续还需逐步完善大脑与物理系统的接口设计和调度流程。
在这里插入图片描述

Blackboard:介绍 ddP 以及在一起模拟前先分配加速度的好处

我们目前进入了物理系统与大脑控制系统整合的关键阶段。之前虽然实现了一个基础的物理系统,但那只是起步阶段的简单方案,现在随着大脑系统逐渐接入,真正的行为模拟与物理控制也需要跟上。


当前的问题与目标

我们意识到一个核心问题:
逐个实体依次处理移动会引发物理模拟不准确的问题。

例如:

  • 如果两个实体想同时向右移动,并且它们当前相邻,那么先处理的实体会认为前方被阻挡,从而停止;
  • 但实际上,如果两个实体都要移动,那么第二个实体会离开原地,第一个实体应该可以成功前进;
  • 由于我们并没有提前知道其他实体的“意图”,导致模拟行为与预期不一致,产生微妙的碰撞错位(称为“微抖”或“碎步”问题)。

解决思路:分阶段执行

为了解决这个问题,我们采用以下执行模型:

  1. 第一阶段:意图阶段(意图收集)

    • 每个大脑先独立运行,根据其逻辑计算各自控制的实体应当执行的动作;
    • 这些动作被表示为“加速度意图”或“移动请求”,而不是直接移动;
    • 这些意图被记录下来,存储在实体结构中。
  2. 第二阶段:统一物理模拟(动作应用)

    • 物理系统统一读取所有实体的意图;
    • 同时考虑所有实体的移动计划、加速度、碰撞等;
    • 进行统一模拟,计算最终的位置变化并应用;
    • 这样做可以最大程度避免“先移动的阻挡了后移动的”的问题。

当前执行步骤

  • 已经实现了大脑系统将加速度写入其控制的实体;

  • 比如 brain_hero 中控制的 hero,其 headbody 会被写入加速度数据;

  • 原先控制输入的逻辑还存在于旧的控制器模块中(controller code),需要和大脑系统整合;

  • 接下来的步骤是:

    • 清理控制器与大脑的冗余部分;
    • 把控制逻辑全部交由大脑统一处理;
    • 并且结构上使 controller 的行为更加整洁与清晰;
    • 为后续扩展更多种类的智能实体和 AI 行为做准备。

总结当前阶段成果

  • 初步完成大脑分配与初始化;
  • 明确了意图先于执行的模拟顺序;
  • 已在实体结构中准备好了接受加速度指令的接口;
  • 下一步将是整合旧控制器逻辑、规范控制流程、清理无用结构。

这一机制将显著提升行为模拟的合理性,消除微妙错误,为复杂的交互与碰撞系统打下稳定基础。

game_entity.h:给实体添加 ddP 属性

我们决定对加速度的处理方式进行简化并改进,采取更直接、清晰的方式来表达对象受到的力。以下是这一阶段的详细思路与实现:


当前目的

我们要做的,是将加速度(ddP,即 delta delta position,加速度)明确地记录在每个实体(entity)中。这不是为了保存历史数据,而是为了在每一帧内部的逻辑处理过程中有一个临时的累加器,供所有可能“推动”该实体的逻辑模块写入影响。


关于加速度存储的说明

  • 加速度不是持久数据

    • 加速度是临时变量,仅在每一帧的更新中起作用;
    • 只需要在帧开始时初始化为0,并在帧内由各逻辑模块累加;
    • 不需要被打包(pack)或序列化,不会存入存档或状态保存中;
    • 每帧处理完毕后,会通过物理模拟转换为速度(velocity)的变化。
  • 为何选择这种方式

    • 虽然可以直接将控制器的输出写入速度,但那样做缺乏灵活性;
    • 多个控制器或逻辑单元(如大脑、物理环境等)可能对一个实体产生影响;
    • 使用加速度累加器,可以更清晰地聚合多个力的作用;
    • 提高了可扩展性,也方便日后调试。

实现方法简述

  1. 在实体结构中添加一个 ddP 字段,类型为加速度向量(通常是二维或三维向量);
  2. 每帧开始时将该值清空(设为零);
  3. 各逻辑模块(如大脑控制器)可以通过对 ddP 加值来“推动”该实体;
  4. 最终在物理模拟阶段,根据该值计算速度和位置的变化;
  5. 计算完毕后清空,为下一帧做准备。

举个简单例子

假设一个实体由英雄大脑控制:

  • 英雄大脑每帧判断输入并决定移动方向;
  • 然后对 entity->ddP 累加一个向右的加速度向量;
  • 如果有多个部分(如头部、身体)都会被控制器推动,它们各自的 ddP 会独立累加;
  • 最后在物理模拟阶段,统一读取所有实体的 ddP,计算更新后的速度和位置。

总结

我们在实体中加入了一个临时性加速度累加器,用于收集各控制逻辑对实体的推动作用。该字段不会被打包存储,仅用于每帧内部计算。这一设计提高了系统的清晰度与可扩展性,为后续多来源控制、多实体协作以及复杂的物理反应模拟提供了良好的基础。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

game_world_mode.cpp:在 UpdateAndRenderWorld 中,让控制器代码获取对应英雄的控制器

我们目前正在重构控制器与实体(特别是“英雄”)之间的关联逻辑,使其更加清晰、模块化,并符合当前系统的架构思路。以下是对整个过程的详细整理与总结:


保持的逻辑(保留部分)

我们保留原本用来检测控制器是否尝试加入游戏的逻辑,也就是:

  • 遍历所有控制器;
  • 判断控制器是否已绑定到某个玩家;
  • 如果尚未绑定并按下 Start 键,则创建一个新的玩家。

这段逻辑合理,是我们需要保留的——用于允许新玩家通过控制器加入游戏。


控制器绑定重构目标

我们原本在运行每个“英雄大脑”(Hero Brain)时,会去遍历所有控制器来确认哪一个控制器控制当前英雄,这是不合理的,冗余且低效。

现在我们有了明确的大脑(Brain)结构,并通过它将实体与控制器绑定,我们打算将控制器直接通过索引或引用关联到特定实体,使得:

  • 每个英雄只需直接访问其控制器索引,无需全局搜索;
  • 控制器和实体之间的映射关系明确、可靠;
  • 减少运行时开销,提升架构清晰度。

英雄与控制器的绑定关系

我们约定,“英雄”这一实体的定义基础是它拥有控制器连接,也就是说:

  • 只有拥有控制器的实体才被视作英雄;
  • 如果要创建一个类似英雄但由 AI 控制的角色(例如僵尸),那就是另外一种大脑类型;
  • 英雄大脑专门负责读取控制器输入,而 AI 大脑将使用自身逻辑控制动作。

这样的设计允许我们以相同的框架处理不同类型的控制逻辑,同时保持逻辑边界清晰。


新的控制器处理流程

  1. 创建玩家时绑定控制器索引
  2. 在大脑(Brain)中存储该控制器索引
  3. 在执行大脑逻辑时,直接通过控制器索引获取该控制器输入
  4. 避免冗余遍历,提高效率与清晰度

临时移除旧的僵尸测试代码

由于目前系统中没有合适的方式生成僵尸(AI控制角色),我们先移除旧的测试代码,以保持主流程整洁。后续会:

  • 新建一个僵尸大脑(Zombie Brain);
  • 可能复用部分英雄大脑的逻辑;
  • 让其自动控制角色行动,作为测试用例。

最终目标

  • 实现稳定、明确的控制器绑定机制;
  • 将控制器驱动的大脑与 AI 大脑彻底分离;
  • 为后续扩展(如多种 AI、网络控制、输入切换等)打下基础;
  • 保持系统架构简洁、清晰、可维护。

这次重构主要目的是提升架构一致性与运行效率,使大脑系统能够清晰地区分由玩家控制与由程序控制的实体,并为多种角色行为提供灵活支撑。

game_world_mode.cpp:在 UpdateAndRenderWorld 中基于 Brain->ID.Valueα 设定 ControllerIndex

我们正在对控制器索引(controller index)与大脑 ID(brain ID)之间的映射逻辑进行简化和规范化,以下是详细的中文总结:


控制器索引与大脑 ID 的映射

我们决定通过大脑 ID 来确定控制器索引,方法如下:

  • 预留一段连续的大脑 ID 区间专门用于“英雄”类控制器;
  • 将控制器索引直接映射为大脑 ID 减去起始值,例如:
    controller_index = brain_id - reserved_first_hero_id
  • 通过这样的设计,每一个英雄大脑都天然对应一个控制器索引,无需额外搜索或绑定逻辑。

控制器编号与大脑 ID 区间的规划

  • 控制器编号是从 0 开始的;

  • 假设大脑 ID 从 1 开始(0 不可用),那么我们就从 1 开始为英雄控制器分配大脑 ID;

  • 使用一个枚举(enum)来定义 reserved_brain_id_first_hero,这样能确保该区间在代码中具有清晰语义;

  • 比如,如果 reserved_brain_id_first_hero = 1,那么:

    • 大脑 ID 1 对应控制器 0;
    • 大脑 ID 2 对应控制器 1;
    • 依此类推。

安全性处理

  • 由于 get_controller 函数内部本身已经包含断言(assertion)来确保索引有效,因此在这里无需重复添加断言;
  • 利用断言机制可以及时捕捉非法访问,保证运行期安全。

代码整合位置与方式

  • 可以选择在大脑模块或实体模块中定义该 enum

  • 推荐放在与大脑相关的结构中,这样逻辑更加集中、清晰;

  • 使用 enum 的好处是可以为不同类型的大脑保留不同的 ID 区段,例如:

    enum {reserved_brain_id_first_hero = 1,reserved_brain_id_first_zombie = 100,reserved_brain_id_first_ai = 200,// 依此类推
    };
    

系统结构性好处

这种处理方式带来了多方面的优势:

  • 简化绑定逻辑:不再需要在运行时查找哪个控制器控制哪个大脑;
  • 统一命名规范:通过枚举定义所有预留 ID,清晰明了;
  • 便于扩展与维护:后续添加新的 AI 或控制器类型时,只需继续预留新的 ID 区段即可;
  • 增强可读性:控制器索引与大脑 ID 之间的关系一目了然,便于调试与理解。

这套结构实现了控制器、大脑、实体三者之间清晰、高效的映射机制,为整个系统的可扩展性和模块化打下了坚实基础。
在这里插入图片描述

game_entity.h:引入 reserved_brain_id

我们为“英雄”类大脑(即通过控制器操作的角色)专门划分并保留了一段大脑 ID 区间,用于和控制器进行一一映射。以下是该部分逻辑的详细中文总结:


大脑 ID 的保留机制设计

为了实现大脑与控制器的直接映射,我们采用以下方式对大脑 ID 进行管理和预留:

1. 枚举方式预留关键 ID

使用枚举(enum)定义以下关键值:

  • reserved_brain_id_first_hero:第一个“英雄”大脑的 ID,设定为从 1 开始;
  • reserved_brain_id_last_hero:最后一个“英雄”大脑 ID,等于 reserved_brain_id_first_hero + 控制器数量 - 1
  • reserved_brain_id_first_free:下一个可分配的大脑 ID(即不属于控制器控制的脑),等于 reserved_brain_id_last_hero + 1
2. 控制器数量常量定义

使用一个宏定义 MAX_CONTROLLER_COUNT,表示系统支持的最大控制器数量,例如设为 4、8 或其他合适值。用于参与计算保留的大脑 ID 区间范围。


为什么需要这样预留

  • 控制器与大脑一一对应:每个控制器对应一个“英雄”角色,因此需要为每个控制器分配唯一的大脑 ID;
  • 简化查找逻辑:在代码中可以直接通过大脑 ID 推导出控制器索引,反之亦然;
  • 清晰的资源划分:通过明确的枚举变量管理保留 ID,避免 ID 冲突;
  • 方便扩展:未来添加更多控制器时,只需增加 MAX_CONTROLLER_COUNT 即可,不影响已有逻辑。

具体实现逻辑

  • 假设 reserved_brain_id_first_hero = 1MAX_CONTROLLER_COUNT = 4
  • 那么控制器相关的大脑 ID 范围为 1~4;
  • reserved_brain_id_last_hero = reserved_brain_id_first_hero + MAX_CONTROLLER_COUNT - 1,即 1 + 4 - 1 = 4
  • reserved_brain_id_first_free = reserved_brain_id_last_hero + 1 = 5,从这个 ID 开始可以分配给其他用途,如 AI、敌人等。

细节处理

  • 我们没有浪费额外的 ID 空间,计算时使用减一操作,确保仅预留实际所需的 ID;
  • 实际运行中,我们并不担心资源耗尽,因为系统保留空间充足,但依然保持严谨划分,提升维护性;
  • 预留 ID 的目的是逻辑上的一致性,而非性能优化,主要目的是提升结构可读性和系统扩展性。

设计结果总结

通过这种预留方式,我们实现了以下目标:

  • 保证控制器与“英雄”角色之间的映射稳定可靠;
  • 避免运行时复杂的控制器查找逻辑;
  • 实现模块化、易扩展的系统设计;
  • 明确区分系统中不同类型大脑的 ID 范围。

这一机制为后续添加 AI、僵尸大脑、NPC 等提供了清晰的 ID 管理基础,是引擎中一个重要的系统设计点。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

game_world_mode.cpp:使 AddPlayer 接受 BrainID 并返回 void

我们对玩家添加流程进行了调整,使其更清晰地遵守大脑 ID 的预留机制,并与控制器索引直接关联。以下是该过程的详细中文总结:


添加玩家时传入大脑 ID 而非返回

1. 控制器索引与大脑 ID 的映射

我们不再通过添加玩家函数返回新的大脑 ID,而是由调用者直接传入一个指定的大脑 ID。这个大脑 ID 是通过如下方式计算得到的:

brain_id = reserved_brain_id_first_hero + controller_index

这样,每个控制器索引就唯一对应一个预留的大脑 ID。

2. 修改玩家添加逻辑
  • 添加玩家时,改为接收大脑 ID 作为输入参数;
  • 原本在内部通过 add_brain() 创建大脑并返回 ID 的逻辑被移除;
  • add_brain() 不再用于为玩家分配 ID,因为我们需要确保这些大脑 ID 处于预留区间内,不是自动分配的;
  • 这也意味着控制器关联的大脑是手动管理的,和其他自动增长分配的 AI 大脑逻辑区分开来。

防止意外使用预留的大脑 ID 区间

我们进一步确保大脑和实体初始化时不会错误地使用预留区间:

1. 管理空闲大脑 ID 的起始位置

初始化时设定:

first_free_brain_id = reserved_brain_id_first_free

这确保所有自动分配的大脑 ID 都会从未保留的区间开始,避免与控制器相关的大脑 ID 冲突。

2. 统一使用同一存储结构

虽然实体和大脑目前使用的是相同的存储结构(或同一个数组),我们暂时不进行分离,但通过逻辑约束(设定分配起点),也能保证不会干扰预留区间。

3. 实体初始化也遵守起点限制
  • 无论是实体 ID 还是与其相关的大脑 ID;
  • 初始化逻辑都从 reserved_brain_id_first_free 开始;
  • 这也意味着在内存分配或数据初始化中,不会占用用于控制器的那些“英雄”大脑 ID。

最终效果与意义

通过以上设计与调整,我们实现了以下几点:

  • 控制器对应的大脑 ID 是手动绑定、显式映射的,提升清晰性与可控性;
  • 预留区间被严格隔离,不会被其他逻辑误用;
  • 自动分配的 AI 或非玩家角色使用的 ID 与控制器无冲突;
  • 玩家添加逻辑变得更具扩展性——比如支持在运行时修改绑定关系、添加测试大脑等;
  • 后续如需支持非控制器驱动的角色(如僵尸等),可以统一使用未预留区间,方便代码模块划分。

整体而言,这是一种通过结构性手动管理 ID 的做法,确保大脑与控制器的一一对应,并防止逻辑混乱或资源错用,同时也为系统扩展打下了良好的基础。
在这里插入图片描述

在这里插入图片描述

运行游戏,发现可以改变英雄的朝向

我们现在实际上需要创建一个与特定控制器配套的内容。虽然我们之前所做的工作主要是为了让整体结构更清晰,但它并不能直接解决 ddP(假定为某种系统或模块)的问题。不过从某种角度来说,它确实起到了一定的帮助作用——它让我们能够确认当前确实已经连接到了正确的 hero(主角角色或对象)。

我们可以通过更改 hero 的朝向方向来验证连接是否正确,因为朝向的改变并不依赖于加速度。换句话说,即使加速度机制还没有重新启用,我们依然能够控制角色的方向,从而确认控制连接是正常的。

不过我们还没有把加速度的机制加回来。为了将加速度恢复,我们需要把它当作一个“累积量”来处理。也就是说,我们要把加速度的变化不断叠加上去,而不是一次性地设置某个值。只有这样,运动系统才能正确地响应控制器输入,实现更自然的加速与移动行为。我们下一步的目标就是实现这种加速度的累加处理逻辑。

game_world_mode.cpp:从 controlled_hero 中移除 BrainID、ddP、dSword、dZ、Exited 和 DebugSpawn,改由 UpdateAndRenderWorld 直接初始化

我们现在在整理结构,发现有些原本放在 ConHero(可能是某个控制模块或临时中介结构)里的东西其实不需要留在那里,比如 DDP(可能是某种加速度或输入向量)现在已经移动到实体(entity)中,不再需要绑定在“连接的 hero”上了。因此我们可以将一些数据从 ConHero 中剥离出来,它们本来也不应该属于那里。

接着我们检查 controlled hero 结构,发现其中的一些字段其实是多余的,比如 brain ID 是可以通过映射隐式获取的,不需要显式保存。DDP、sword(武器数据)以及 DZ(不明确,可能是方向或区域信息)等都可以移除。我们评估了一下,可能连 exiteddebug spawn 也不需要,不过 recenter timer 可能还要保留,其他字段看起来都可以删掉或清零处理。

这样一来结构就被大大简化了。原本我们有一堆不必要的临时存储变量,现在都被清除掉了,数据开始分布在更合理的位置,系统变得更整洁。sword 虽然暂时保留了,但含义已经不一样了,等整理完代码后需要重命名。

在初始化阶段,这些字段之前就已经初始化过了,因此我们可以沿用已有的初始化流程。如果像 exiteddebug spawn 最终不需要使用,我们就统一初始化为 0,保持一致性。

所有原来用 ConHero.DDP 的地方都改成 entity.DDP,这可以通过替换操作快速完成。之后我们考虑是否可以直接构造一个新的 DDP 对象,保存到 entity 中,再在之后的步骤中赋值。

在整理过程中还发现了个小插曲:原以为是在某处更新 DDP 后设置到 entity 上,但其实代码直接修改了 velocity(速度),这有些出乎预料。这种方式虽然可以工作,但并不是我们真正希望的实现方式。我们更倾向于通过 DDP 来控制速度变化,实现一个加速过程,而不是直接设置速度。

由此可见,还有很多地方需要清理和重构,代码逻辑也有待理顺。有些变量,比如 brain ID,虽然我们原本想移除,但实际运行中可能还是需要某种标识来判断是否已经赋值,因此暂时保留还是有意义的。

总之,我们通过本轮整理实现了结构瘦身、职责划分更合理,并进一步明确了后续重构的方向和要调整的内容。整体架构正在朝着更简洁和模块化的方向前进。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

game.h:将 BrainID 加回 controlled_hero

我们现在在整理 add_player 相关的逻辑。先把一些旧的、已经不再需要的残留代码清理掉。例如,之前设置 debug_spawn 为 true 的部分,我们暂时保留,稍后再处理。

关于 con_hero 中的 D_sword,我们已经重命名或重构为更通用的 D_sword,不再需要通过复杂结构间接访问。虽然代码中有些修改位置看不到,但已经完成了调整。

然后我们继续处理 exited 的逻辑。我们检查了它的处理方式,发现当检测到 exited 为 true 时,我们的处理逻辑是删除实体并设置 brain ID,这也是我们真正想要实现的逻辑,所以这部分可以保留。

对于 DZ 和跳跃(jumping)相关的字段,我们认为跳跃机制已经不应该再放在这块了,因此也将其移除。现在结构基本已经精简完毕,所有逻辑都回归到通过 brain 驱动处理的方式。

接下来我们要做的,是让 hero 实体重新“动起来”。如果我们直接设置 DDP,那么我们只需要调用一次 move_entity,就能让角色移动。但是我们对之前系统直接设置速度而不是通过 DDP 实现加速行为仍感到意外。这虽然能工作,但并不是我们理想中的做法。

目前系统在处理实体类型(entity_type)时使用了 switch 结构,而大部分与 hero 相关的处理逻辑已经转移到了 hero 结构体中。我们观察到,现有的 movement 代码看起来需要进一步分层处理:也就是说,不同的移动方式(如固定式移动 vs 跳跃式移动)需要通过更清晰的逻辑阶段来区分。

我们的目标是建立一种共享的移动方式框架:将“角色站立原地”与“角色处于跳跃或弹跳状态”的概念区分开来,并为每种移动状态提供适当的处理逻辑。通过这种方式,我们可以灵活扩展新的移动模式,并保持代码结构清晰。

总结来说,这一阶段我们完成了对旧逻辑的清理和数据结构的简化,理清了 DDP 和实体移动的关系,明确了后续要实现的“分阶段移动逻辑”设计方向。下一步将是设计与实现支持多种移动模式的统一接口与逻辑框架。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

game_world_mode.cpp:在 UpdateAndRenderWorld 中用 #if 0 注释掉 Planted 相关代码

我们现在的目标是进一步理清和拆分“移动模式”中的逻辑,特别是将不同状态下的移动行为做更细致的区分。

首先我们意识到,当前的“planted”状态(类似于静止或固定状态)中包含了一些并不适合这个状态的逻辑。具体来说,代码中存在一段逻辑,假设当前实体正在“朝向”另一个目标靠近,或者说与另一个实体存在交互预期——而这是对“planted”这种通用静止状态不合理的假设。并不是所有静止状态的实体都在准备接近某个目标,所以这部分逻辑需要被移除或者移到更合适的状态处理分支中。

相比之下,“hopping”模式(跳跃状态)则实现得比较清晰合理。在跳跃状态中,实体只需要从当前位置跳到目标位置,不涉及其他实体,也不需要了解环境中的其他交互目标。这种处理逻辑相对简单纯粹,正是我们期望的状态行为模型。

因此,我们决定将“planted”内部那些混杂进来的错误逻辑先临时移除,因为“planted”本身并不是一个特别复杂或依赖外部状态的行为状态。我们可以先将它简化为最基础的静止处理,然后再根据实际需求逐步添加真正需要的逻辑。

接下来的计划是逐个处理这些移动模式状态,因为整个系统里相关的代码量比较大,需要我们一步步地梳理和重构,使各个状态逻辑变得更加清晰、职责单一,并且彼此之间互不干扰。

最后我们注意到系统正在报错或者发出某种警告,我们将在清理完这些状态逻辑之后进一步检查这个问题的具体原因。当前的重点还是集中在结构整理和行为分离上,以确保后续功能扩展的可维护性和逻辑清晰度。
在这里插入图片描述

调试器:调查如何在没有 CameFrom 的情况下进入 Hopping 模式

我们当前遇到的问题是实体为 null,具体表现为一个处于“hopping”状态的实体试图访问 came_from 时发现这个字段为空,从而触发了错误。

我们先确认了正在更新的实体类型是 brain_hero,也就是一个英雄单位的本体部分(body),它正处于“hopping”模式,理论上在该模式下必须有一个合法的 came_from 引用才行。但现在 came_from 是空的,因此出错。

于是我们回溯调查是如何进入“hopping”模式的。根据逻辑分析,进入这个状态的前提是必须设置 came_from,也就是说,如果能进入“hopping”,那么 came_from 应该已经初始化了。但显然现在不是这种情况,所以我们推测初始化过程中存在遗漏。

接下来我们查看 add_player 过程,发现虽然设定了 occupying(当前占据的位置),但并未正确设置 came_from。理论上这两个字段都应该在初始化阶段完成赋值。可能是我们在 transactional_occupy(事务式占位)过程中只设置了 occupying 而未同步设置 came_from,导致逻辑不完整。

我们检查了打包(pack)逻辑,确认在打包实体数据时,occupying 确实被设置并正确传入,但 came_from 没有参与打包,这意味着它没有被传入下一帧使用的数据中。于是我们继续检查解包(unpack)过程,发现由于初始化过程的顺序问题,解包前英雄实体尚未真正被添加进系统,所以当前帧就无法解包成功,只能跳过处理。这种跳过意味着该实体在首次创建时不会有 came_from 值,直到下一帧系统重新更新并正确赋值后,才会恢复正常。

我们还检查了 headbody 的读取逻辑,希望确认这些指针结构是否指向了正确的数据,以排除结构性错误的可能。当前观察来看 occupying 的设置在打包中是正确的,只是初始化顺序和 came_from 的同步被遗漏。

总结当前问题:

  1. 错误表现:实体进入 hopping 状态时访问空的 came_from 引用,导致崩溃。
  2. 原因分析:初始化阶段未正确设置 came_from,而代码默认其存在。
  3. 根本原因:在 add_playertransactional_occupy 过程中只设置了 occupying,没有同步设置 came_from
  4. 附加问题:实体首次加入时尚未完成打包和解包流程,因此字段缺失。
  5. 解决方向:需要确保在初始化 occupying 的同时也设置 came_from,并检查初始化顺序确保 unpack 时数据已经准备就绪。

接下来的步骤是修复初始化过程中的缺口,确保所有必要的字段都在实体初始创建时正确设置,防止出现空引用问题。

game_sim_region.cpp:重新启用并修复 ConnectEntityPointers

我们发现目前的系统在解包(unpack)过程中存在问题,导致某些关键字段(如 occupyingcame_from)的值在实体恢复时丢失,从而引发运行时错误。

具体表现为实体的 occupying 字段在解包后是空的(null),而这本应该在实体打包和解包流程中被保留。我们怀疑是解包代码出了问题,于是开始逐步排查。

我们意识到当前的打包与解包逻辑尚未系统化,仍处于一种“过渡状态”,这一点增加了排查和维护的难度,流程不够清晰,变量间传递关系较为混乱。

我们将排查重点放在了解包指针连接阶段 connect_entity_pointers 函数中。该函数负责解包后将实体之间的引用(如 occupyingcame_from)重新建立。结果发现了关键问题:部分解包操作被注释掉或未启用,这是造成 occupying 丢失的根本原因。

具体地说,解包逻辑中处理 occupyingcame_from 的那段代码被标注为“需要重新启用(reenable)”,也就是说之前我们暂时关闭了那部分逻辑,导致这两个字段没有被还原。这也解释了为什么 occupying 是空的,从而导致后续的逻辑无法正常运行(例如进入 hopping 状态时引用不到 came_from)。

目前修复策略明确:只需重新启用解包流程中 occupyingcame_from 字段的还原逻辑即可。这将确保实体在从持久化状态还原时拥有完整的上下文信息。

额外补充:

  • 我们确认 head 字段在这部分逻辑中并不需要,因此可以忽略;
  • 解包时正确标记这些字段为“已使用(used)”是合理的,有助于后续追踪和验证;
  • 当前临时处理虽然略显混乱,但计划在后续版本中使打包与解包流程更系统化、统一化。

总结:

  1. 问题表现:实体在解包后 occupyingcame_from 为 null。
  2. 核心原因:相关解包逻辑被人为注释或未启用,导致字段未被恢复。
  3. 修复方法:重新启用 connect_entity_pointers 中的解包逻辑,确保引用正确建立。
  4. 后续改进方向:系统化打包解包机制,避免中间状态逻辑混乱带来的问题。

已经启用过了

在这里插入图片描述

在这里插入图片描述

调试器:在 UpdateAndRenderWorld 中断点,调查 Occupying 被设置为 0 的原因

我们发现当前在实体解包时,occupying 字段依然会被错误地设为 0(空引用),这显然是不正确的。因为在逻辑上,只要实体正在“占据”某个位置或空间,occupying 就不应该为 0,否则后续的空间查询和行为逻辑将无法正确工作。

我们首先怀疑是解包(unpack)过程中的指针恢复机制有问题。通过进一步检查,我们确认了解包逻辑中:

  • 在加载(load)过程中,确实有检查 occupying 的索引;
  • 如果索引存在,理论上应该可以顺利获取对应实体引用;
  • 但结果却发现,字段依然变成了空指针,说明这个过程出了问题。

为此我们采取了以下操作:

  1. 设置断点:我们在加载 occupying 的过程中设置了断点,准备追踪实际运行时发生了什么。
  2. 回顾引用加载流程:我们检查了低层的遍历引用(low traversal reference)和设置实体引用的相关代码,逻辑上看是合理的,若存在有效索引就应该能拿到实体。
  3. 发现异常行为:通过运行调试,我们观察到之前实体确实处于 occupied 状态——在图形界面上能看到它变蓝了,意味着 occupying 被设置过,但很快又被重置成了空。

这种现象暗示我们可能存在以下问题之一:

  • 解包过程中某一逻辑错误地清空了 occupying 字段;
  • 或者实体在某个时机被不正确地重新初始化,覆盖了原来的值;
  • 也可能是 packing 或 unpacking 中状态重建顺序不正确,导致数据被提前清除或未能及时恢复。

综上我们确认:

  • occupying 字段不应被设为 0;
  • 解包逻辑中有潜在错误或遗漏;
  • 即便逻辑“看起来”合理,实际执行中还是会出错,因此需要实际调试数据验证。

下一步我们将:

  • 检查是否在 unpack 之后的某个阶段意外清除了 occupying 字段;
  • 分析是否是某处默认初始化覆盖了已有数据;
  • 进一步完善字段恢复机制,保证实体在加载后能正确恢复其所占位置和状态。

这个问题虽然技术上细节繁杂,但它非常关键,因为正确的实体状态是整个行为逻辑和场景交互的基础。我们会持续排查并修正这部分问题,确保实体系统稳定可靠。

game_world_mode.cpp:让 AddPlayer 把 Body 和 Head 放入正确的 BrainSlot

我们遇到的问题是由于实体在初始化时被放入了错误的槽位(slot),导致程序在运行时出现崩溃。我们一开始以为槽位分配是正确的,但事实证明实体被错误地放进了错误的位置。

更具体来说,我们本应将某个对象放入特定的槽位,比如将“头脑体”放入一个槽,“实体体”放入另一个槽。但实际运行中这两个被互换了位置,产生了逻辑错误,也造成了程序崩溃。现在我们已经识别并修复了这个槽位错误,程序恢复了正常运行。

为了防止类似问题再次发生,我们计划提高该机制的鲁棒性,也就是增强容错和可维护性。当前的做法中,C/C++ 本身的类型系统并不容易支持严格的槽位校验或自动槽位匹配,因此需要引入一种更加系统化的结构,减少人为出错的机会。

我们准备设计一种通用结构,也就是“模板式实体结构定义”。这里所说的模板,不是指 C++ 的 template 编程,而是指一种统一的实体布局描述格式,它可以明确:

  • 每种类型的实体应该在哪个槽位中;
  • 每个槽位接受什么类型的对象;
  • 初始化过程是否成功完成了匹配;
  • 是否遗漏或放错了某个组件。

通过这种方式,我们希望做到以下几点:

  1. 明确的槽位描述与映射规则:将各个部分结构化分配,防止互相搞错;
  2. 解耦槽位和实体逻辑:不让具体实体类型与槽位强耦合,减少更改时出错;
  3. 增加初始化时的验证:若实体放错槽位,能在初始化阶段就报错而不是运行时崩溃;
  4. 方便后期扩展和调试:尤其在添加新实体或复杂逻辑时,模板结构更容易保持正确性。

总之,我们的问题出在实体初始化的槽位匹配上。虽然 bug 本身较为简单(一个赋值错误),但暴露了初始化机制的不严谨。我们将通过引入模板化结构来系统性地解决这个问题,从根本上降低类似 bug 出现的可能性,同时提升系统的稳定性和维护效率。

game_entity.h:用 #define BrainSlotFor 用指针算术来命名插槽

我们希望能自动、可靠地根据结构体成员的位置计算出它在“脑槽位(brain slot)”数组中的索引。由于 C/C++ 本身并没有提供直接的机制来完成这种结构体成员位置到数组索引的映射,我们采用了手动计算偏移量的方法,并为此编写了一个宏。

我们定义了一个宏,例如:

BRAIN_SLOT_FOR(类型, 成员名)

这个宏的用途是:给定一个结构体类型(如 brain_hero_parts)和其内部某个成员(如 bodyhead),自动算出该成员在联合体数组 entity_pointers[] 中所对应的索引位置。

实现这个宏的原理如下:

  1. 结构体成员在内存中的偏移
    假设结构体从地址 0 开始,那么可以通过 &(((类型*)0)->成员) 来取得该成员相对于结构体开头的偏移地址。这是一个常见的 C 语言技巧,用来获取成员相对位置而不需要实际实例化结构体。

  2. 将偏移转换为数组索引
    由于这个结构体是一个联合体(union),其内存布局与数组是重叠的。我们把这个偏移地址强制转换成指向 entity** 的指针,然后与同样类型的 0 地址指针进行相减。这种指针相减会返回两个元素之间相隔多少个指针长度(而不是字节),正好是我们想要的槽位索引。

    举例来说:

    #define BRAIN_SLOT_FOR(Type, Member) \((int)(((entity **) &(((Type *)0)->Member)) - (entity **)0))
    
  3. 用途
    这样做的最大好处是,我们再也不需要手动指定 body 是槽位 0,head 是槽位 1 这样的数字,也不容易搞错插槽顺序。只要结构体布局一致,索引就能自动计算出来,代码更安全、可维护性更高。

  4. 验证与测试
    实际运行后,我们验证了该宏能正确计算出槽位索引,说明其计算逻辑与内存布局一致。虽然写法不太优雅,但这种宏让我们从根本上避免了硬编码槽位数字的繁琐与错误。

总结:

  • 实现了一个用于自动计算“实体指针插槽索引”的宏;
  • 基于结构体成员偏移计算原理,利用 C 语言中指针操作的特性;
  • 显著提高了代码的鲁棒性与可维护性;
  • 将来可用于所有实体脑部件的插槽索引计算,无需手动指定槽号。

虽然看起来复杂,但这套方法本质上是对 C/C++ 缺少反射机制的一种手动弥补,非常适合这种结构体覆盖数组的联合体设计模式。

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

虽然可以编译过不过clangd 会提示错误

在这里插入图片描述

#define BrainSlotFor(type, member) (&(((type*)0)->member) - (entity**)0)
#define BrainSlotFor(type, member) (offsetof(type, member) / sizeof(entity*)) 

这两条宏的作用本质上都是在计算结构体中某个成员在一个指针数组(比如 entity* 数组)中的索引位置,但它们实现方式不同,第一种可能触发未定义行为,第二种是标准做法


第二种(推荐做法)详解

#define BrainSlotFor(type, member) (offsetof(type, member) / sizeof(entity*))

核心逻辑

  1. offsetof(type, member)

    • 返回 membertype 结构体中距离开头的字节偏移

    • 例如:

      struct A { entity* body; entity* head; };
      offsetof(A, head);  // 假设结果是 8(因为 head 在 body 后面)
      
  2. 除以 sizeof(entity*)

    • 将字节偏移转为“指针单位的偏移”,即在 entity* 数组中的索引位置

安全、标准、跨平台,推荐使用


第一种(不安全做法)详解

#define BrainSlotFor(type, member) (&(((type*)0)->member) - (entity**)0)

它在干什么?

  1. (type*)0:创建一个空指针,假装这是指向 type 类型结构的指针。
  2. ((type*)0)->member:访问这个空结构体指针的某个成员(但不真正解引用,只是拿地址)。
  3. &(((type*)0)->member):取得这个成员的地址(其实就是成员相对于结构体起始地址的偏移)。
  4. 强制转成 (entity**) 类型,然后:
  5. (entity**)0 相减,结果就是 member 在 entity 数组中的索引*。

存在的问题

  • 你正在对 null 指针做指针运算(取地址和减法)
  • 在 C/C++ 中,对 null 指针做地址运算是未定义行为
  • 有些编译器会容忍它,但标准不允许,Clangd 会报错或警告。

举个例子帮助理解

假设结构如下:

typedef struct {entity* body; // 假设偏移 0entity* head; // 假设偏移 8
} BrainHeroParts;

这两个宏计算的目标是:

  • BrainSlotFor(BrainHeroParts, body) 应该是 0
  • BrainSlotFor(BrainHeroParts, head) 应该是 1

使用推荐宏:

offsetof(BrainHeroParts, head) == 8
sizeof(entity*) == 8(假设 64 位平台)
8 / 8 == 1

总结对比表

项目第一种宏(❌不推荐)第二种宏(✅推荐)
实现方式利用空指针取地址再做减法使用 offsetof() 宏再除以指针大小
是否标准行为❌ 否,触发未定义行为✅ 是,完全符合 C 标准
编译器兼容性⚠️ 可能警告、报错✅ 所有标准编译器支持
可读性❌ 难懂✅ 易于理解和维护
结构体布局依赖✅ 是✅ 是
适用于计算结构体指针槽位✅ 是✅ 是

推荐

总是使用这一种:

#define BrainSlotFor(type, member) (offsetof(type, member) / sizeof(entity*))

它简单、规范、安全,编译器不会报错或警告,也便于未来维护和理解。

关于编译器厂商让程序员生活困难的吐槽

我们在讨论一个语言本身缺乏支持、但在系统底层开发中又经常需要的操作:为结构体中某些指针成员命名槽位(slot)并获得其索引值,这在处理 entity* 指针数组映射时非常常见。然而,由于 C/C++ 本身的局限性和标准约束,这类操作并没有直接的语法或机制支持,只能通过技巧手动实现,比如通过偏移计算宏来解决。

在实现过程中,遇到的问题不完全来自于语言本身,而是编译器厂商出于“安全性”或“优化”的考虑,限制了开发者绕过语言缺陷的能力。比如:

  • 编译器可能会对某些运算(如基于 null 指针做偏移)发出警告或报错;
  • 有些优化器会“聪明过头”,假设内存块不来自同一内存池,从而生成不合理的优化代码;
  • 即便开发者知道某段逻辑在语义上完全安全,编译器仍然拒绝通过编译或发出误导性提示。

这些情况导致我们在解决语言设计问题时,不仅要绕过语言本身的局限,还要应对工具链人为增加的障碍。

我们认为语言应该原生支持这种行为:允许开发者显式、安全地获取结构体中成员在数组中应占位置的索引,这是非常直观和基本的能力。正因为语言缺乏这一点,我们只能通过复杂且不够安全的手段(如通过 null 指针计算偏移)去模拟实现。而一旦编译器对这些方式持否定态度,就会使得问题复杂化、调试和维护变得困难,开发效率也会下降。

虽然目前这种写法在技术上基本是安全的(因为结构体的布局在编译期是确定的,成员指针计算只是静态分析),但仍希望未来语言和工具链可以更合理地支持这类需求,减少这种重复且不必要的“对抗性开发”。目前为了功能实现,不得不继续使用这些技巧来绕过语言和编译器的限制。

game_entity.h:引入 BrainSlotFor_ 来返回 brain_slot

我们希望将槽位(slot)的计算方式变得更加规范、正式和清晰,因此计划将原本基于宏的做法升级为一个更有类型系统支持的内联函数(inline function)。这样不仅可以提高代码的可读性和类型安全,还能减少维护负担,避免宏展开带来的隐晦错误。

原先我们是通过宏去做结构体成员在数组中的偏移计算,把这种计算看作是“槽位编号”的获取过程,例如:

#define BrainSlotFor(type, member) (offsetof(type, member) / sizeof(entity*))

这种方式虽然有效,但缺乏类型约束,同时不太便于调试和工具支持。因此我们决定引入一个正式的函数 BrainSlotFor(...),它接受偏移量或打包值,并返回一个结果结构。这使得我们可以:

  1. 避免直接与索引做交互,而是处理更清晰的语义值;
  2. 在较老的 C 版本中规避语法限制,例如不支持字段初始化器的情况;
  3. 使槽位管理成为更系统性的机制,可扩展并清晰维护;
  4. 实现槽位结构更适合集成到整体系统逻辑中,尤其是打包(packing)和解包(unpacking)流程。

通过这种封装,我们可以用如下方式来管理槽位:

BrainSlot slot = BrainSlotFor(offset);

再也不需要手动写 index 数字或处理偏移除法这些底层细节,使得实体结构体与槽位的映射更清晰、更安全。这个函数化的设计会让后续的槽位访问、实体更新和调试过程都更加稳健。最终目的是使实体系统更系统化,槽位编号和成员引用之间实现清晰、安全的桥接。
在这里插入图片描述

在这里插入图片描述

问答环节

“生产环境”断言会怎么处理?

在发布模式(release mode)下,断言(assert)不会被编译进最终程序中,因此在实际的生产环境中,这些断言是不存在的。也就是说,断言只存在于开发和调试阶段,用于捕捉逻辑错误、状态不一致或预期失败等问题。一旦切换到发布模式,断言代码会被编译器移除,以提升性能和减少最终可执行文件的体积。

这是因为:

  • 发布版本通常追求运行时效率,去除断言能减少不必要的分支判断;
  • 发布环境下不希望中断用户使用,即使出现非致命的状态问题;
  • 如果存在问题,应通过日志、回退机制等方式处理,而不是像调试阶段一样中止程序;

因此在发布版本中:

  1. 所有通过 assert(...) 形式的断言都会被自动移除;
  2. 如果希望保留关键检查,可使用自定义的运行时检查函数,并配合日志输出或失败上报机制;
  3. 对于不可容忍的异常场景,也可以替代使用更严谨的错误处理逻辑,而非依赖断言;

总结来说,在调试模式中断言用于开发时捕错;在发布模式中它们完全不会存在于编译结果中,因而不会影响生产环境行为或性能。

这个脑系统能否实现两个角色共同搬运被砍倒的树,由两人的大脑共同控制树的移动?

我们在讨论“两个角色一起抬一棵倒下的树”这种行为在当前的 brain 系统中是否可行。技术上来说,确实可以使用 brain 系统来实现这种机制,但我们认为这可能不是最合理或最合适的实现方式。

原因如下:

  • brain 系统本质上是角色行为的控制单元,如果我们将“抬树”这种行为逻辑直接集成到 brain 中,那么当抬树的双方角色种类不同时(比如一个是英雄,另一个是怪物),就需要为每种组合分别编写一套 brain 类型,比如 HeroTreeCarrier、MonsterTreeCarrier 等。这会导致系统的扩展性变差,组合爆炸,代码维护复杂。

  • 我们更倾向于在 brain 系统中实现通用行为逻辑,而不是与具体交互对象(比如树)绑定得过紧。更理想的方式可能是:在 brain 中包含“是否正在参与抬树”这一状态变量或行为节点,当检测到自己是抬树的一方时,进入一套特殊的行为处理流程,比如同步移动、约束姿势等。

  • 抬树的行为本质上是一个多人协作任务,所以还可能引入另一个系统(比如 task/task assignment 系统),用来协调多个 entity 对一个目标(比如倒下的树)的协作。这种任务系统可以与 brain 解耦,通过共享状态和目标来实现互动逻辑。

  • 当前 hammer hero 是单人游戏,暂时没有复杂协作行为的需求,因此我们还没有设计这一部分,未来如果扩展为多人或群体 AI 时,可能会引入更完善的协作机制。

总结:

虽然可以用 brain 系统实现两个角色共同抬树,但在设计上更推荐以 状态驱动 + 协作任务系统 的方式处理这类多人协作行为,这样可以更好地解耦角色逻辑和协作机制,提高系统的灵活性和可维护性。

尽量别让大脑变得自我意识太强,我们可不能让“短腿”统治世界

我们在讨论脑系统的开发时,开了个玩笑,说不要让这个系统具备“自我意识”,否则像 Stumpy 这样的角色可能就要统治世界了。不过真正需要担心的不是 Stumpy,而是 in sohbat——因为 in sohbat 比我们现在在 Game Hero 中实现的任何脑系统都要先进得多。

in sohbat 的复杂程度远超当前游戏中所用的 brain 系统,如果世界真的要被某个系统统治,那一定是它,而不是我们目前实验性质的 AI 控制模块。

总结如下:

  • Stumpy 只是个调侃对象,用来轻松化话题。
  • 真正技术上值得警惕的是 in sohbat,它代表的是更高级别的行为系统或 AI 架构。
  • 目前 Game Hero 中的 brain 系统还比较初级,更多是专注在角色控制和行为逻辑上,离真正“自我意识”还很遥远。
  • 背后也反映出一种对 AI 复杂性潜力的认知,提醒我们谨慎地看待行为控制系统的边界和发展。

关于脑类型,为什么不使用枚举来做数组索引?

我们在设计 brain 系统时需要处理多个 brain 类型的数组索引,有人可能会疑惑为什么不直接用 enum 来表示这些索引。我们之所以没有采用 enum,主要有以下几个原因:


当前实现方式的优势:

  1. 更少输入、更直观:
    如果使用 enum,我们每次访问都要写 brain_slot[HeroBrainSlot_Body] 之类的形式。而当前实现方式可以直接通过成员名字使用结构体偏移更快速、清晰,避免繁琐的 enum 索引。

  2. 减少混用风险:
    每种 brain(例如 HeroBrain、MonsterBrain)都有自己不同的 slot(例如头部、身体、武器等),如果都用全局的 enum,很容易在不同类型之间不小心混用,比如错误地将 Hero 的 body slot 用在 Monster 上。

  3. 作用域清晰、风险更低:
    当前的做法天然有“类型限定”的作用,例如通过 BrainSlotFor(HeroParts, Body) 这种形式,我们可以清晰地知道这是 HeroParts 的 Body,不容易搞错。


enum 的潜在问题:

  • 虽然 enum 本身类型安全,但在多种不同 brain 类型共存的情况下,容易混用不同类型的枚举值。
  • 要保证前缀(如 HeroBodySlot_MonsterBodySlot_)一直保持一致,维护成本较高。
  • 数组索引错误属于“静默错误”,编译器不一定能检测出来,一旦写错索引,运行时才能发现。

总结我们不使用 enum 的思考逻辑:

  • 枚举并不会减少构造时的出错可能,反而增加了维护工作。
  • 多种 brain 类型混用时,更容易出错。
  • 目前通过偏移方式自动获取 slot index,不仅方便,也能更安全地匹配正确结构中的字段。
  • 这种方法对作用域控制更自然,避免了不同模块之间 enum 名字污染的问题。

尽管 enum 看起来是更“标准”的方式,但在这种多类型 brain 并存、结构频繁变化的上下文中,我们选择了一个更贴近底层内存布局、出错率更低的做法,提升了代码的鲁棒性与可维护性。

你会如何让游戏实体支持mod?

我们在讨论让游戏实体具备可模组化(moddable)特性的实现方式时,首先需要明确“可模组化”具体指的是什么。这个词本身并没有明确的定义,也没有固定的实现标准。是否可模组,取决于我们希望允许模组作者对游戏实体做出哪些层级的修改。

可模组的目标可能包括以下几种层次:

  1. 资源替换:最简单的形式是允许模组作者更换实体的资源,比如替换贴图、音效或动画文件。这种方式通常不涉及逻辑变更,仅影响视觉或听觉表现。

  2. 组合已有行为:进一步的模组支持可能允许模组作者使用已有的行为模块或组件,组合出新的实体行为。比如使用现有的移动、攻击、交互等模块,配置参数后形成新的单位或NPC。这种方式仍旧基于已有逻辑,关键在于行为配置的灵活性。

  3. 编写全新行为:更高级的需求可能包括允许模组作者自己写新的逻辑行为,这就涉及到脚本语言或插件系统的支持。这种情况需要引擎设计时为外部代码预留执行环境,并设定好安全边界与调用规范。

因此,实现可模组化的关键在于我们希望开放的修改深度。如果只是视觉替换,我们可以设计资源路径结构并允许覆盖。如果要支持行为组合,就要将逻辑模块化、数据驱动,并暴露足够的配置接口。如果要支持自定义逻辑,就要设计良好的脚本或插件接口机制,并考虑运行时性能与安全性。

总之,可模组化并不是一个简单的属性,而是一系列设计决策的集合。需要根据游戏的目标、模组作者的预期以及我们能接受的维护成本来综合考量。

我在想定义全新的实体类型

如果想要支持添加全新的实体类型,单纯通过已有的资源或配置是无法满足的,这时候就需要考虑动态加载外部代码,比如加载DLL文件。因为只有加载DLL或类似的二进制模块,才能真正引入新的、完全不同的实体行为和逻辑。

另一种可行方案是使用脚本语言,让模组可以通过脚本编写新的行为逻辑,但这需要游戏引擎额外集成脚本解释器,开发难度大且是一个独立的庞大工程。

因此,如果选择不使用脚本语言,加载DLL是一种比较现实的方案。加载DLL后,需要设计好行为的调用接口和数据传输机制,也就是如何“封装”和“桥接”这些外部行为代码,让它们能够与游戏引擎现有的系统有效对接。

此外,定义新实体类型还涉及到实体的识别和管理机制。比如如何在系统中区分和挑选这些新类型的实体。可以借鉴资源管理系统的思路,将新的行为类型(比如“脑类型”)注册到一个类似资源库的系统里,配合参数进行实例化和绑定。这样在运行时通过参数配置和资源查找来创建和使用这些新的实体行为。

总结来说,支持完全新实体类型的模组功能,核心在于:

  • 允许动态加载外部代码(DLL或脚本)
  • 设计行为接口与数据桥接机制,确保外部代码能被调用
  • 制定新实体类型的注册和实例化机制,类似于资源管理体系
  • 解决新实体的识别和调度问题,使其能被游戏逻辑正常管理

这些设计能保证模组作者不仅能替换资源和组合行为,还能引入全新的实体类型和逻辑,实现更深度的扩展。

有没有办法减轻dll安全问题?

对于加载DLL带来的安全问题,理论上是可以通过沙箱技术来限制DLL的权限,防止它访问系统的敏感资源,类似Chrome浏览器对插件的处理方式。但实际上,作为游戏开发者,我们不太需要过于担心这类安全问题。

因为操作系统本身的设计初衷就是要保证应用程序不能随意对系统造成危害,这就是操作系统提供保护机制的根本目的。游戏运行时不应该有权限危害用户的系统环境,操作系统应该负责保障这一点。如果出现安全问题,更多是操作系统或平台厂商(例如微软)没有做好安全保护的责任,而不是游戏开发者的责任。

所以,担心DLL安全问题,实际上是平台和操作系统的问题,游戏开发者不用承担这种安全防护的职责。游戏开发者更应该关注的是游戏和服务器之间的通信安全,因为那才是游戏运行中可能存在被攻击的环节。

总结来说:

  • DLL安全问题可以通过沙箱技术来缓解,但这不是游戏开发者的主要职责。
  • 操作系统设计本应防止程序对系统造成损害,操作系统厂商应负责安全保护。
  • 游戏开发者不应该为DLL的安全问题过度担忧。
  • 游戏开发者主要需要关注的安全问题是网络通信和服务器端安全。
  • 由此来看,DLL加载的安全性问题是操作系统和平台层面的事情,而非游戏开发的核心关注点。

这种观点强调了安全责任的归属,认为游戏本身不应该被看作是潜在的安全威胁,而安全防护应当是操作系统的基本职责。

所以现在所有脑代码都写在 switch(Brain->Type) 中。你预计会有多少种脑?如果有100种,会一直放在那里吗?

代码中现在通过switch语句来区分不同的脑类型(brain type),但如果脑类型数量增多,比如达到一百种,直接在一个文件里用switch处理所有脑类型的代码显然不现实。

对此的解决方案是不会完全取消switch语句,但会把它从当前文件中移出来,放到一个专门管理脑类型的文件里,比如叫做plan_brain.cpp。这样代码结构会更清晰,维护起来也更方便。

另外,虽然脑的种类很多,但它们之间会共享大量的公共代码。也就是说,脑类型的代码不会简单地全部写在switch里,而是会调用一些通用的函数来完成具体的行为。这些共享的函数负责处理脑的共同行为逻辑,减少重复代码,使整体代码量不会太庞大。

总的来说:

  • 脑类型多了以后,不会把所有代码都写在一个switch语句里。
  • switch语句会被集中管理,放到专门的文件中,便于维护。
  • 脑类型代码会大量调用公共函数,实现行为的共享和复用。
  • 这样设计有利于代码结构清晰,便于扩展和维护。

相关文章:

  • 遨游科普:三防平板是什么?有什么功能?
  • 使用Langfuse和RAGAS,搭建高可靠RAG应用
  • AI编码代理的崛起 - AlphaEvolve与Codex的对比分析引言
  • Redis 事务与管道:原理、区别与应用实践
  • 深入理解桥接模式:解耦抽象与实现的设计艺术
  • 给你的matplotlib images添加scale Bar
  • DataX:一个开源的离线数据同步工具
  • 计算机视觉与深度学习 | Python实现EEMD-LSTM时间序列预测(完整源码和数据)
  • Predict Podcast Listening Time-(回归+特征工程+xgb)
  • 基于C语言的歌曲调性检测技术解析
  • NX二次开发——设置对象的密度(UF_MODL_set_body_density)
  • redisson分布式锁实现原理归纳总结
  • JAVA EE_HTTP
  • 仅需三张照片即可生成沉浸式3D购物体验?谷歌电商3D方案全解析
  • 信息系统项目管理师高级-软考高项案例分析备考指南(2023年案例分析)
  • 【通用智能体】Search Tools:Open Deep Research 项目实战指南
  • Ubuntu 安装 squid
  • 【MySQL】第五弹——表的CRUD进阶(三)聚合查询(上)
  • AI:人形机器人的应用场景以及商业化落地潜力分析
  • 神经网络与深度学习第六章--循环神经网络(理论)
  • 经济日报:人工智能开启太空经济新格局
  • 知名中医讲师邵学军逝世,终年51岁
  • 湖南慈利一村干部用AI生成通知并擅自发布,乡纪委立案
  • 上海国际珠宝时尚功能区未来三年如何建设?六大行动将开展
  • 高新波任西安电子科技大学校长
  • 【社论】打破“隐形高墙”,让老年人更好融入社会