#路径搜索 #bevy #导航 #2d-array #游戏引擎 #bevy-plugin #field-value

bevy_flowfield_tiles_plugin

Bevy游戏引擎的插件实现FlowField(矢量场)路径搜索

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 游戏开发

Download history 1/week @ 2024-06-05 167/week @ 2024-06-12 15/week @ 2024-06-19 122/week @ 2024-07-03 4/week @ 2024-07-10 102/week @ 2024-07-24 48/week @ 2024-07-31

每月150次下载

MIT/Apache

350KB
6K SLoC

crates.io docs.rs MIT/Apache 2.0 GitHub Workflow Status (with event) GitHub Workflow Status (with event)

e

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

crgifsgif3sgif

目录

  1. 简介
  2. 有用定义
  3. 设计/流程
  4. 使用
  5. 功能
  6. 性能
  7. 许可

简介

在游戏中,路径搜索可以采取不同的形式,这些形式与它们应用的游戏类型有一定的优势。通常人们会遇到

  • 路径点图 - 空间中的点相互连接,结构非常严格,一个角色将从一点移动到另一点。非常适合在小型网格上玩的游戏,需要将移动限制在精确的线条上,当多个角色共享路径时将会很繁琐 - 尤其是当角色有某种类型的碰撞系统时
  • 导航网格 - 从游戏世界中的网格拓扑结构生成的可通行表面,定义了有效的移动区域。它允许在网格限制内进行一系列动态移动,并且是路径点图的天然进化
  • FlowField Tiles - 一种通过生成描述角色如何穿越世界的流场(矢量场)来处理人群和集群行为的手段。大量角色可以同时流向一个端点,同时共享相同的路径数据结构 - 节省计算资源和时间

对于越来越大的环境,以及路径选择者数量的增加,采用基于FlowField的方法可能有益,因为它促进了数据共享和类似群体移动的形成。FlowField瓦片很复杂,它实际上类似于流体力学,因此这是将一种无差别的实现带到Bevy游戏引擎的尝试。我这么做的原因是,我最近为原型实现了一个路径点图。为了实现“不错的”演员移动,它必须由1600万个数据点组成。为了防止演员偶尔在游戏世界中进行之字形移动,必须将精度提高到8000万个数据点,以创造一个“逼真”的移动印象。那真是太愚蠢了,所以我开始研究路径查找的历史,随后我偶然发现了FlowField瓦片,并决定尝试用我最喜欢的语言和引擎实现它。

有用定义

  • 区域 - 由三个二维数组(称为字段)组成的游戏世界的一部分(CostFieldIntegrationFieldFlowField)。一个游戏世界实际上由多个区域表示
  • 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:(,)

sectors

同样,对于一个具有(300, 550)分辨率和10的世界,您将看到30列和55行。将世界划分为扇区(而不是将整个世界视为一个巨大的Flowfield)的优势在于,生成路径的工作可以分解成多个操作,并且只需触及某些扇区。比如说,对于一个具有(300, 550)大小的世界,您将其视为一个单独的场集——在计算路径时,您可能需要计算165,000个场单元格的Flowfield值。将其划分为扇区可能意味着您的路径只需通过20个扇区,因此只需计算2,000个Flowfield场单元格。

CostField

点击展开!

CostField是一个8位值的MxN二维数组,默认情况下这是一个10x10数组。这些值表示通过该场单元格的成本。值为1是默认值,表示最容易的成本,而值为255是一个特殊值,用于表示该场单元格不可通行——这可以用来表示墙壁或障碍物。从2-254的所有其他值表示增加的成本,例如斜坡或难以通行的地形,如湿地。路径查找计算的理念是在任何其他单元格之前优先考虑具有较小值的单元格。

cf

在运行时,为每个区域生成一个默认值为 - 的 CostField,尽管使用 ron 功能可以从磁盘加载字段,或者使用 heightmap 功能可以使用灰度 png/jpeg 文件作为种子来生成字段。有关在初始遍历(例如加载关卡时)和游戏过程中调整 CostFields 的详细信息,请参阅下面的 使用说明 部分。当放置墙壁或地面裂缝等障碍物时,一个细胞会翻转到一个更高的成本或不可通过的成本 255

此数组用于在请求可导航路径时生成 IntegrationField

传送门

点击展开!

