diff --git a/README.md b/README.md index ab24f5f07..75561af84 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,30 @@ public record ExamplePayload(Item item, int count) { 通过 `CubeSelection.enableNamespace("your_mod")` 启用;`registerTargetExclusion` 可按 ID 注册、替换和注销运行时排除规则,而无需丢弃模型缓存。 几何共享、BVH 射线检测和后台轮廓任务均有数量及内存预算。26.2 使用 `CuboidModelElement`、`BlockStateModel` 和高亮状态提取接口。 +Cube 资源配置位于 `assets//anvillib/cube_selection.json`。KubeJS 整合包可使用 +`kubejs/assets/example/anvillib/cube_selection.json`,通过 `F3+T` 与模型一起重载: + +```json +{ + "namespaces": { "kubejs": "model" }, + "blocks": { + "kubejs:example": "outline", + "kubejs:interaction_proxy": "vanilla" + } +} +``` + +- `model`:精确模型选取与模型描边;`outline`:原有选取与模型描边;`vanilla`:原有选取与描边。 +- 方块规则优先于命名空间规则,未配置项使用 Java API 注册的默认值,默认不启用其他方块。 +- 按资源包从低到高逐项合并,同一资源包内按配置资源 ID 的字典序合并,后者覆盖同一键;缺省项保留。 +- 配置文件移除后,下次重载恢复下层配置或 Java 默认值。非法字段、模式或资源 ID 会记录配置路径和资源包,整份文件不生效。 +- 可以预先配置未安装模组的方块 ID;配置不会注册方块。配置内容仅控制客户端模型选取,不改变服务端碰撞与玩法逻辑。 +- `exclude` 的程序排除优先于配置;`registerTargetExclusion` 仍同时排除精确选取与默认描边,不删除模型缓存。 +- `registerDynamic` 继续提供动态部件,资源规则不会覆盖 provider;其 `includeStaticModel` 参数仍决定是否合并静态模型。 +- `target` 仅返回精确选取目标;`outlineTarget` 同时支持 `model` 与 `outline`,供描边和预览使用;`isEnabled` 表示是否启用模型几何。 +- 配置与模型在同一次资源重载完成后发布;不支持的模型或超出既有限额的几何保留原版行为。此配置不增加自定义模型加载器或 BER 的自动解析能力。 + + ### RPC 模块 提供 `@RemoteCallable` 方法注册、双向调用、异步返回值以及 `RPC.invokeSync` / `invokeSyncByName` 同步等待。 diff --git a/module.cube/src/main/java/dev/anvilcraft/lib/v2/cube/client/CubeHighlightEvents.java b/module.cube/src/main/java/dev/anvilcraft/lib/v2/cube/client/CubeHighlightEvents.java index af7fab640..5aa7ec835 100644 --- a/module.cube/src/main/java/dev/anvilcraft/lib/v2/cube/client/CubeHighlightEvents.java +++ b/module.cube/src/main/java/dev/anvilcraft/lib/v2/cube/client/CubeHighlightEvents.java @@ -26,7 +26,7 @@ public static void highlight(ExtractBlockOutlineRenderStateEvent event) { if (minecraft.gui.hud.isHidden()) return; BlockPos pos = event.getBlockPos(); float partial = CubeSelection.frameFraction(event.getLevel(), minecraft.getDeltaTracker().getGameTimeDeltaPartialTick(true)); - CubeSelection.Target target = CubeSelection.target(event.getLevel(), pos, event.getBlockState(), partial); + CubeSelection.Target target = CubeSelection.outlineTarget(event.getLevel(), pos, event.getBlockState(), partial); if (target == null) return; List parts = target.parts(); PackedOutline[] outlines = new PackedOutline[parts.size()]; diff --git a/module.cube/src/main/java/dev/anvilcraft/lib/v2/cube/client/CubeModelEvents.java b/module.cube/src/main/java/dev/anvilcraft/lib/v2/cube/client/CubeModelEvents.java index 186130007..695ff6b7b 100644 --- a/module.cube/src/main/java/dev/anvilcraft/lib/v2/cube/client/CubeModelEvents.java +++ b/module.cube/src/main/java/dev/anvilcraft/lib/v2/cube/client/CubeModelEvents.java @@ -2,6 +2,7 @@ import dev.anvilcraft.lib.v2.cube.AnvilLibCube; import dev.anvilcraft.lib.v2.cube.client.model.ModelSelections; +import net.minecraft.client.Minecraft; import net.neoforged.api.distmarker.Dist; import net.neoforged.bus.api.EventPriority; import net.neoforged.bus.api.SubscribeEvent; @@ -12,17 +13,21 @@ @EventBusSubscriber(modid = AnvilLibCube.MOD_ID, value = Dist.CLIENT) public final class CubeModelEvents { + private static CubeSelectionRules pendingRules = CubeSelectionRules.EMPTY; private static ModelSelections.Snapshot pending = new ModelSelections.Snapshot(Map.of(), 0, 0); private CubeModelEvents() { } @SubscribeEvent(priority = EventPriority.LOWEST) public static void prepare(ModelEvent.ModifyBakingResult event) { - pending = ModelSelections.bake(event.getBakingResult().blockStateModels()); + CubeSelectionRules loaded = CubeSelectionRules.load(Minecraft.getInstance().getResourceManager()); + pending = ModelSelections.bake(event.getBakingResult().blockStateModels(), block -> CubeSelection.isEnabled(block, loaded)); + pendingRules = loaded; } @SubscribeEvent public static void apply(ModelEvent.BakingCompleted event) { - CubeSelection.install(pending); + CubeSelection.install(pending, pendingRules); + pendingRules = CubeSelectionRules.EMPTY; pending = new ModelSelections.Snapshot(Map.of(), 0, 0); } } diff --git a/module.cube/src/main/java/dev/anvilcraft/lib/v2/cube/client/CubePicking.java b/module.cube/src/main/java/dev/anvilcraft/lib/v2/cube/client/CubePicking.java index 28a1f4949..b03ecf6f4 100644 --- a/module.cube/src/main/java/dev/anvilcraft/lib/v2/cube/client/CubePicking.java +++ b/module.cube/src/main/java/dev/anvilcraft/lib/v2/cube/client/CubePicking.java @@ -18,12 +18,16 @@ private CubePicking() { } /** 只包围准星射线调用,普通碰撞、服务端和投射物射线不读取客户端模型。 */ public static HitResult pick(Entity entity, double range, float partialTick, boolean fluids) { - if (!(entity.level() instanceof ClientLevel level) || !CubeSelection.active() || range > MAX_RANGE) { + if (!(entity.level() instanceof ClientLevel level)) { return entity.pick(range, partialTick, fluids); } Context context = CONTEXT.get(); if (context.level != null) return entity.pick(range, partialTick, fluids); + // 仅描边模式也需要逐帧更新动态几何,不能在清理帧缓存之前跳过精确选取。 CubeSelection.beginFrame(level, partialTick); + if (!CubeSelection.active() || range > MAX_RANGE) { + return entity.pick(range, partialTick, fluids); + } context.level = level; context.partialTick = partialTick; try { diff --git a/module.cube/src/main/java/dev/anvilcraft/lib/v2/cube/client/CubeSelection.java b/module.cube/src/main/java/dev/anvilcraft/lib/v2/cube/client/CubeSelection.java index 63119f2fd..34e9f61e2 100644 --- a/module.cube/src/main/java/dev/anvilcraft/lib/v2/cube/client/CubeSelection.java +++ b/module.cube/src/main/java/dev/anvilcraft/lib/v2/cube/client/CubeSelection.java @@ -26,7 +26,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.function.Predicate; -/** 仅在客户端初始化时注册命名空间;注册后的模型默认同时启用精确拾取和棱线高亮。 */ +/** 资源包控制选取与描边模式;客户端注册接口提供默认启用范围及动态几何。 */ public final class CubeSelection { public static final int MAX_PARTS = 32; private static final Set NAMESPACES = ConcurrentHashMap.newKeySet(); @@ -35,10 +35,12 @@ public final class CubeSelection { private static final Map DYNAMIC = new ConcurrentHashMap<>(); private static final OutlineCache OUTLINES = new OutlineCache(); private static final Long2ObjectOpenHashMap FRAME = new Long2ObjectOpenHashMap<>(); + private static CubeSelectionRules rules = CubeSelectionRules.EMPTY; private static Map models = Map.of(); private static Map extended = Map.of(); private static @Nullable ClientLevel frameLevel; private static int radius; + private static boolean pickingActive; private static float partialTick; private static long geometryBytes; private static int unsupportedStates; @@ -91,11 +93,29 @@ public static void registerDynamic(Block block, AABB maximumBounds, boolean incl throw new IllegalArgumentException("Dynamic bounds including model offset must lie in [-2, 3]"); } DYNAMIC.put(block, new Dynamic(maximumBounds, includeStaticModel, provider)); - radius = Math.max(radius, reach(maximumBounds)); + if (mode(block, rules) == CubeSelectionRules.Mode.MODEL) { + pickingActive = true; + radius = Math.max(radius, reach(maximumBounds)); + } } + /** 是否启用模型几何;outline 模式仍需烘焙并可由自定义描边器读取。 */ public static boolean isEnabled(Block block) { - return !EXCLUDED.contains(block) && (NAMESPACES.contains(BuiltInRegistries.BLOCK.getKey(block).getNamespace()) || DYNAMIC.containsKey(block)); + return isEnabled(block, rules); + } + + static boolean isEnabled(Block block, CubeSelectionRules selectionRules) { + return mode(block, selectionRules) != CubeSelectionRules.Mode.VANILLA; + } + + private static CubeSelectionRules.Mode mode(Block block, CubeSelectionRules selectionRules) { + if (EXCLUDED.contains(block)) { + return CubeSelectionRules.Mode.VANILLA; + } + var id = BuiltInRegistries.BLOCK.getKey(block); + CubeSelectionRules.Mode fallback = NAMESPACES.contains(id.getNamespace()) || DYNAMIC.containsKey(block) + ? CubeSelectionRules.Mode.MODEL : CubeSelectionRules.Mode.VANILLA; + return selectionRules.mode(id, fallback); } public static boolean supportedBounds(AABB bounds) { @@ -105,6 +125,11 @@ public static boolean supportedBounds(AABB bounds) { && bounds.maxX <= 3 && bounds.maxY <= 3 && bounds.maxZ <= 3; } + static void install(ModelSelections.Snapshot snapshot, CubeSelectionRules selectionRules) { + rules = selectionRules; + install(snapshot); + } + public static void install(ModelSelections.Snapshot snapshot) { OUTLINES.clear(); FRAME.clear(); @@ -114,7 +139,12 @@ public static void install(ModelSelections.Snapshot snapshot) { unsupportedStates = snapshot.unsupportedStates(); Map overhang = new IdentityHashMap<>(); radius = 0; + pickingActive = false; models.forEach((state, selection) -> { + if (mode(state.getBlock(), rules) != CubeSelectionRules.Mode.MODEL) { + return; + } + pickingActive = true; int reach = reach(selection.bounds()); // 原版模型偏移由位置决定,不可烘入共享几何。 if (state.hasOffsetFunction()) reach = Math.min(2, reach + 1); @@ -122,7 +152,12 @@ public static void install(ModelSelections.Snapshot snapshot) { }); extended = overhang; for (int value : extended.values()) radius = Math.max(radius, value); - for (Dynamic dynamic : DYNAMIC.values()) radius = Math.max(radius, reach(dynamic.bounds)); + DYNAMIC.forEach((block, dynamic) -> { + if (mode(block, rules) == CubeSelectionRules.Mode.MODEL) { + pickingActive = true; + radius = Math.max(radius, reach(dynamic.bounds)); + } + }); } public static void beginFrame(ClientLevel level, float fraction) { @@ -139,7 +174,8 @@ public static void clearWorld() { public static OutlineCache outlines() { return OUTLINES; } public static int searchRadius() { return radius; } - public static boolean active() { return !models.isEmpty() || !DYNAMIC.isEmpty(); } + /** 当前快照是否需要执行模型精确拾取;仅描边的几何不会启动邻格补扫。 */ + public static boolean active() { return pickingActive; } static @Nullable ModelSelection modelForState(BlockState state) { return models.get(state); } @@ -155,12 +191,24 @@ public static void clearWorld() { static float frameFraction(ClientLevel level, float fallback) { return frameLevel == level ? partialTick : fallback; } public static boolean extendsBlock(BlockState state) { + if (mode(state.getBlock(), rules) != CubeSelectionRules.Mode.MODEL) { + return false; + } Dynamic dynamic = DYNAMIC.get(state.getBlock()); return extended.containsKey(state) || dynamic != null && reach(dynamic.bounds) > 0; } + /** 精确拾取入口;仅描边模式保留方块原有射线选取。 */ public static @Nullable Target target(ClientLevel level, BlockPos pos, BlockState state, float fraction) { - if (isTargetExcluded(state)) return null; + if (mode(state.getBlock(), rules) != CubeSelectionRules.Mode.MODEL) { + return null; + } + return outlineTarget(level, pos, state, fraction); + } + + /** 描边与预览共用的几何入口;运行时排除规则仍同时约束选取和默认描边。 */ + public static @Nullable Target outlineTarget(ClientLevel level, BlockPos pos, BlockState state, float fraction) { + if (!isEnabled(state.getBlock()) || isTargetExcluded(state)) return null; if (frameLevel != level || fraction != partialTick) beginFrame(level, fraction); Target cached = FRAME.get(pos.asLong()); if (cached != null && cached.state == state) return cached; diff --git a/module.cube/src/main/java/dev/anvilcraft/lib/v2/cube/client/CubeSelectionRules.java b/module.cube/src/main/java/dev/anvilcraft/lib/v2/cube/client/CubeSelectionRules.java new file mode 100644 index 000000000..d5ab243bd --- /dev/null +++ b/module.cube/src/main/java/dev/anvilcraft/lib/v2/cube/client/CubeSelectionRules.java @@ -0,0 +1,96 @@ +package dev.anvilcraft.lib.v2.cube.client; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.mojang.logging.LogUtils; +import net.minecraft.resources.Identifier; +import net.minecraft.server.packs.PackResources; +import net.minecraft.server.packs.resources.Resource; +import net.minecraft.server.packs.resources.ResourceManager; + +import java.io.IOException; +import java.io.Reader; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** 规则与本轮烘焙几何一起发布,避免资源重载过程中使用不同批次的数据。 */ +record CubeSelectionRules(Map namespaces, Map blocks) { + static final CubeSelectionRules EMPTY = new CubeSelectionRules(Map.of(), Map.of()); + private static final String PATH = "anvillib/cube_selection.json"; + + CubeSelectionRules { + namespaces = Map.copyOf(namespaces); + blocks = Map.copyOf(blocks); + } + + Mode mode(Identifier block, Mode fallback) { + return this.blocks.getOrDefault(block, this.namespaces.getOrDefault(block.getNamespace(), fallback)); + } + + static CubeSelectionRules load(ResourceManager resources) { + List packs = resources.listPacks().map(PackResources::packId).toList(); + List sources = new ArrayList<>(); + resources.listResourceStacks("anvillib", id -> id.getPath().equals(PATH)).forEach((id, stack) -> { + for (Resource resource : stack) { + sources.add(new Source(id, resource, packs.indexOf(resource.sourcePackId()))); + } + }); + // 先按资源包排序,保证不同配置命名空间也遵守资源包优先级;同一包内按资源 ID 排序。 + sources.sort(Comparator.comparingInt(Source::priority).thenComparing(source -> source.id().toString())); + CubeSelectionRules rules = EMPTY; + for (Source source : sources) { + try (Reader reader = source.resource().openAsReader()) { + rules = rules.merge(JsonParser.parseReader(reader).getAsJsonObject()); + } catch (IOException | RuntimeException exception) { + LogUtils.getLogger().warn("Unable to load cube selection rules {} from {}; keeping lower-priority rules", + source.id(), source.resource().sourcePackId(), exception); + } + } + return rules; + } + + CubeSelectionRules merge(JsonObject json) { + for (String key : json.keySet()) { + if (!key.equals("namespaces") && !key.equals("blocks")) { + throw new IllegalArgumentException("Unknown cube selection field: " + key); + } + } + Map namespaces = new HashMap<>(this.namespaces); + Map blocks = new HashMap<>(this.blocks); + if (json.has("namespaces")) { + for (var entry : json.getAsJsonObject("namespaces").entrySet()) { + Identifier.fromNamespaceAndPath(entry.getKey(), "cube_selection"); + namespaces.put(entry.getKey(), Mode.parse(entry.getValue())); + } + } + if (json.has("blocks")) { + for (var entry : json.getAsJsonObject("blocks").entrySet()) { + blocks.put(Identifier.parse(entry.getKey()), Mode.parse(entry.getValue())); + } + } + // 整份文件校验成功后才替换,非法规则不会只应用一半。 + return new CubeSelectionRules(namespaces, blocks); + } + + enum Mode { + MODEL, OUTLINE, VANILLA; + + static Mode parse(JsonElement value) { + if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) { + throw new IllegalArgumentException("Cube selection mode must be a string"); + } + return switch (value.getAsString()) { + case "model" -> MODEL; + case "outline" -> OUTLINE; + case "vanilla" -> VANILLA; + default -> throw new IllegalArgumentException("Unknown cube selection mode: " + value); + }; + } + } + + private record Source(Identifier id, Resource resource, int priority) { } +} diff --git a/module.cube/src/main/java/dev/anvilcraft/lib/v2/cube/client/model/ModelSelections.java b/module.cube/src/main/java/dev/anvilcraft/lib/v2/cube/client/model/ModelSelections.java index db002e9ee..64b9d8c54 100644 --- a/module.cube/src/main/java/dev/anvilcraft/lib/v2/cube/client/model/ModelSelections.java +++ b/module.cube/src/main/java/dev/anvilcraft/lib/v2/cube/client/model/ModelSelections.java @@ -23,6 +23,7 @@ import java.util.IdentityHashMap; import java.util.List; import java.util.Map; +import java.util.function.Predicate; public final class ModelSelections { public static final long MAX_GEOMETRY_BYTES = 16L * 1024 * 1024; @@ -36,10 +37,15 @@ public final class ModelSelections { private ModelSelections(Map sources) { this.sources = sources; } public static Snapshot bake(Map models) { + return bake(models, CubeSelection::isEnabled); + } + + /** 使用本轮资源规则筛选模型,不提前修改正在使用的选择快照。 */ + public static Snapshot bake(Map models, Predicate enabled) { ModelSelections builder = new ModelSelections(ModelCapture.take()); Map states = new IdentityHashMap<>(); for (Block block : BuiltInRegistries.BLOCK) { - if (!CubeSelection.isEnabled(block)) continue; + if (!enabled.test(block)) continue; for (BlockState state : block.getStateDefinition().getPossibleStates()) { if (states.size() >= MAX_STATES) { builder.unsupported++; continue; } BlockStateModel baked = models.get(state);