元素配方索引机制

基本信息

属性 值
类型 特殊机制(NEI 查询索引)
核心类 ItemsContainingAspectHandler、TemplateThaumHandler.CachedThaumRecipe
进度状态类 com.gtnewhorizons.aspectrecipeindex.client.ThaumcraftHooks
触发时机 惰性查询时(无预建索引、无加载期缓存表)

结论先行:它没有维护一张"配方索引表"

尽管模组名叫 “Aspect Recipe Index”,源码里不存在任何长期存活的 Map<Aspect, List<Recipe>> 之类的结构。实际机制是每次查询即时线性扫描 + 惰性分页:

  • 正向(元素 → 配方):ItemsContainingAspectHandler 遍历玩家自己的扫描记录算出含该元素的物品。
  • 反向(元素 → 配方/用途):其余 6 个 handler 每次都把 ThaumcraftApi.getCraftingRecipes() 全量遍历一遍,按配方类型和元素集合匹配。
  • 元素物品的"命中判定"发生在 CachedThaumRecipe.contains / containsWithNBT 里,走的是内存中的 AspectList,不是索引表。

下面分层说明。

第 1 层:数据源是玩家的扫描记录

ItemsContainingAspectHandler.findContainingItemStacks(Aspect) 的唯一数据源:

  • Thaumcraft.proxy.getScannedObjects().get(Util.getUsername()) — 玩家研究存档里的已扫描对象列表,List<String>。
  • 每个字符串是一个物品 hash 缓存键,首字符是前缀:@ 与 # 两种。源码注释举例 @12921929129。
  • 若列表为 null(尚未初始化)直接返回空。

这意味着结果按玩家不同:别人的扫描记录不会被索引进来。服务器无法预生成一份全局索引,因为内容依赖玩家进度。

第 2 层:逐条 hash → ItemStack → AspectList

findContainingItemStacks 的转换过程:

  1. itemStackCache.substring(1) 去掉前缀字符。
  2. Integer.parseInt 转成 hash;解析失败(NumberFormatException)静默跳过。
  3. GuiResearchRecipe.getFromCache(hash) 反查 ItemStack;null 跳过。
  4. ThaumcraftCraftingManager.getObjectTags(stack) 取物品自身元素。
  5. 再过 ThaumcraftCraftingManager.getBonusTags(stack, tags) 叠加罐装 / 瓶装等容器附带的 essentia。
  6. stackSize = tags.getAmount(aspect);小于等于 0 则跳过(不排除该物品,只是不显示这一格)。
  7. stacks.sort(...) 按 stackSize 降序排列——所以 UI 顶部总是"该元素含量最高"的物品。

复杂度上界是"该玩家扫描过的物品数",每次查询都重算一次。

第 3 层:36 格分页 + 链表

AspectCachedRecipe 是内部类常量 STACKS_COUNT = 36 的分页单元:

  • 构造函数接收 start 偏移量,切片区间为 [start, start + 36)(getItemsInInterval)。
  • 用一个 next 字段串成单向链表:initStackList 在 start + 36 < list.size() 时惰性创建下一页。
  • 每一页都是独立的 CachedRecipe,getResult() 返回同一个元素物品(放在 OUTPUT_X, 5),所以 NEI 的"配方翻页"直接生效。
  • 网格布局 9 列 × 4 行:x = 起始X + 1 + (i % 9) * 18,y = 起始Y + 1 + (i / 9) * 18(源码注释标明这是"像原版那样留一点上边距")。
  • 背景板 aspectrecipeindex:textures/gui/itemstack_background.png,尺寸 163×74;OUTPUT_X = 75,所以 STACKS_OVERLAY_START_X 落在 1 附近,START_Y = 116 - 74 = 42。
  • 每一页都挂同一个前置研究 ResearchCategories.getResearch("ASPECTS"),且恒定标记为已完成(true)。

第 4 层:元素可见性过滤

在生成任何分页之前先过 Util.shouldShowAspect(aspect):

  • 叠加列表加载时,对 Aspect.aspects.values() 里每个元素逐一过滤,未发现的直接 continue。
  • 单个元素物品查询时,先 ItemAspect.getAspect(ingredient) 再过滤,且结果为空时不生成任何页面。

判定式:showUndiscoveredAspectRecipes || hasDiscoveredAspect(当前用户名, aspect)。

第 5 层:加载进度与自动刷新

Thaumcraft 在客户端有一个后台 MappingThread 在重建物品 hash 映射。ARI 用一个 LATE mixin 挂进去,把进度暴露成一个静态三元组 ThaumcraftHooks:

字段 含义 写入方
totalToLoad 待加载条目数 = MappingThread.idMappings.size() mixin 在 run() 开头
itemsLoaded 已迭代条数 mixin 在 Iterator.next() 处自增
allDataLoaded 是否跑完 mixin 在 run() 结尾置 true

ThaumcraftHooks 里标 @ApiStatus.Internal 的三个 setter 是给 mixin 用的,不是对外 API;对外只有两个 getter 加一个 isDataLoaded()。

刷新策略在 ItemsContainingAspectHandler.onUpdate():

  • isDataLoaded() 为真 → 直接返回,完全不做事。
  • 未完成时,每 200 tick(10 秒)只重算第一个缓存配方(arecipes.get(0))的元素列表,然后 initStackList 重建它自己及后续全部分页。
  • 界面上 drawBackground 绘制 Still loading... (已加载/总数),取自 lang 键 aspectrecipeindex.items_containing_aspect.still_load。

所以在 MappingThread 跑完之前,界面上看到的内容是边加载边变的,且只刷新第一页的来源列表。

第 6 层:其余 handler 的"伪索引"

另外 6 个 recipe handler 没有分页结构,它们是纯线性扫描:

Handler 扫描对象 元素匹配方式
Alchemy ThaumcraftApi.getCraftingRecipes() 里所有 CrucibleRecipe recipe.catalystMatches(ingredient) 或 recipe.aspects 与 Util.getEssentiaFromItem(ingredient) 交集
Infusion 所有 InfusionRecipe(先经 TC4RecipeLib 转换) 中心物品匹配 / 外围 RecipeIngredient 匹配 / essentia 交集
Shaped Arcane 所有 ShapedArcaneRecipe 匿名子类覆写 isValid() 加 contains(ingredients, ingredient) 与研究判定
Shapeless Arcane 所有 ShapelessArcaneRecipe 同上,改用 containsWithNBT
Wand 杖柄 × 杖帽笛卡尔积,或 GTNH-TC-Wands 的 wrapper 列表 OreDictionary.itemMatches 匹配组件;元素物品走"原始元素"分支
Aspect Combination Aspect.getCompoundAspects() ArrayUtils.contains(components, aspect)

共用的两个技巧(在 TemplateThaumHandler.CachedThaumRecipe 上):

  • contains / containsWithNBT 对 ItemAspect 走特判:直接查内存里的 aspects.aspects.containsKey(aspect),不走普通物品匹配。这就是"拿元素物品当搜索词"能命中的原因。
  • setIngredientPermutation 对 ItemAspect 直接 return——元素物品永远不会被当成真实材料摆进合成格。

相关条目