游戏引擎学习第292天:实现蛇
每次VLC 读取OSD 会有bug
修复一下
回顾并计划实现一种漂浮的移动方式,并制作一个贪吃蛇
虽然不完全记得之前具体计划,但感觉是想实现一个小蛇形生物,之前一直没来得及做。我们还打算让熟悉的伙伴能漂浮移动,所以今天会继续进行一些脑部编程,确保之前设计的架构决定能够合理运作,没有隐藏问题。
项目加载运行后,可以看到怪物在跳动,英雄也在移动,一切基本正常。到目前为止,还没有实现漂浮移动方式,而我们希望让小伙伴“mini-me”能漂浮起来,这不应该太难。
另外,还想做一个小蛇生物,可能会用类似的小头来组成蛇身,具体还不确定。接下来会看一下代码,看看能不能让熟悉的伙伴围绕英雄优雅地旋转。
脑部代码里,熟悉的伙伴会判断离自己最近的英雄大致位置,然后计算出要朝哪个方向移动并尝试执行。不过目前还没真正走查这部分代码,也没给这个角色设置移动模式,所以它的移动可能马上就会被抵消。
接下来要检查一下这几个移动模式,因为移动行为是基于这些设定的。
修复之前留下的问题是0 而不是9
在 game_entity.h 中:向 entity_movement_mode 添加 MovementMode_Floating
当前代码里有“planted”(固定)和“hopping”(跳跃)两种移动模式,记得固定状态会让实体的位置锁定在所在格子上。如果有这种模式的话,可能还需要增加一种“floating”(漂浮)移动模式,专门用于让实体能够自由漂浮移动。
检查代码时发现,世界模式里还没有完全确定好移动模式相关的实现细节,需要更多关注和完善。目前代码根本没有执行我预期的那个移动操作,移动行为完全没有生效,这显然是需要修复的问题。
观察到调用了一个叫“move_entity”的函数,传入了实体的DDP(方向和加速度信息),理论上这个过程应该能让实体朝英雄方向加速移动,但目前并没有看到任何加速效果。
因此,核心问题是弄清楚为什么实体的DDP没有被正确设置,导致实体没法向英雄移动。接下来就是要分析代码,找到怎么才能让这个实体开始正确地加速朝英雄方向移动。
在 game_brain.cpp 中:使 Familiar(跟随者)向最近的 Hero(英雄)移动
首先,我们设置了一个“加速度除以平方根”的计算公式,这个公式的目的是让实体的加速度既指向最近的英雄方向,同时又具有我们所设定的加速度长度。
注意到的第一件事是:当前的加速度值可能太小了。因此在开始进行复杂调试之前,我们先把加速度调得非常大,看看是否只是数值太小导致的移动无效。测试之后发现实体(头部)还是没有移动,说明问题不在于加速度数值太低,而是这个力根本没有产生实际效果。
在确认这一点之后,接下来要做的是进入调试状态,不再盲目猜测,而是直接查看代码在执行过程中实际发生了什么。
同时还检查了 add_familiar
函数,想确保在添加这个实体的时候是否为它正确设置了“脑子”(即 AI 行为逻辑)相关的信息。从当前代码判断,看起来已经正常添加了大脑相关逻辑,因此这部分是正确的。
接下来的工作是继续通过调试去追踪这个熟悉体(familiar)为什么没有收到期望的加速度,导致它无法移动,进一步确认代码是否有遗漏或逻辑冲突。
使用调试器:进入 Type_brain_familiar,注意默认值是 Type_brain_hero
我们在查看熟悉体(familiar)脑子相关代码时,重点确认它是否正确地设置了指向英雄的向量。运行游戏时,预期会看到这个向量正确指向英雄,但实际上完全没有发生。
我们排查发现,尽管之前有设置相关标志为真,代码也遍历了实体,试图找到带有特定脑类型的实体,但结果并未生效。怀疑可能是因为英雄距离太远导致没有选中,检查发现最近英雄的距离平方竟然是0,这很不合理。
进一步调试发现,出现问题的原因是“脑类型”(brain type)存在默认值为“英雄”的情况,但实际上该默认值导致了逻辑错误。这种默认脑类型掩盖了是否真正拥有脑子的问题,导致判断错误。
因此,我们认为脑类型系统需要改进:一是引入一个“空脑类型”作为默认值,或者二是强制要求每个实体有一个合法的脑ID,这样才能确保只有真正拥有脑子的实体才会被识别。
目前还在思考如何实现这个改进,以避免类似的“脑类型默认值导致逻辑错误”的bug。
断点没有命中
在 game_brain.h 中:引入 IsType,让 Familiar 检查是否为 Hero 类型
我们计划改进脑槽类型的判断方式,不再直接检查脑槽的具体类型,而是引入一个“is type”的判断方法。这个方法会检查脑槽的类型是否与期望类型匹配,同时索引必须非零,这样可以避免之前产生的很多误判。
具体来说,我们设计了一个类似B32的“is type”标志,通过传入脑槽和类型参数,返回该脑槽是否有效且类型匹配。这个改进让类型判断更加准确,减少了之前的假阳性情况。
在调整过程中,发现会触发“最大实体速度违规”断言,这是因为加速度设置得过高。需要将加速度调回合理范围,以避免实体移动速度过快。
此外,还需要调整实体的运动逻辑,确保它能平滑减速停止,而不是出现当前那种怪异行为,比如距离过远时实体突然完全停止不动。代码也需要改进,比如在调用“移动实体”的函数时,不仅要检查加速度是否大于零,还要检查当前速度是否大于零。如果速度还在动,实体应继续移动,而不是直接停止。
总之,需要对实体的运动控制做更细致的调整,确保移动逻辑自然且合理。
运行游戏,看到 Familiar 围绕 Hero 旋转
当前这个角色没有任何阻力,所以它会不断加速,导致围绕我们做无规则的轨道运动。它不会主动减速或停止,因此几乎会一直持续这种轨道运动。轨道的形状看起来有些三角形且不规则,这是因为当角色靠近时不会加速,但远离时会加速向我们靠拢,从而产生这种轨道效应。
我们还需要为所有角色引入运动模式,确保他们的移动是“事务性”的,也就是说,移动时能够正确更新角色占据的方格位置,而不是随意改变速度。现在的问题是,我们没有更新角色所占的方格,这导致角色可以随意进入本不应该进入的方格,比如熟悉角色可以进入已经被占据或没有空间的方格,这是不合理的。
此外,还有些方格角色根本无法进入,但角色会直接跳过这些方格,这同样不符合预期。为此,需要持续更新角色所占空间,防止进入非法方格。整体来说,我们还有大量工作要做,首先是完善这些运动模式,确保移动和占据的逻辑更加合理和准确。
在 game_world_mode.cpp 中:引入 AddSnakePiece(添加蛇身段)
接下来要完成脑部逻辑,然后创建一个“蛇”角色。计划新增一个“添加蛇”功能,类似之前的“添加怪物”等功能。其实更准确地说,是“添加蛇的部分”,也就是蛇的各个片段。这个功能会复用之前放置部件的逻辑,基本上就是添加一个怪物实体,只不过这次使用的是蛇的身体部分。
身体部分会用头部替代躯干,其他部分保持一致。最重要的是,为这个角色创建一个“蛇脑”——也就是给所有蛇的片段传入同一个脑ID,使得这些蛇的片段能共享同一个脑,实现整体的控制和行为聚合。这样所有蛇的片段都属于同一个脑,方便整体管理。
现在准备查看已有代码,确认之前是否有类似的添加蛇的功能,或者从头创建该功能。总的思路是重用已有的怪物创建逻辑,但通过传入脑ID,实现一条完整的蛇由多个部分组成,且共享同一个脑。
拷贝一份Monster 内容修改
在 game_brain.h 中:引入 brain_snake(蛇)
我们定义了一个名为“脑蛇”(brain snake)的类型,配合宏定义,需要有具体的实体支持,于是定义了“蛇段”(snake segment)。蛇由多个段组成,但具体段数不确定,暂定最大段数为8。
考虑到之前用过的数组结构(array 16),在设计时怀疑是否还需要保留,因为不一定还用得到。而且不一定需要对数据进行打包(pack),可能可以简化处理。
设计中,每个蛇段会有一个段索引(segment index),这就导致脑槽(brain slot)的设计也比较复杂。脑槽可能需要支持索引形式,也就是说它不仅是一个单一的槽位,而是一个带索引的脑槽数组,用来标识蛇的具体段号。
这些想法引出了几个新的设计需求和问题,需要进一步思考和完善脑槽的结构和脑蛇的具体实现方式。
在 game_brain.h 中:定义宏 #define IndexedBrainSlotFor
我们在设计脑槽(brain slot)时遇到的问题是,需要一个常量来表示槽的位置,但如果把槽索引作为函数参数传入,就不再是常量了。为了解决这个问题,提出了“带索引脑槽”(indexed brain slot)的方案,思路是给脑槽结构加一个索引成员,然后在访问时自动把索引加上偏移量,这样就能动态访问对应的槽位。
实现时发现可以用一个更简单的方法,就是调用普通的脑槽访问函数时,把索引作为一个额外的偏移量参数传入,函数内部在计算具体内存地址时直接把索引加进去。这避免了之前复杂的操作,也不需要取地址符号“&”,使表达式更简洁。
在具体代码操作中,遇到了类型转换问题,特别是指针运算和类型转换顺序的问题。由于指针减法只能在指针之间进行,之前的强制类型转换位置错误导致编译器报错。调整转换顺序后问题解决,确保指针运算先完成再做转换。
总的来说,方案是用索引偏移动态访问脑槽,通过传入偏移量简化代码,解决了之前常量限制和类型转换的问题,使脑槽访问更灵活且代码更简洁。
在 game_sim_region.h 中:将脑中的 entity *Array[16] 替换为 *Array
我们考虑到是否还需要之前那个数组相关的机制,发现其实可能用不到了,不确定其他地方是否还用这个功能,所以先去确认一下有没有实际使用。现在的判断条件是 brain index 要小于数组的计数,这样有个大小限制,但不确定是不是想要的断言方式。感觉直接用结构体的大小来当最大数量更合理。
想法是改成只需要一个 entity*
指针,这个指针指向脑结构的起始地址,然后根据索引计算偏移,按需要访问对应的元素。这样就不必事先限定最大数量,最大数量由结构体大小自动决定,访问更灵活。
具体实现时,就是取到那个指针的地址,然后用索引偏移访问,保证访问的位置正确。虽然这样没法直接用断言确认指针是否有效,但可以用类型转换等方法来检测和确保安全。
总体上,这样做会让脑结构的数据映射更简洁,更通用,便于管理多段结构和对应的实体指针,不必担心之前数组大小限制的问题。
关于 C++ 中指针算术的吐槽
C++在指针算术运算方面的规则设计非常糟糕,尤其是在对非字节(如非8位)单位做指针运算时,可能导致运算结果错误。具体来说,如果对非8位的指针进行算术操作,且这些指针不来源于同一内存分配区域,C++标准允许编译器为了优化速度而采用不正确的算术运算结果,这种行为是不被保证的。
例如,如果对一个大小为8字节的数据做指针算术,且数据地址未按8字节对齐,C++可能会用替代的运算方式,这可能导致错误结果。虽然对8字节对齐的数据来说,这种情况很少发生且一般不会出现问题,但如果数据大小是18字节这样不规则的长度,就更容易出错。
我们不希望编译器偷偷做这种不安全的优化,如果真想要提升速度,应该手动进行地址计算,而不是依赖这种不确定的规则。总之,针对指针算术,最好保证操作基于字节单位的指针,确保运算正确性,避免潜在的安全隐患和难以发现的错误。
当然,举个简单的例子说明C++指针算术中出现的问题:
假设有一个结构体,大小是18字节:
struct WeirdStruct {char data[18];
};
我们申请了一块这样的内存:
WeirdStruct* ptr = new WeirdStruct[10]; // 分配10个WeirdStruct数组元素
现在,如果我们写这样一段代码:
char* bytePtr = (char*)ptr; // 转成字节指针
WeirdStruct* weirdPtr = ptr;bytePtr += 18; // 正确:往后移动18个字节
weirdPtr += 1; // 编译器认为移动了一个结构体的大小,即18字节
上面看起来没问题,但如果你直接对weirdPtr
进行类似非标准对齐的指针算术,比如:
// 假设 weirdPtr 并非严格对齐或不来自同一内存块
WeirdStruct* newPtr = weirdPtr + 1; // 这在某些情况下,C++标准允许编译器做不安全优化
在某些编译器或平台上,如果WeirdStruct
的大小不是标准对齐,编译器可能会用一种替代的方式来计算newPtr
,导致地址偏移计算错误,从而访问错误内存。
而如果我们用字节指针:
char* p = (char*)weirdPtr;
p = p + sizeof(WeirdStruct); // 这里按字节算,绝对准确
就能避免这个问题,因为字节指针的加法是确定的、直接的。
总结:
- 对非字节指针进行算术运算,可能因为不规则大小和未对齐导致运算结果不符合预期。
- 对字节指针进行算术运算,是直接在字节单位加减,结果准确安全。
- C++允许编译器为了优化在非标准用法下做出不可预测的行为,因此一定要保证指针算术的安全和正确。
在 game_sim_region.cpp 中:推进 Ptr 通过 Brain->Array,确保其仍在 Brain 结构内部
我们想做的是,先拿到一个 uint8_t*
指针,指向脑结构的起始地址。然后,我们希望这个指针能够向前移动,移动的距离是一个实体指针(entity*
)的大小乘以想要前进的脑槽(brain slot)的数量。这样做的目的是通过字节级别的指针算术来精准定位脑结构中的具体槽位。
接着,我们会将这个 uint8_t*
指针转换成 entity**
类型(即指向实体指针的指针),然后解引用它,读取或写入具体的实体指针。这个操作允许我们方便地通过偏移量访问脑结构内部的不同实体指针槽。
为了保证安全性,我们会做一些断言,确保这个计算得到的指针不会超出脑结构的内存范围。具体来说,指针必须大于等于脑结构的起始地址,并且不能超出脑结构的末尾。计算末尾时,需要加上脑结构整体的大小,再减去一个实体指针的大小,保证指针不会越界。
在实现过程中,最开始写断言时,误以为只要保证指针不低于脑结构的起始地址就好,忽略了还必须确保指针不会超出脑结构的末尾。经过纠正后,明确了指针的最大允许位置是脑结构起始地址加脑结构大小,再减去一个实体指针的大小。
这样,整个代码逻辑变得更加严谨和灵活,不用再关心脑结构内部具体有多少槽位大小,而是直接根据脑结构大小动态计算和断言指针边界,从而保证访问安全且简洁。
总的来说,这种做法利用了字节指针的精确算术,确保了对脑结构内存的安全访问和有效操作。
Assert(Ptr <= ((uint8 *)Brain + sizeof(brain) - sizeof(entity *)));
是 防止越界写入 的一个 关键安全断言,我们来逐个部分详细解释它的含义和作用:
背景复述
Brain
是一个结构体指针,结构中有一个entity* Array[N]
这样的指针数组;- 我们用指针
Ptr
计算出某个索引(Dest->BrainSlot.Index
)对应的槽位地址,准备写入; - 为了保证安全,必须确保
Ptr
指向的内存区域仍然在brain
结构体内部; - 否则我们将非法写入未定义区域,造成 内存越界或崩溃风险。
表达式逐个分析:
(uint8 *)Brain
把 Brain
强制转换为字节指针,表示结构体的起始地址(以字节为单位)
+ sizeof(brain)
跳到结构体末尾,即 Brain
占用的最后一个字节的下一个位置
- sizeof(entity *)
回退一个 entity*
的大小。我们要确保最后一个可写地址的起点是结构体内部的最后一个 entity*
槽。
换句话说:
最后一个安全写入位置是结构体尾部前
sizeof(entity*)
个字节的位置,也就是最后一个槽位的起始处。
最终断言含义
Assert(Ptr <= ((uint8 *)Brain + sizeof(brain) - sizeof(entity *)));
确保我们即将写入的 Ptr
指向的位置,不超过 brain
结构体中最后一个槽位的开始地址。
也就是说,我们允许写到最后一个 entity*
所在的位置,但不允许写到结构体之外。
为什么这一步很重要?
- 防止写入越界内存 —— 在低级语言如 C/C++ 中,越界写内存会造成不可预知的程序行为、崩溃甚至安全漏洞;
- 调试友好 —— 如果索引写错了,这个断言能第一时间把问题暴露出来;
- 逻辑更明确 —— 明确告诉代码阅读者和维护者:这里的最大访问范围是什么。
举个例子:
假设 brain
的定义:
struct brain {entity* Array[8];
};
结构体大小 = 8 * sizeof(entity*)
= 8 * 8 = 64
字节(在 64 位系统)
(uint8 *)Brain + sizeof(brain) // = Brain + 64
- sizeof(entity *) // - 8
--------------------------------------
= Brain + 56
所以 Ptr
最大可以是 Brain + 56
,也就是 Array[7]
的起始地址。
总结
这句断言确保:
- 你最多只能写到结构体中
Array[7]
所在的槽位; - 再往后写就会越界,断言就会触发;
- 是对 低层内存手动偏移操作 必不可少的安全防线。
强调编程语言必须让简单操作易于实现的重要性
这段内容主要表达了我们对 C++ 在元编程(Metaprogramming)和底层内存管理方面设计的不满,以及为什么我们会在工程实践中倾向于构建属于我们自己的元系统来解决这些问题。以下是这段话的中文详细总结:
我们一直在抱怨 C++,这次我们也想强调为什么我们认为它的某些设计非常荒谬。本质上,我们只是想做一件非常简单、非常基础的事情——比如定义一组结构体,它们实际上是内存中的重叠映射(overlays),我们希望能够通过索引来访问它们。
这件事情非常基础,是元编程中最基本的需求之一:我们可能会把多个不同的数据结构打包在同一块内存里,然后我们希望知道哪个结构是最大的,并自动用这个最大结构的大小来生成一组 entity*
指针数组,用于映射这些结构。这个操作的抽象和自动化实现非常简单,但在 C++ 里我们却不得不自己手动写一堆复杂又容易出错的代码。
C++ 的问题在于,它的设计思维并不是以“让程序员更高效地描述意图”为出发点的。它更像是某些人主观设定的一套编程“政策”(policy):你必须按照某种他们认定好的方式去写程序,而不是让你自由地描述你真正要做的事情。
在我们自己的引擎里,我们是以元编程为基础构建整个系统的。我们建立了自己的工具,让我们可以非常直接地表达这些意图——比如,“我有这些结构,这些结构共享内存,我要用一个数组来索引它们”。我们不需要手动计算偏移、转换指针、写断言、避免非法行为,因为系统自动为我们生成了正确的代码。
相比之下,在 C++ 里写这样的代码,你就会遇到很多非常琐碎、低级的问题,甚至连“获取一个 union 中最大结构的大小”都做不到。明明这应该是语言本身提供的基本能力,但我们却不得不重复编写手动代码来模拟。
最讽刺的是,C++ 的某些支持者还会建议我们去写模板(template),比如把数组的长度模板化,把这些模板传来传去,以便生成我们想要的代码。但这反而使事情变得更复杂了——我们只想表达一个非常简单的事情,却要写一堆模板代码来“达成共识”。相比之下,我们自己用元编程写,可能只要三行代码,直接生成想要的效果。
总之,C++ 的这套强加式的设计哲学让我们在日常开发中频繁陷入“不该写的低层实现细节”里,而不是专注于表达意图。这正是我们不满的根源,也是为什么我们更喜欢自己构建元编程工具,把这类需求彻底自动化和抽象化。
这一段是对编程语言设计哲学的深度批判,同时也阐明了我们为何要自建元系统,并说明这样做不仅更高效,也更贴近开发者的真实意图。
在 game_world_mode.cpp 中:让 AddStandardRoom 通过调用 AddSnakePiece 添加一条蛇
我们现在要开始处理蛇(snake)相关的部分,准备将它加入游戏逻辑中。我们希望能够调用“添加蛇”的功能,所以在添加怪物(Monstar)之后,我们也要添加蛇。
首先,我们要为蛇添加一个初始身体段(segment)。我们用一个循环来准备多个段,不过现在先从只添加一个开始,等功能跑通之后,再逐步扩展成多个段。我们设置一个变量 PieceIndex = 0
,然后用 while(PieceIndex < 1)
的方式跑一遍,目的是先放一个蛇段,之后再拓展出更多。
接下来,我们需要为这条蛇分配一个“脑子”(Brain),因此在做任何实体添加前,先创建一个 BrainID
,这一过程很简单,只需要生成一个即可。
然后,我们决定这段蛇的身体应该位于地图的哪个位置。我们打算将它竖着摆在地图的最右边,比如设定 X 坐标为 12,Y 坐标则根据段索引计算,比如 Y = 4 + SegmentIndex
,这样每段蛇的身体会逐段往下排列。
当我们创建完第一个蛇段实体后,发现蛇头的尺寸远比预期要大,看起来非常不合适,因此我们立刻调整它的尺寸,让它变小,这样视觉上才更合理。
调整完后,现在我们得到了一个大小合适的蛇段,可以作为初始蛇体的一部分,为之后的扩展(比如添加多个段、动态移动等)打下基础。
这段逻辑体现了我们采用的迭代式开发策略:先让功能通、显示正确,再逐步扩展复杂度;并且也说明我们在开发过程中重视视觉反馈,发现尺寸不合理后及时调整,确保游戏元素在画面上表现符合预期。
# 在 game_brain.cpp 中:让 ExecuteBrain 控制蛇的移动
这段内容主要描述了我们为“蛇”的多个身体段(segments)添加移动逻辑的过程。我们采用类似之前怪物(Monstar)移动的方式来实现蛇的跟随行为,并做出适当调整以支持多个身体段协同移动。
我们现在需要让蛇的身体段能够移动起来。为了实现这一点,我们决定复用先前为怪物(Monstar)实现的移动系统,并将其稍作修改,使它可以支持链式移动行为。
首先我们获取当前蛇的“脑子”(Brain)结构,即 brain_snake
。我们认为蛇的“头”就是其身体段数组 Segments
的第一个元素,如果这个元素为空,就说明这条蛇并不存在。
接着进入移动逻辑,我们从蛇头开始尝试移动。蛇头将尝试选择一个可以前进的方向,比如寻找一个未被占用的位置,然后执行 TransactionalOccupy
—— 这是一个原子性地尝试将蛇移动到目标格子的函数。
如果蛇头成功移动了,我们就希望身体的每一段也能跟着移动,形成蛇的自然动作。为此我们添加一个 for
循环,从 segment_index = 1
开始,依次遍历蛇身的每一段。
对于每个身体段,我们重复执行相同的占位逻辑(TransactionalOccupy
),目标是让它移动到前一段所在的位置。我们通过记录“上一段占用的位置”来传递目标坐标,实现段与段之间的衔接。
即使其中某个 TransactionalOccupy
失败了也无妨,因为我们知道该位置之前是由蛇身另一部分占用的,失败也不会影响整体状态太严重,逻辑仍然是健壮的。
为了实现这个过程,我们还保留了一个 last_occupying
的引用,它表示“上一段刚刚占据的位置”。我们每次将当前段的当前位置赋值给 last_occupying
,以便下一段可以根据它进行移动。
此外,我们也会根据 body_came_from
和 occupy_movement_mode
等信息来设定移动的具体模式,确保运动轨迹和方式保持一致,模仿蛇身跟随蛇头的自然行为。
为了减少代码重复,我们还计划将这段逻辑提取成共享的工具函数,提升代码复用性和整洁度。最后,我们还将“body”相关的命名统一更改为“head”或“segment”,确保语义准确一致。
这段逻辑完整地为“蛇”实体添加了基于蛇头引导、蛇身跟随的运动机制,并具备良好的健壮性和可扩展性,是构建蛇形生物基础行为的重要一步。
拷贝一份Monstar 的修改
运行游戏,看到蛇像怪物一样移动
我们目前实现的逻辑正如预期所表现的一样:如果没有提供任何额外信息,“蛇”就会像之前的怪物(Monstar)一样行动。这是我们所期望的默认行为。因为我们重用了怪物的移动系统,而蛇在目前阶段尚未加入其他特殊的逻辑,所以它自然会以与怪物相同的方式进行路径选择和移动。
接下来我们打算在这个基础上继续扩展蛇的行为系统,比如增加更多控制段之间衔接、长度更新、碰撞处理等逻辑。我们会逐步引入这些功能,使蛇的行为更加接近我们预期的复杂动态表现。
这个阶段的关键点是:
- 验证现有逻辑可以兼容新的“蛇”实体;
- 确保如果不添加额外控制,蛇默认会表现得像之前的怪物;
- 为后续定制行为奠定基础,比如蛇头控制、身体跟随、链式移动、自动路径调整等。
总的来说,目前的行为是完全符合预期的基本表现形式。接下来可以安心开始对蛇添加更具特色的行为机制。
在 game_world_mode.cpp 中:添加更多蛇身段
现在我们要观察一个更有趣的情况:当我们调用 AddSnakePiece
添加多个蛇段时,看看这些蛇段是否能够正确地彼此跟随移动。这是验证蛇形生物链式逻辑是否正常工作的关键步骤。
经过初步测试后,发现蛇段之间确实可以正确地彼此跟随,说明我们之前实现的链式移动逻辑基本是可行的,运行效果良好。
接下来尝试添加更多的蛇段。不过我们注意到一个潜在问题:如果按照 Y 方向堆叠太多蛇段,可能会导致某些蛇段生成时重叠在已有实体上,比如生成在角色脚下,这会造成占位冲突或逻辑错误。
为了解决这个问题,决定改用 X 方向布置蛇段,使蛇水平排列,避免竖直方向上的重叠。这时候我们调整初始化坐标,比如 X = 2 + segment_index
,就可以轻松生成 12 段以上的蛇。
但在测试过程中又发现一个限制:当前逻辑似乎不允许蛇超过特定长度(比如最多 8 段)。因此,为了支持更长的蛇体,我们决定将蛇段最大数量提升到 16 段,便于更充分地测试蛇形链条的移动和衔接逻辑。
总结当前工作重点:
- 验证多个蛇段能否正确跟随并形成运动链;
- 避免初始放置时的实体重叠,改用横向生成;
- 提高蛇段上限,以支持更多样化的测试和行为扩展。
整体来看,链式移动逻辑运行正常,当前的调整旨在提升测试灵活性与稳定性,为后续蛇体行为扩展(如拐弯、收缩、分裂)打下坚实基础。
运行游戏,看到蛇能正常移动,直到它自己困住自己
目前我们已经完成了蛇形生物的基本逻辑,并进行了运行验证,一切表现都非常良好。以下是本阶段工作的详细总结:
蛇段逻辑验证与行为测试
我们发现蛇的移动行为基本能够正常工作,链式结构生效,每个蛇段可以依次跟随前一个段移动,形成“蛇身”的自然运动。最初的单段蛇体可以移动无误,进一步增加多个蛇段也能形成完整的跟随逻辑。
头尾区分与显示优化
为了更清晰地区分蛇头和蛇身段,特别是在蛇体盘绕时能明确哪个是头部,我们引入了可视化区分方式:
- 若当前段索引为
0
,表示蛇头,赋予特殊颜色或显示方式; - 其余段为蛇身(torso),使用普通颜色;
这样在地图中观察蛇形活动时能更轻松识别其方向和状态。
蛇体自锁死机制发现与分析
在测试中发现了一个有趣现象:如果蛇体自己盘绕到一定程度,使蛇头完全被自己的其他段包围,头部就无法再移动。表现为蛇完全“锁死”在一个结构中,无法脱离,这其实是符合预期的逻辑行为。
我们尝试复现这个场景:
- 将蛇以多个段构成;
- 引导蛇盘绕;
- 最终让蛇头没有任何可以跳出的空位;
- 结果蛇体永久停滞,无法逃脱。
这是蛇体逻辑自然形成的一种边界情况,也是一个有趣的 emergent behavior,表明系统行为稳定且逻辑自洽。
设计机制的简洁性与高效性验证
从整个实现来看,设计方式非常简洁高效:
- 无需引用计数
- 无需生命周期管理
- 不需要显式状态记录
- 仅通过数组索引与链式引用完成整个结构行为
这种结构非常适合类似“蛇”这类头尾连接、链状移动的生物逻辑。我们可以通过简单的“添加段”操作就构造出可移动的实体群体,完全符合我们对数据驱动结构的期望。
删除行为的简化假设
我们进一步提出了一个合理设想:如果需要删除整个蛇体,只需把蛇作为一个段列表处理,进行数组删除即可。这种删除逻辑非常直观,且无需额外复杂的内存管理流程,也不会影响其它蛇或实体。
下一阶段工作:移动模式扩展
目前蛇体结构与移动基本测试完成,接下来将重点关注 移动模式(Movement Mode) 的丰富和调整:
- 区分蛇与其他生物的行动方式;
- 实现蛇体拐弯、变速等更复杂逻辑;
- 与地图状态互动(比如食物、障碍);
- 增加行为判断策略(例如自动避障或追踪目标);
总结
我们已经成功实现了一个具备链式结构和自然移动行为的蛇形生物系统,运行良好、逻辑合理、行为自然,且不依赖复杂状态管理。后续重点将放在进一步行为扩展与交互逻辑的设计上。整个过程验证了我们构建的脑结构系统在处理复合生物行为方面的强大能力。
在 game_world_mode.cpp 中:尝试创建一些额外的屏幕
我们开始对整体系统进行规模扩展测试,以验证当前实现的结构在大规模数据量下是否依然能保持性能和行为正确性。
扩展性假设与测试目标
我们的核心目标是验证当实体(例如蛇或其他生物)数量大量增加时,系统的运行行为是否依旧稳定,性能是否可接受,以及是否存在明显的瓶颈或崩溃点。
N²复杂度风险点预估
我们初步意识到当前逻辑中存在一些 N²(平方级)复杂度 的部分。这些可能是:
- 实体之间的交互检测;
- 位置信息查询;
- 空间占用状态判断;
- 排他性移动尝试或占据处理(如 TransactionalOccupy);
这些部分在小规模下运行非常高效,但一旦实体数目扩大,就有可能造成性能瓶颈甚至逻辑崩溃。
扩展测试计划
为了验证系统在扩展场景下的表现,我们打算:
- 显著增加实体数量,如大量添加蛇或其他生物;
- 确保这些实体能保持正常移动和逻辑同步;
- 观察系统是否卡顿、崩溃或出现行为紊乱;
- 评估是否存在可以优化的热点路径;
“脑”系统的规模弹性
由于我们使用了“脑”系统来管理生物行为,各个实体的逻辑是以脑为中心组织的,这种设计在理论上具备较好的扩展能力。每个脑控制一组子实体(如蛇的段),只要脑本身的行为逻辑不发生全局依赖,扩展多个脑理论上是并行的、不互相干扰的。
优化方向初步设想
如果在大规模下确实观察到性能下降,可能的优化方向包括:
- 空间划分(Spatial Partitioning):如八叉树、网格等,用于减少不必要的碰撞或交互检测;
- 更智能的遍历与更新调度:避免全体迭代,采用按需更新;
- 逻辑缓存或惰性求值机制:减少重复计算;
- 进一步的逻辑去中心化:确保每个实体只关心局部状态,减少全局遍历;
总结
我们正在进行系统的扩展性验证,目标是确保即便实体数量大幅上升,逻辑结构也依然能稳定运作。尽管当前结构存在潜在的 N² 复杂度风险,但依托“脑”系统的模块化设计和结构清晰性,我们有充分的优化空间与调整策略。这将是进一步完善系统性能与弹性的关键环节。
“我们有很多N平方复杂度的东西”α
当前我们识别到系统存在一个潜在的性能瓶颈 —— 缺乏高效的空间查询机制(Spatial Query)。这一缺失已经开始限制我们实体的创建数量和运行效率,因此需要特别注意并在后续优先解决。
当前问题与风险
- 缺少空间查询机制:目前系统没有任何结构化的空间索引方式,所有实体的位置判断和冲突检测都依赖直接遍历。
- 导致大规模实体时性能急剧下降:随着实体数量增加,这种直接对比造成 O(N²) 的复杂度,仿真时间迅速恶化。
- 影响多屏幕同步:即使部分蛇或单位处于当前屏幕外,它们的行为也需要模拟,而空间查询的低效导致性能进一步下降。
当前验证行为
- 测试中尝试添加多个“屏幕”并观察整体系统行为;
- 在部分屏幕外仍会继续模拟生物(如蛇)行为,导致额外查询与更新;
- 注意到帧率下降至 30FPS,甚至更低(约18FPS),说明模拟线程存在明显负载瓶颈;
- 虽然目前为 Debug 模式构建,但此类复杂度将无法通过 Release 编译根本性解决,结构性优化是必须的。
障碍物影响观察
- 当前地图中某些区域有树木阻挡,导致实体移动受限;
- 推测这是为测试穿越墙体行为而设的临时内容(如
tileX == 14
位置的一排树); - 后续清理该区域以更方便观察移动路径行为。
后续工作方向
我们决定暂缓空间查询优化,先将重心放在更核心的行为系统,如“Movement Mode” 逻辑实现上。但空间查询优化是下一阶段必须处理的重要内容,其实现目标是:
- 实现快速的邻域/碰撞查询;
- 支持大规模动态实体的高效更新;
- 可能的解决方案包括:空间哈希(Spatial Hashing)、四叉树、网格索引(Grid Index)等;
总结
目前系统表现出的性能下降主要由于缺乏高效的空间查询结构,随着实体数量增长,O(N²) 的遍历代价迅速累积,严重影响仿真效率。虽然暂时将注意力集中在移动模式系统的开发上,但空间查询模块必须尽早引入,以支撑系统的扩展性与稳定性。我们将在适当时机对此进行结构设计和实现。
运行游戏并观察模拟效果
我们尝试把模拟规模调大一点,但发现无法无限制扩大,因为系统只模拟靠近玩家或观察点附近的实体,远处的实体不会被模拟。模拟区域外的对象不会执行逻辑运算,只有当这些对象的头部进入到模拟范围内时,它们才开始活动。
在调试摄像机视角下可以清楚看到这个模拟区域,显示哪些实体正在被模拟,哪些未被模拟。头部是控制整个实体决策的关键因素,如果头部不在模拟区域,整个实体就不会行动。
然而,这种设计也暴露了一个潜在的Bug:当一个多段组成的实体(比如蛇形实体)部分进入模拟区域,而另一部分还在外面时,可能会出现头部可以移动但身体部分不能同步移动的矛盾。这个问题不容易出现,但一旦发生,必然会导致行为异常。
对于这个问题,我们还没有确定最佳的解决方案。一种思路是始终以头部所在位置为主,把整个实体“打包”到头部所在的房间或区域,即使身体部分还在外面,也强制归入头部所在区域一起模拟。这样可以保证实体的各部分始终被一起管理和更新。
不过,由于游戏的区域划分是基于房间的,暂时可能不需要特别复杂的处理,但这个问题值得深思,因为它可能带来一些有趣的系统设计或玩法上的创新。
目前把这个问题列入待办事项,等后续有时间再详细研究并尝试解决。整体来看,这个问题的发现说明我们的实体系统在面对复杂、多段的动态对象时,空间管理还需改进和完善。
考虑鼓励实体从相邻房间移动过来接近玩家
我们希望实现的一个目标是,即使我们站在当前房间,也能让来自邻近房间的蛇或者怪物进入我们的房间并被模拟,这样可以避免邻近房间的实体完全被“关闭”或停用。这种设计能使游戏世界更加连贯和生动。
实际观察中,蛇确实会从上方的房间移动到当前房间,但如果没有移动,可能是因为路径被阻塞或其它实体的死锁情况。比如蛇之间互相堵住了,导致它们无法继续移动,这种死锁现象虽然意外,但也挺有趣。
目前比较担心的是,如何处理多段实体跨越多个房间时的模拟和空间查询问题。一个可能的解决方案是:既然我们知道实体的最大空间占用范围,就可以在查询同类实体时,把查询区域扩大超过当前模拟区域,确保实体即使身体部分跨越多个区域,也能被整体考虑进去,防止模拟时出现断裂。
不过,这样的方案在技术上会带来一些复杂性。比如,游戏中数据是以区块(chunk)为单位流式加载的,如果扩大查询区域,就不得不加载更多区块的数据,可能导致性能问题。为了减少影响,也许可以设置一个阈值,比如实体只有当距离模拟边界足够远时才被模拟,防止部分实体处于模拟区域之外时出现问题。
总之,这种跨区块、跨房间的多段实体模拟问题比较棘手,特别是地理位置分散的实体会加剧这种情况。这个问题尚未解决,计划放入待办事项,后续继续深入研究合适的处理方式。
整体来看,当前系统虽然有趣且功能较完善,但空间管理和模拟边界的处理仍有优化空间,需要进一步改进以保证实体行为连贯且性能可控。
在 todo.txt 中:更新待办事项,并添加关于地理分散实体的实体系统说明
目前我们还在考虑实现一些基础的行为示例和相关功能,比如路径寻路和AI状态的存储。虽然具体会有多少AI状态和存储内容还不确定,但这部分内容依然是需要规划和保留的。关于AI状态如何存储、存储在哪里,还没有明确方案,这部分也需要继续探讨和设计。
我们对当前的实体系统已有了基本的理解,整体结构和设计看起来比较合理。像剑的碰撞检测之类的功能暂时不会实现,这些内容可能不会按照最初设想的方式来处理,不过暂时会保留相关代码和设计以备后续调整。
另外,我们明确了一个待办事项,就是如何处理地理上分散但需要整体同步移动的实体。比如有些实体体积较大、跨越多个区块,而当前流式模拟和加载机制只部分加载这些实体,可能导致它们无法统一移动或同步行为。对此,可以选择暂时不支持这种情况,接受系统的局限性,因为每个系统都有自己的限制,这也是合理的。
但也希望能尝试找到更好的解决方案,避免这种局限性带来的问题。整体来看,这块问题比较复杂,需要继续研究和尝试。
接下来计划优先处理空间查询相关的功能,这对于优化模拟效率非常重要。关于移动模式的内容则可能视情况决定是否提前进行,可能会在后续再详细展开。
问答环节
编译器真的会搞砸指针算术吗?它不过是整数运算,应该不难搞砸吧
关于定点算术的问题,本质上它只是整数运算,不应该太难出错。问题不在于代码本身错误,而是在于规范中说这类运算的结果是未定义的。这个“未定义”的含义让编译器和处理器厂商有了随意处理的空间,他们往往会选择最有利于性能优化的方案,而不是遵循“最不令人惊讶原则”,即生成符合程序员预期的行为。
这种情况导致了很多不可预测的行为,虽然厂商这样做能提高基准测试的分数,但从程序员角度来说,应该是尽量保证行为的确定性和可预测性。理想的做法是规范制定者应明确规定所有情况下的行为,或者至少在无法明确时,给出明确的指导,比如说:带符号运算的结果应该是目标处理器在执行该操作时会得到的结果,符合程序员对该平台的合理预期。
规范不应该简单说“未定义,随你怎么做”,而应尽可能详尽地说明细节,任何不能明确的地方都应该要求厂商遵循“最不令人惊讶原则”,避免产生程序员无法理解或预料的异常行为。
最后,“long billion”被提到是解决“蛇”问题的方法,虽然具体细节未展开,但可以推测是指利用某种长整型或大数字处理来解决相关逻辑或表现问题。
解决蛇卡住问题的方法是允许蛇体穿过自己,还是让蛇的AI足够聪明避免自困?
关于蛇被卡住的问题,主要有两种解决方案:一种是允许蛇穿过自身,另一种是让蛇的AI足够智能,不会陷入自我困境。首先,蛇通常不会很长,因此问题在当前测试环境中被放大了。实际上,AI不会完全随机选择移动路径,因此陷入自我卡住的情况发生概率较低。
如果蛇真的卡住了,其实这也没什么大问题,反而是一种设计上的乐趣——玩家可以轻松击败这条蛇,这样游戏中的蛇既有挑战性,又有被击败的可能性,增加了趣味性。AI的设计目标是让行为可预测且有趣,而不是让AI非常聪明,因为过于聪明的AI未必能为这种类型的游戏带来更多乐趣。
因此,偶尔蛇会卡住反而是积极的体验,意味着玩家有机会轻松击败它,而通常情况下蛇还是比较难缠的。最后,提到最近观看了关于GJK算法的视频,可能与碰撞检测或游戏物理相关,但具体细节未展开。
最近看了你关于GJK算法的视频,非常棒!你有做过EPA算法的视频吗?
谈到了GJK算法,非常棒,也表达了感谢。接着提问是否也做过关于EPA算法的视频。这里的EPA并不是指环境保护署,而是一种算法的名称。关于EPA算法,我们表示不太确定它具体是什么,但明确知道它不是指环境保护署。随后提到蛇头相关的话题,暗示可能会结合这些算法来处理蛇的碰撞或移动逻辑。整个对话围绕着算法的介绍和疑问展开,重点在于理解不同算法及其应用于游戏中的实体,比如蛇的行为和物理交互。
蛇头可以移动到它最后一个段的位置吗?这样蛇可以形成圈或环路
蛇头是否能够移动到它的最后一个蛇节的位置,从而允许蛇形成一个圆圈或环路。理论上这是可以允许的,但对我们来说,这个功能的优先级不高,暂时不打算实现。实现的方法是先在搜索路径之前临时释放当前位置,然后再重新占据新位置。提到了一些惊讶的反应,表明这一想法可能让人意外或觉得有趣。整个讨论集中在蛇的移动规则和路径规划的灵活性上,考虑了是否允许蛇头回到尾部位置形成环状移动。
你竟然做过GJK视频?还有什么其他讲解视频?
我们做过关于GJK算法的视频,确实有做过。除此之外,还做过四元数双覆盖(quaternion double cover)相关的视频。关于其他的解释性视频,也有一些涉及不同主题。总体来说,视频内容涵盖了各种技术细节和算法原理,帮助理解复杂的数学和计算机图形学概念。
GJK(Gilbert-Johnson-Keerthi)算法是一种用于计算两个凸形体(convex shapes)之间最短距离的算法。它常用于计算机图形学和物理引擎中,特别是在碰撞检测(collision detection)里用来判断两个物体是否相交,或者计算它们之间的最小距离。
它的核心思想是利用“支持映射”(support mapping)函数,不断在两个物体的“形状差集”(Minkowski difference)上进行迭代搜索,寻找两个形体之间的最近点。如果找到的点包含原点,说明两个物体相交。
简单来说,GJK算法效率高,适用于实时碰撞检测,是游戏开发和物理模拟中非常重要的基础算法之一。
你会把所有AI都实现到brain entity里吗?路径搜索和其他AI功能什么时候安排?还有能推荐几本AI好书吗?谢谢,Casey
计划实现所有AI相关的功能,尤其是控制逻辑会集中在brain.cpp文件中。控制逻辑会逐步成型,现在已经开始展现初步效果。为了简化开发过程,会将常用功能封装成实用的辅助函数,比如寻找最近的特定类型目标(如最近的英雄)或者向某个目标移动等操作,这样写AI逻辑时就不需要重复写复杂代码。
这些辅助函数会构建出AI“大脑”的基础,整个AI系统的控制逻辑就是由这些简单的调用组合而成,方便编写和维护。至于路径查找和其它AI相关功能的具体实现时间,目前没有固定的开发计划,因为游戏开发通常需要根据实际情况和架构需求灵活安排,优先开发最有意义和最合理的部分。因此具体什么时候完成各种AI功能,需要根据当时的需求和架构来决定。
关于推荐的AI参考书,暂时没有具体的书籍推荐,无法提供帮助。
还有IMGUI,其他呢?
我们提到过的视频内容主要包括GJK算法和Bezier样条曲线的讲解。Bezier样条曲线的视频重点是介绍它们的构造方法,强调不需要查找复杂公式,只要记住它们本质上是线性插值的递归组合,就能轻松推导出来。此外,还涉及了扩展多面体算法(Expanding Polytope Algorithm)的内容。除此之外,视频数量不多,主要聚焦在这些比较基础且实用的算法和数学概念上。
扩展多面体算法(EPA)
我们从未做过扩展多面体算法(EPA)的详细讲解视频,不过可以想象,如果用类似于GJK算法视频中的逻辑去理解EPA,它也会变得比较容易理解。EPA是一个几何算法,但相较于GJK,它稍微复杂一些。GJK非常简单,EPA则稍微复杂点。
视频内容主要涵盖了几何算法的基本原理和实现方法,便于理解这些算法的几何本质。关于如何实现蛇形行为的问题,视频中并没有详细说明,但相关内容会在算法和实体行为设计中有所体现。
能简单说说蛇的每个身体段是怎么知道跳到哪个节点的吗?不是蛇头的那种段
蛇的每个身体节段知道应该跳转到哪个节点,尤其是非头部的节段会跟随相应的节点移动,这样保证了蛇的整体连贯性和运动。这个机制是蛇形运动的关键,因为它让蛇的身体各部分能够协调一致地移动。我们还注意到在实现过程中要特别关注这些节点的跳转逻辑,确保不会出现脱节或者运动异常的情况。
别忘了API视频!
蛇的代码实现非常简单,核心是利用怪物的逻辑来找到蛇头附近最近的可通行且未被占据的位置,然后让蛇头移动到那里。接着,蛇的每个身体节段依次跟随前一个节段的位置移动。具体来说,当蛇头离开一个格子时,会将这个格子的位置保存为“上一个占据位置”,然后通知下一个节段跳到蛇头刚离开的那个位置。这个过程像一个“接力队”,每个节段都离开当前位置,告诉后面的节段跳到它刚离开的格子,从而保证整条蛇的身体依次移动并保持连贯。这样每个节段依次占据前一个节段离开的格子,实现了连贯的蛇形运动。
我已经实现了2D的GJK和EPA,以及3D的GJK,但3D的EPA有点困难
确实,EPA(Expanding Polytope Algorithm)在3D中的实现比在2D中复杂得多。虽然GJK(Gilbert–Johnson–Keerthi)算法在2D和3D中都可以使用,但在3D中理解和实现EPA更具挑战性。
在2D中,涉及的几何结构主要是点、线段和简单的多边形,拓展边界和处理邻接关系相对直观。而在3D中,不仅要处理点和线段,还要处理三角形面、体积的拓展边界、以及这些面的邻接关系管理,这大大增加了算法的复杂度。
此外,在3D中构造和维护凸多面体结构更为繁琐。需要处理的问题包括:
- 如何正确地扩展多面体的边界。
- 如何找到支持点并在三维空间中更新当前的最小面。
- 如何处理新生成的面与现有面之间的拓扑连接关系。
- 如何维护面法线方向一致性,确保外表面朝外。
总的来说,EPA在3D中的实现不仅需要良好的几何直觉,还需要构建和维护复杂的空间结构,因此遇到困难是很自然的。这个问题的复杂性主要来源于三维空间中构造与遍历多面体的逻辑比二维要多出很多情况和特殊情况。