每个区域最多有 4 个与相邻区域的边界(当区域位于角落或游戏世界边缘时数量更少)。每个边界可以包含传送门,表示从当前区域到相邻区域的可导航点。传送门具有双重功能,其中之一是提供响应性 - FlowFields 的生成可能需要时间,因此当演员需要快速移动时,可以使用基于从一个传送门移动到另一个传送门的快速 A* 路径查询,并基于此生成初始路径路由,然后演员可以开始向目标/终点的一般方向移动。一旦 FlowFields 已构建,演员可以切换到使用它们进行细粒度导航。

以下区域位于世界边缘之外,这意味着每个边界都可以有传送门(紫色单元格)

portalsportals

传送门生成在边界的中间 - 在 CostField 中沿边缘包含 255 成本的情况下,则可能在边界的每个有效路径段的中间生成多个传送门,并将其传播到相邻区域,以便每个传送门都有一个相邻的伙伴(如上图中右侧的区域所示,S(1, 1) 传送门 (9, 1) 允许进入 S(2, 1) 传送门 (0, 1),尽管 S(2, 1) 的整个边界看起来是完全可通行的)。

在更大的规模上(但仍然是小的)和最简单的 CostField 中,一个 2x2 区域网格会产生可预测的边界传送门。

sector_portals

传送门图

为了在传送门级别从一个区域到另一个区域找到路径,所有区域的传送门都记录在一个称为 PortalGraph 的数据结构中。传送门存储为节点,并在这之间创建边来表示可通行路径,它分为三个阶段构建

  1. 为所有传送门添加一个图 节点
  2. 为每个区域创建从每个传送门 节点 到和来自的 (路径) - 实际上创建了每个区域的内部可步行路线
  3. 在所有扇区边界(从一个扇区到另一个扇区的可行路径)上创建通过门户的edges

这允许使用source扇区和target扇区进行查询,并返回一个可路径化的门户列表。当CostField发生变化时,这将触发所在区域(以及其邻居以确保边界均匀)的扇区门户的重新生成,并使用任何新的门户nodes更新图,并删除旧的门户。

IntegrationField

点击展开!

IntegrationField是一个16位值的MxN 2D数组。它使用CostField来生成达到最终目标/目标的累积成本。它是一个瞬时字段,因为它只为所需的扇区构建,然后由FlowField计算消耗。

当需要处理新路线时,字段值设置为u16::MAX,目标字段单元格设置为0

从目标开始执行一系列遍历,作为一个扩展的波前来计算字段值。

  1. 确定目标的合法序号邻居(北、东、南、西 - 当不是对扇区/世界边界时)。
  2. 对于每个序号字段单元格查找其CostField值。
  3. CostField成本添加到当前单元格的IntegrationFields成本中(开始时这是目标整数成本0)。
  4. 传播到下一个邻居,找到它们的序号,并重复将它们的成本值添加到当前单元格的集成成本中,以生成它们的累积集成成本,直到整个字段完成。

这产生了一个漂亮的菱形图案,因为波扩散(这里的底层CostField设置为1)。

ifp0ifp1 ifp2ifp3

现在,一个类似菱形的波在动态运动的世界中并不完全真实,所以应该在某个时候替换它。根据各种文章,人们似乎采用Eikonal方程来在字段空间上创建一个更球形的波。

CostField包含不可穿越的标记时,例如作为黑盒的255,它们被忽略,所以波会绕过这些区域流动。

ifpi

并且当你的CostField使用一系列值来表示不同的穿越区域时,例如陡峭的小山。

cfhifph

所以这鼓励路径算法绕过你的世界中的障碍物和昂贵区域!

这涵盖了计算包含目标的单个扇区的IntegrationField,但当然演员可能在一个遥远的扇区,这就是Portals重新发挥作用的地方。

PortalGraph中,我们可以获取一个Portals路径来引导演员穿过几个扇区到达所需的扇区,扩展上述目标扇区的IntegrationField的计算,接下来我们“跳跃”通过边界Portals,从目标扇区向后到演员扇区(门户用紫色表示)产生一系列IntegrationFields,描述链式扇区的流动运动。

ifsts0ifsts1ifsts2

在路径查找方面,角色将优先选择“向下”流动。从角色的位置出发,观察其领域的邻近细胞,在该区域的IntegrationField中的较小值意味着达到终点更有利的位置,从较小的值到更小的值,基本上是一个流向目的地的梯度。

