13个版本 (破坏性)
0.10.1 | 2024年7月24日 |
---|---|
0.9.0 | 2024年6月16日 |
0.8.0 | 2024年2月27日 |
0.5.1 | 2023年11月24日 |
0.2.0 | 2023年7月31日 |
#28 in 游戏开发
每月150次下载
350KB
6K SLoC
bevy_flowfield_tiles_plugin
受到Elijah Emerson的工作启发,并借鉴了leifnode和jdxdev的灵感,这是尝试实现生成用于移动角色的Flowfield表示的数据结构和逻辑的一个尝试。
bevy | bevy_flowfield_tiles_plugin |
---|---|
0.14 | 0.10 |
0.13 | 0.7 - 0.9 |
0.12 | 0.5 - 0.6 |
0.11 | 0.1 - 0.4 |
目录
简介
在游戏中,路径搜索可以采取不同的形式,这些形式与它们应用的游戏类型有一定的优势。通常人们会遇到
- 路径点图 - 空间中的点相互连接,结构非常严格,一个角色将从一点移动到另一点。非常适合在小型网格上玩的游戏,需要将移动限制在精确的线条上,当多个角色共享路径时将会很繁琐 - 尤其是当角色有某种类型的碰撞系统时
- 导航网格 - 从游戏世界中的网格拓扑结构生成的可通行表面,定义了有效的移动区域。它允许在网格限制内进行一系列动态移动,并且是路径点图的天然进化
- FlowField Tiles - 一种通过生成描述角色如何穿越世界的流场(矢量场)来处理人群和集群行为的手段。大量角色可以同时流向一个端点,同时共享相同的路径数据结构 - 节省计算资源和时间
对于越来越大的环境,以及路径选择者数量的增加,采用基于FlowField的方法可能有益,因为它促进了数据共享和类似群体移动的形成。FlowField瓦片很复杂,它实际上类似于流体力学,因此这是将一种无差别的实现带到Bevy游戏引擎的尝试。我这么做的原因是,我最近为原型实现了一个路径点图。为了实现“不错的”演员移动,它必须由1600万个数据点组成。为了防止演员偶尔在游戏世界中进行之字形移动,必须将精度提高到8000万个数据点,以创造一个“逼真”的移动印象。那真是太愚蠢了,所以我开始研究路径查找的历史,随后我偶然发现了FlowField瓦片,并决定尝试用我最喜欢的语言和引擎实现它。
有用定义
- 区域 - 由三个二维数组(称为字段)组成的游戏世界的一部分(
CostField
、IntegrationField
和FlowField
)。一个游戏世界实际上由多个区域表示 - CostField - 一个二维数组,描述了通过数组的每个单元格进行路径选择的难度。它始终存在于系统内存中
- Cost - 到达某处的难度/成本,你也可以称之为权重,
CostField
的每个单元格都有一个这样的值 - 门户 - 一个可导航的点,它将一个区域链接到另一个区域,以便从世界的一侧移动到另一侧
- IntegrationField - 一个二维数组,使用CostField确定达到目标/端点的累积成本(即你想要路径的地方)。这是一个短暂的字段 - 它在需要计算
FlowField
时存在 - FlowField - 由
IntegrationField
构建的二维数组,描述了演员应该如何在世界上移动(流动) - FlowField缓存 - 存储FlowField的一种方式,允许多个演员使用和重复使用它们
- 序数 - 一种基于传统罗盘序数的方向:北、东北、东、东南、南、西南、西、西北。在算法的各个点用于发现区域/字段单元格
- 字段单元格 - 二维数组的一个元素
- 目标 - 演员需要路径到的目标字段单元格
- 门户目标 - 一个允许演员过渡到另一个区域、使其更接近目标的目标点
设计/流程
点击展开!
要生成一组导航FlowFields
,游戏世界被分成由(列, 行)
索引的区域,并且每个区域有3层数据:[CostField, IntegrationField, Flowfield]
。每一层都帮助下一层构建路径。使用Portals
的概念将区域连接在一起。
区域
点击展开!
对于一个三维世界,x-z
(在二维中为x-y
)平面定义了用于表示该世界的扇区数量,这通过一个称为sector_resolution
的缩放因子来实现。这意味着对于一个具有(30, 30)
大小和10
分辨率的世界的3x3
扇区将表示它——这意味着单个扇区的相对尺寸为(10, 10)
,并且一个扇区内的单个场单元格代表一个1x1
的区域。每个扇区都有一个与其位置关联的唯一ID:(列, 行)
。
同样,对于一个具有(300, 550)
分辨率和10
的世界,您将看到30
列和55
行。将世界划分为扇区(而不是将整个世界视为一个巨大的Flowfield
)的优势在于,生成路径的工作可以分解成多个操作,并且只需触及某些扇区。比如说,对于一个具有(300, 550)
大小的世界,您将其视为一个单独的场集——在计算路径时,您可能需要计算165,000
个场单元格的Flowfield值。将其划分为扇区可能意味着您的路径只需通过20个扇区,因此只需计算2,000
个Flowfield场单元格。
CostField
点击展开!
CostField
是一个8位值的MxN二维数组,默认情况下这是一个10x10
数组。这些值表示通过该场单元格的成本。值为1
是默认值,表示最容易的成本,而值为255
是一个特殊值,用于表示该场单元格不可通行——这可以用来表示墙壁或障碍物。从2-254
的所有其他值表示增加的成本,例如斜坡或难以通行的地形,如湿地。路径查找计算的理念是在任何其他单元格之前优先考虑具有较小值的单元格。
在运行时,为每个区域生成一个默认值为 - 的 CostField
,尽管使用 ron
功能可以从磁盘加载字段,或者使用 heightmap
功能可以使用灰度 png/jpeg 文件作为种子来生成字段。有关在初始遍历(例如加载关卡时)和游戏过程中调整 CostFields
的详细信息,请参阅下面的 使用说明 部分。当放置墙壁或地面裂缝等障碍物时,一个细胞会翻转到一个更高的成本或不可通过的成本 255
。
此数组用于在请求可导航路径时生成 IntegrationField
。
传送门
点击展开!
每个区域最多有 4 个与相邻区域的边界(当区域位于角落或游戏世界边缘时数量更少)。每个边界可以包含传送门,表示从当前区域到相邻区域的可导航点。传送门具有双重功能,其中之一是提供响应性 - FlowFields
的生成可能需要时间,因此当演员需要快速移动时,可以使用基于从一个传送门移动到另一个传送门的快速 A* 路径查询,并基于此生成初始路径路由,然后演员可以开始向目标/终点的一般方向移动。一旦 FlowFields
已构建,演员可以切换到使用它们进行细粒度导航。
以下区域位于世界边缘之外,这意味着每个边界都可以有传送门(紫色单元格)
传送门生成在边界的中间 - 在 CostField
中沿边缘包含 255
成本的情况下,则可能在边界的每个有效路径段的中间生成多个传送门,并将其传播到相邻区域,以便每个传送门都有一个相邻的伙伴(如上图中右侧的区域所示,S(1, 1)
传送门 (9, 1)
允许进入 S(2, 1)
传送门 (0, 1)
,尽管 S(2, 1)
的整个边界看起来是完全可通行的)。
在更大的规模上(但仍然是小的)和最简单的 CostField
中,一个 2x2
区域网格会产生可预测的边界传送门。
传送门图
为了在传送门级别从一个区域到另一个区域找到路径,所有区域的传送门都记录在一个称为 PortalGraph
的数据结构中。传送门存储为节点,并在这之间创建边来表示可通行路径,它分为三个阶段构建
- 为所有传送门添加一个图
节点
- 为每个区域创建从每个传送门
节点
到和来自的边
(路径) - 实际上创建了每个区域的内部可步行路线 - 在所有扇区边界(从一个扇区到另一个扇区的可行路径)上创建通过门户的
edges
。
这允许使用source
扇区和target
扇区进行查询,并返回一个可路径化的门户列表。当CostField
发生变化时,这将触发所在区域(以及其邻居以确保边界均匀)的扇区门户的重新生成,并使用任何新的门户nodes
更新图,并删除旧的门户。
IntegrationField
点击展开!
IntegrationField
是一个16位值的MxN
2D数组。它使用CostField
来生成达到最终目标/目标的累积成本。它是一个瞬时字段,因为它只为所需的扇区构建,然后由FlowField
计算消耗。
当需要处理新路线时,字段值设置为u16::MAX
,目标字段单元格设置为0
。
从目标开始执行一系列遍历,作为一个扩展的波前来计算字段值。
- 确定目标的合法序号邻居(北、东、南、西 - 当不是对扇区/世界边界时)。
- 对于每个序号字段单元格查找其
CostField
值。 - 将
CostField
成本添加到当前单元格的IntegrationFields
成本中(开始时这是目标整数成本0
)。 - 传播到下一个邻居,找到它们的序号,并重复将它们的成本值添加到当前单元格的集成成本中,以生成它们的累积集成成本,直到整个字段完成。
这产生了一个漂亮的菱形图案,因为波扩散(这里的底层CostField
设置为1
)。
现在,一个类似菱形的波在动态运动的世界中并不完全真实,所以应该在某个时候替换它。根据各种文章,人们似乎采用Eikonal方程来在字段空间上创建一个更球形的波。
当CostField
包含不可穿越的标记时,例如作为黑盒的255
,它们被忽略,所以波会绕过这些区域流动。
并且当你的CostField
使用一系列值来表示不同的穿越区域时,例如陡峭的小山。
所以这鼓励路径算法绕过你的世界中的障碍物和昂贵区域!
这涵盖了计算包含目标的单个扇区的IntegrationField
,但当然演员可能在一个遥远的扇区,这就是Portals
重新发挥作用的地方。
从PortalGraph
中,我们可以获取一个Portals
路径来引导演员穿过几个扇区到达所需的扇区,扩展上述目标扇区的IntegrationField的计算,接下来我们“跳跃”通过边界
Portals
,从目标扇区向后到演员扇区(门户用紫色表示)产生一系列IntegrationFields,描述链式扇区的流动运动。
在路径查找方面,角色将优先选择“向下”流动。从角色的位置出发,观察其领域的邻近细胞,在该区域的IntegrationField
中的较小值意味着达到终点更有利的位置,从较小的值到更小的值,基本上是一个流向目的地的梯度。
这构成了FlowField
的基础。
以一个30x30
的世界为例,目标位于0
,角色位于A
,一个询问所有区域Portals
的IntegrationField
可能产生一组看起来类似的字段:
注意从目标传播出的酷炫波浪!
程序化生成这些字段会导致
注意我们不需要为角色不需要路径穿越的区域生成字段。另外,一个门户代表可穿越区域边界的中间点,在生成字段时,我们将门户扩展以覆盖其整个段落 - 这提高了效率,使得角色可以更直接地接近其目标,而不是曲折地走向门户边界点。
从IntegrationFields
中,我们现在可以构建最终的字段集 - FlowFields
FlowField
点击展开!
FlowField
是由Sectors的IntegrationField
构建的MxN 2D数组8位值。值的第一个4位对应于角色可以采取的八个顺序移动方向之一(不可通行时为零向量),第二个4位对应于应由角色控制器/引导管道使用的标志,以跟随路径。
方向位定义为
0b0000_0001
- 北0b0000_0010
- 东0b0000_0100
- 南0b0000_1000
- 西0b0000_0011
- 东北0b0000_0110
- 东南0b0000_1100
- 西南0b0000_1001
- 西北0b0000_0000
- 零向量,代表不可通行细胞0b0000_1111
- 在FlowField
初始化时默认,总是被其他值替换
辅助标志定义为
0b0001_0000
- 可通行0b0010_0000
- 可见目标,角色不再需要跟随字段,可以直接移动到目标。这避免了计算实际不需要的字段值,一旦角色进入带有此标志的细胞,它们就不再需要花费时间查找`FlowField`0b0100_0000
- 指示目标0b1000_0000
- 指示通往下一个区域的门户目标
因此,在FlowField
中,具有0b0001_0110
值的字段细胞意味着角色应该向南东方向流动。在用法方面,无需过多关注这些位值,`用法`部分展示了用于解释FlowField
值的辅助程序,以引导角色。
使用之前生成的IntegrationFields
,角色在右上角尝试到达左下角,我们现在生成FlowFields
每个单元格图标较细的部分表示流动方向。行动者沿着指向目标的流动线运行。这意味着对于一组行动者,他们将以类似编队的行为,沿着流动线向目标流动。
路由 & 流场缓存
点击展开!
为了使行动者能够重用 FlowFields
(从而避免重复计算),使用了一对缓存来存储路径数据。
-
路由缓存 - 当行动者请求去某个地方时,会生成一个高级路由,描述要穿越的整体系列区域门户(
PortalGraph
A*)。如果尚未计算FlowField
,则行动者可以使用route_cache
作为后备,以获得他们应该开始移动的通用方向。一旦构建了FlowFields
,他们就可以切换到使用更细粒度的路径。待办事项:删除《此外,《code>CostFields 的更改可能会改变门户位置和最佳路径,因此需要为FlowFields
重新生成相关区域的数据结构,在重新生成步骤中,行动者可以再次使用高级路由作为后备。 -
字段缓存 - 对于路由的每个区域到门户部分,都会构建一个
FlowField
并存储在缓存中。行动者可以轮询此缓存以获取到达目标的真实流动方向。一个角色控制器/引导管道负责解释FlowField
的值以产生运动 - 虽然此插件包含一个引导管道,但实际情况是每个游戏都有自己的运动怪癖和需求,因此您很可能想要构建自己的管道。此插件的实际目的是封装数据结构和逻辑,以创建一个行动者可以通过其自己的实现读取的FlowField
。
请注意,缓存中存储的数据具有时间戳 - 如果记录超过 15 分钟,则将其删除以减少大小并提高查找效率。在实现引导管道/角色控制器以解释 FlowFields
时,您可能需要考虑这些旧路由/路径已过期。
行动者大小
点击展开!
在模拟中,您可能有不同大小的行动者以及不可穿越的墙壁之间的间隙,考虑这些紫色行动者。
左边的较小行动者显然可以通过不可穿越的地形之间的间隙。然而,右边的行动者要大得多,因此在处理 PathRequest
时,仅应考虑具有适当间隙的路线(否则,如果有碰撞系统,它只会撞到侧面的墙壁,而无法通过)。
为了处理此问题,定义各种字段尺寸的总体 MapDimenions
组件包含一个 actor_scale
参数。这种缩放由行动者大小和字段内单元格的单位大小确定。例如,具有 640x640
像素尺寸的区域表示 (m, n) -> (10, 10)
字段中的每个单元格代表一个 64x64
像素的像素区域,如果行动者的宽度大于 64
像素,则将应用行动者大小和单元格大小的比率来“扩展”不可穿越的单元格,以封闭对行动者来说太小的间隙,无法通过。
在请求路线后,行动者可以看到的是,左边的较小行动者可以通过间隙进行路径搜索,而右边的较大行动者会寻找替代路线。
在一个包含多种大小角色的游戏中,您将希望从 FlowFieldTilesBundle
创建不同的实体,每个实体配置为处理特定大小的角色。
#[derive(Component)]
struct ActorSmall
#[derive(Component)]
struct ActorLarge
fn setup () {
let map_length = 1920;
let map_depth = 1920;
let sector_resolution = 640;
let actor_size_small = 16.0;
cmds.spawn(FlowFieldTilesBundle::new(
map_length,
map_depth,
sector_resolution,
actor_size_small
)).insert(ActorSmall);
let actor_size_large = 78.0;
cmds.spawn(FlowFieldTilesBundle::new(
map_length,
map_depth,
sector_resolution,
actor_size_large
)).insert(ActorLarge);
}
fn system_navigation_small_actors(
actor_q: Query<&Actor, With<ActorSmall>>,
field_q: Query<&FlowCache, With<ActorSmall>>
) {/* handling movement etc */}
fn system_navigation_large_actors(
actor_q: Query<&Actor, With<ActorLarge>>,
field_q: Query<&FlowCache, With<ActorLarge>>
) {/* handling movement etc */}
使用
更新您的 Cargo.toml
并添加您需要的任何功能,要实际与计算字段接口,您应该根据您世界的坐标系启用 2d
或 3d
。
[dependencies]
bevy_flowfield_tiles_plugin = { version = "0.x", features = ["3d"] }
默认
将插件添加到您的应用程序中
use bevy_flowfield_tiles_plugin::prelude::*;
fn main() {
App::new()
// ... snip
.add_plugins(FlowFieldTilesPlugin)
// ... snip
}
自定义系统设置和约束
在自己的模拟中,您可能会使用自定义的调度或阶段来控制逻辑执行。插件默认将所有逻辑设置为作为主 Bevy 调度中的 PreUpdate
阶段的组成部分运行。要将逻辑集成到自己的调度中,请分解 plugin/mod.rs
的内容 - 注意某些系统已经被 chained
在一起,并且它们必须保持连接,以便计算准确的路径。
初始化数据
接下来是时候生成配置为您的世界大小的捆绑实体(通过查看示例也会提供一些提示)。
在初始化时,需要知道世界的尺寸和分辨率,需要三个值
map_length
- 在 2d 中,这指的是世界的像素x
尺寸。在 3d 中,这仅仅是x
尺寸。map_depth
- 在 2d 中,这指的是世界的像素y
尺寸。在 3d 中,这是z
尺寸。sector_resolution
- 通过将每个尺寸除以该值来确定扇区的数量。在 2d 中,这基本上是每个扇区边的像素长度,同样,在 3d 中,它使用您定义的任何单位测量每个扇区边的长度(为了方便起见,我选择单位x
为 1 米,单位z
为 1 米)- 2d:像素大小为
(1920, 1080)
且分辨率为40
的世界将产生 48x27 个扇区。另一种看待方式可能是基于有一个由精灵组成的世界,每个精灵对应一个FieldCell
的位置。如果这些规则大小的精灵具有64
的像素长度和高度,并且您的世界由一个20x20
的精灵网格组成,那么您可以计算其大小。map_length
将是精灵长度乘以沿世界x
轴的精灵数量,即64 * 20 = 1280
。map_depth
使用类似的计算64 * 20 = 1280
。至于分辨率,它将取决于您想要多精细,在这个例子中,一个10x10
的CostField
应该覆盖精确数量的精灵,所以我们使用精灵大小来找到分辨率64 * 10 = 640
。 - 3D:一个大小为
(780x440)
的世界,分辨率为10
,可以产生78x44
个扇区。由于字段是10x10
的数组,这相当于一个代表1x1
单位面积的FieldCell
。
- 2d:像素大小为
在某个系统内部,您可以生成Bundle。
fn my_system(mut cmds: Commands) {
let map_length = 1920;
let map_depth = 1920;
let sector_resolution = 640;
let actor_size = 16.0;
cmds.spawn(FlowFieldTilesBundle::new(map_length, map_depth, sector_resolution, actor_size));
}
请注意,这将初始化代表世界的所有 CostFields
,其单元格值为 1
。这意味着所有地方都可以通行,很可能会需要用真实值初始化字段。
在3D中,您可以考虑向每个FieldCell中心发射射线,并使用射线的 y
位置来确定是否可以通行,然后翻转该特定 FieldCell
的值(可以使用 EventUpdateCostfieldsCell
来排队成本更改)。
对于2D或更复杂的3D场景,您可能希望启用 ron
、csv
或 heightmap
功能,这允许从 .ron
文件、一组 .csv
或灰度png/jpeg(其中像素颜色通道被转换为成本)创建 FlowFieldTilesBundle
,初始 CostFields
,示例展示了这一点的详细信息。
路径请求
当与算法交互时,这基于在可移动角色需要路径时发出的事件。
struct EventPathRequest {
/// The starting sector of the request
source_sector: SectorID,
/// The starting field cell of the starting sector
source_field_cell: FieldCell,
/// The sector to try and find a path to
target_sector: SectorID,
/// The field cell in the target sector to find a path to
target_goal: FieldCell,
}
每个参数都可以通过查询Bundle的 MapDimension
组件,并使用角色的起始和目标位置变换来确定。
使用一些示例组件来跟踪和标记角色
/// Enables easy querying of Actor entities
#[derive(Component)]
struct Actor;
/// Consumed by an Actor steering pipeline to produce movement
#[derive(Default, Component)]
struct Pathing {
target_position: Option<Vec2>,
metadata: Option<RouteMetadata>,
portal_route: Option<Vec<(SectorID, FieldCell)>>,
has_los: bool,
}
然后我们可以做一些类似的事情,处理鼠标点击,分配一个角色一个 target_position
(在3D中使用以xyz结尾的方法)
fn user_input(
mouse_button_input: Res<ButtonInput<MouseButton>>,
windows: Query<&Window, With<PrimaryWindow>>,
camera_q: Query<(&Camera, &GlobalTransform)>,
dimensions_q: Query<&MapDimensions>,
mut actor_q: Query<&mut Pathing, With<Actor>>,
) {
if mouse_button_input.just_released(MouseButton::Right) {
// get 2d world positionn of cursor
let (camera, camera_transform) = camera_q.single();
let window = windows.single();
if let Some(world_position) = window
.cursor_position()
.and_then(|cursor| camera.viewport_to_world(camera_transform, cursor))
.map(|ray| ray.origin.truncate())
{
let map_dimensions = dimensions_q.get_single().unwrap();
if map_dimensions
.get_sector_and_field_cell_from_xy(world_position)
.is_some()
{
let mut pathing = actor_q.get_single_mut().unwrap();
// update the actor pathing
pathing.target_position = Some(world_position);
pathing.metadata = None;
pathing.portal_route = None;
pathing.has_los = false;
} else {
error!("Cursor out of bounds");
}
}
}
}
角色可以查询 RouteCache
以获取路径 - 或者如果不存在,可以发出请求以生成路径。
fn get_or_request_route(
route_q: Query<(&RouteCache, &MapDimensions)>,
mut actor_q: Query<(&Transform, &mut Pathing), With<Actor>>,
mut event: EventWriter<EventPathRequest>,
) {
let (route_cahe, map_dimensions) = route_q.get_single().unwrap();
for (tform, mut pathing) in &mut actor_q {
if let Some(target) = pathing.target_position {
// actor has no route, look one up or request one
if pathing.portal_route.is_none() {
if let Some((source_sector, source_field)) =
map_dimensions.get_sector_and_field_cell_from_xy(tform.translation.truncate())
{
if let Some((target_sector, goal_id)) =
map_dimensions.get_sector_and_field_cell_from_xy(target)
{
// if a route is calculated get it
if let Some((metadata, route)) = route_cahe.get_route_with_metadata(
source_sector,
source_field,
target_sector,
goal_id,
) {
pathing.metadata = Some(*metadata);
pathing.portal_route = Some(route.clone());
} else {
// request a route
event.send(EventPathRequest::new(
source_sector,
source_field,
target_sector,
goal_id,
));
}
}
}
}
}
}
}
一旦构建了 FlowFields
,它们可以查询 FlowFieldCache
并执行/排队某种类型的移动。
注意,这个示例非常基础,因为它只处理单个角色,在应用程序中,您将设计自己的处理系统
const SPEED: f32 = 64.0;
fn actor_steering(
mut actor_q: Query<(&mut LinearVelocity, &mut Transform, &mut Pathing), With<Actor>>,
flow_cache_q: Query<(&FlowFieldCache, &MapDimensions)>,
time_step: Res<Time>,
) {
let (flow_cache, map_dimensions) = flow_cache_q.get_single().unwrap();
for (mut velocity, tform, mut pathing) in actor_q.iter_mut() {
// lookup the overarching route
if let Some(route) = pathing.portal_route.as_mut() {
// find the current actors postion in grid space
if let Some((curr_actor_sector, curr_actor_field_cell)) =
map_dimensions.get_sector_and_field_cell_from_xy(tform.translation.truncate())
{
// trim the actor stored route as it makes progress
// this ensures it doesn't use a previous goal from
// a sector it has already been through when it needs
// to pass through it again as part of a different part of the route
if let Some(f) = route.first() {
if curr_actor_sector != f.0 {
route.remove(0);
}
}
// lookup the relevant sector-goal of this sector
'routes: for (sector, goal) in route.iter() {
if *sector == curr_actor_sector {
// get the flow field
if let Some(field) = flow_cache.get_field(*sector, *goal) {
// based on actor field cell find the directional vector it should move in
let cell_value = field.get_field_cell_value(curr_actor_field_cell);
if has_line_of_sight(cell_value) {
pathing.has_los = true;
let dir =
pathing.target_position.unwrap() - tform.translation.truncate();
velocity.0 = dir.normalize() * SPEED * time_step.delta_seconds();
break 'routes;
}
let dir = get_2d_direction_unit_vector_from_bits(cell_value);
if dir.x == 0.0 && dir.y == 0.0 {
warn!("Stuck");
pathing.portal_route = None;
}
velocity.0 = dir * SPEED * time_step.delta_seconds();
}
break 'routes;
}
}
}
}
}
}
注意:生成的FlowFields和Routes在15分钟后从其缓存中过期,如果依赖的某个角色过期,您的引导管道可能需要发送新的 EventPathRequest
。
注意:当修改CostField时,门户和门户图会更新,涉及修改扇区CostField的任何路径或FlowFields都将被删除 - 它们将被重新生成,但CharacterController需要能够处理从缓存中消失并返回的路径(如果可以返回,CostField的更新可能会使路径无效,因为路径不存在)。
可能使路径请求出错的事情
如果您将此与物理模拟结合使用,请确保您的CharacterController非常稳健,考虑以下可能发生的情况
- 移动中的角色碰撞到将其弹入其路径之外的扇区的物体。角色如何知道发生了这种情况并请求新路径?
- 一个演员已逃逸/隧道逸出你的世界(其翻译超出MapDimensions边界),应该销毁还是重新定位使其在边界内?
功能
serde
- 为某些数据类型启用序列化ron
- 启用从文件中读取CostField
。注意:在.ron中的固定大小数组被写入为元组csv
- 通过从csv文件目录中读取,创建所有CostFields
。注意:csv文件名需要遵循column_row.csv
的部门ID约定,下划线很重要,目录的路径应该是完全限定的,并且文件本身不应包含任何标题2d
- 在2d世界中处理Flowfields时启用接口方法,此外还允许使用Bevy 2d网格列表初始化Flowfields3d
- 在3d世界中处理FlowFields时启用接口方法heightmap
- 允许从灰度png/jpeg初始化CostField
,其中图像的每个像素代表一个FieldCell
。Alpha通道是可选的(如果包含在图像中,将被忽略)。颜色通道为(0, 0, 0, 255)
(黑色)表示不可通行的255
成本,而(255, 255, 255, 255)
(白色)表示1
成本,介于两者之间的通道值将代表更昂贵的成本
性能
基准测试分为两类
- 数据初始化
- init_cost_fields - 测量初始化100x100部门
CostFields
所需的时间 - init_portals - 测量在100x100部门中构建
Portals
所需的时间 - init_portal_graph - 测量构建100x100部门的
PortalGraph
所需的时间 - init_bundle - 测量准备好
FlowFieldTilesBundle
所需的总时间
- init_cost_fields - 测量初始化100x100部门
- 算法使用 - 测量生成一组FlowFields
- calc_route - 测量从一个100x100部门布局的一个角落生成到对角角落的路线所需的时间
- calc_flow_open - 测量创建一组完整的
FlowFields
所需的时间,这些字段描述了在均匀CostFields
(成本 = 1)上从一角到另一角的运动 - calc_flow_sparse - 测量创建一组完整的
FlowFields
所需的时间,这些字段描述了在包含不可通行地块块的多个部门中运动 - calc_flow_maze - 测量创建一个完整的描述在100x100区域世界中从一个角落移动到另一个角落所需的时间
FlowFields
。世界由垂直走廊组成,这意味着演员必须上下移动,最终蜿蜒到达目标
目前最慢的部分是生成 PortalGraph
(在我的机器上7秒),因此这应该是幕后发生的一些初始化(例如加载界面或其他类似内容)。
根据路径复杂性的不同,我看到的 FlowField
生成时间在5-90毫秒之间。
授权协议
MIT和Apache双授权。
依赖项
~35–73MB
~1.5M SLoC