这构成了FlowField的基础。

以一个30x30的世界为例,目标位于0,角色位于A,一个询问所有区域PortalsIntegrationField可能产生一组看起来类似的字段:

ifpbe

注意从目标传播出的酷炫波浪!

程序化生成这些字段会导致

gif

注意我们不需要为角色不需要路径穿越的区域生成字段。另外,一个门户代表可穿越区域边界的中间点,在生成字段时,我们将门户扩展以覆盖其整个段落 - 这提高了效率,使得角色可以更直接地接近其目标,而不是曲折地走向门户边界点。

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

gff

每个单元格图标较细的部分表示流动方向。行动者沿着指向目标的流动线运行。这意味着对于一组行动者,他们将以类似编队的行为,沿着流动线向目标流动。

路由 & 流场缓存

点击展开!

为了使行动者能够重用 FlowFields(从而避免重复计算),使用了一对缓存来存储路径数据。

  1. 路由缓存 - 当行动者请求去某个地方时,会生成一个高级路由,描述要穿越的整体系列区域门户(PortalGraph A*)。如果尚未计算 FlowField,则行动者可以使用 route_cache 作为后备,以获得他们应该开始移动的通用方向。一旦构建了 FlowFields,他们就可以切换到使用更细粒度的路径。待办事项:删除《此外,《code>CostFields 的更改可能会改变门户位置和最佳路径,因此需要为 FlowFields 重新生成相关区域的数据结构,在重新生成步骤中,行动者可以再次使用高级路由作为后备。

  2. 字段缓存 - 对于路由的每个区域到门户部分,都会构建一个 FlowField 并存储在缓存中。行动者可以轮询此缓存以获取到达目标的真实流动方向。一个角色控制器/引导管道负责解释 FlowField 的值以产生运动 - 虽然此插件包含一个引导管道,但实际情况是每个游戏都有自己的运动怪癖和需求,因此您很可能想要构建自己的管道。此插件的实际目的是封装数据结构和逻辑,以创建一个行动者可以通过其自己的实现读取的 FlowField

请注意,缓存中存储的数据具有时间戳 - 如果记录超过 15 分钟,则将其删除以减少大小并提高查找效率。在实现引导管道/角色控制器以解释 FlowFields 时,您可能需要考虑这些旧路由/路径已过期。

行动者大小

点击展开!

在模拟中,您可能有不同大小的行动者以及不可穿越的墙壁之间的间隙,考虑这些紫色行动者。

asp

左边的较小行动者显然可以通过不可穿越的地形之间的间隙。然而,右边的行动者要大得多,因此在处理 PathRequest 时,仅应考虑具有适当间隙的路线(否则,如果有碰撞系统,它只会撞到侧面的墙壁,而无法通过)。

为了处理此问题,定义各种字段尺寸的总体 MapDimenions 组件包含一个 actor_scale 参数。这种缩放由行动者大小和字段内单元格的单位大小确定。例如,具有 640x640 像素尺寸的区域表示 (m, n) -> (10, 10) 字段中的每个单元格代表一个 64x64 像素的像素区域,如果行动者的宽度大于 64 像素,则将应用行动者大小和单元格大小的比率来“扩展”不可穿越的单元格,以封闭对行动者来说太小的间隙,无法通过。

在请求路线后,行动者可以看到的是,左边的较小行动者可以通过间隙进行路径搜索,而右边的较大行动者会寻找替代路线。

aspo

在一个包含多种大小角色的游戏中,您将希望从 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 并添加您需要的任何功能,要实际与计算字段接口,您应该根据您世界的坐标系启用 2d3d

[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 = 1280map_depth 使用类似的计算 64 * 20 = 1280。至于分辨率,它将取决于您想要多精细,在这个例子中,一个 10x10CostField 应该覆盖精确数量的精灵,所以我们使用精灵大小来找到分辨率 64 * 10 = 640
    • 3D:一个大小为 (780x440) 的世界,分辨率为 10,可以产生 78x44 个扇区。由于字段是 10x10 的数组,这相当于一个代表 1x1 单位面积的 FieldCell

在某个系统内部,您可以生成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场景,您可能希望启用 roncsvheightmap 功能,这允许从 .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网格列表初始化Flowfields
  • 3d - 在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 所需的总时间
  • 算法使用 - 测量生成一组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