diff --git a/src/generated/resources/assets/anvilcraft/blockstates/smart_block_placer.json b/src/generated/resources/assets/anvilcraft/blockstates/smart_block_placer.json index 7abfc48a4f..f90771693d 100644 --- a/src/generated/resources/assets/anvilcraft/blockstates/smart_block_placer.json +++ b/src/generated/resources/assets/anvilcraft/blockstates/smart_block_placer.json @@ -1,64 +1,140 @@ { "variants": { - "facing=east,overload=false,powered=false": { + "facing=east,overload=false,powered=false,upside_down=false": { "model": "anvilcraft:block/smart_block_placer_bottom", "y": 90 }, - "facing=east,overload=false,powered=true": { + "facing=east,overload=false,powered=false,upside_down=true": { + "model": "anvilcraft:block/smart_block_placer_bottom", + "x": 180, + "y": 270 + }, + "facing=east,overload=false,powered=true,upside_down=false": { "model": "anvilcraft:block/smart_block_placer_bottom_off", "y": 90 }, - "facing=east,overload=true,powered=false": { + "facing=east,overload=false,powered=true,upside_down=true": { + "model": "anvilcraft:block/smart_block_placer_bottom_off", + "x": 180, + "y": 270 + }, + "facing=east,overload=true,powered=false,upside_down=false": { "model": "anvilcraft:block/smart_block_placer_bottom_overload", "y": 90 }, - "facing=east,overload=true,powered=true": { + "facing=east,overload=true,powered=false,upside_down=true": { + "model": "anvilcraft:block/smart_block_placer_bottom_overload", + "x": 180, + "y": 270 + }, + "facing=east,overload=true,powered=true,upside_down=false": { "model": "anvilcraft:block/smart_block_placer_bottom_overload", "y": 90 }, - "facing=north,overload=false,powered=false": { + "facing=east,overload=true,powered=true,upside_down=true": { + "model": "anvilcraft:block/smart_block_placer_bottom_overload", + "x": 180, + "y": 270 + }, + "facing=north,overload=false,powered=false,upside_down=false": { "model": "anvilcraft:block/smart_block_placer_bottom" }, - "facing=north,overload=false,powered=true": { + "facing=north,overload=false,powered=false,upside_down=true": { + "model": "anvilcraft:block/smart_block_placer_bottom", + "x": 180, + "y": 180 + }, + "facing=north,overload=false,powered=true,upside_down=false": { "model": "anvilcraft:block/smart_block_placer_bottom_off" }, - "facing=north,overload=true,powered=false": { + "facing=north,overload=false,powered=true,upside_down=true": { + "model": "anvilcraft:block/smart_block_placer_bottom_off", + "x": 180, + "y": 180 + }, + "facing=north,overload=true,powered=false,upside_down=false": { "model": "anvilcraft:block/smart_block_placer_bottom_overload" }, - "facing=north,overload=true,powered=true": { + "facing=north,overload=true,powered=false,upside_down=true": { + "model": "anvilcraft:block/smart_block_placer_bottom_overload", + "x": 180, + "y": 180 + }, + "facing=north,overload=true,powered=true,upside_down=false": { "model": "anvilcraft:block/smart_block_placer_bottom_overload" }, - "facing=south,overload=false,powered=false": { + "facing=north,overload=true,powered=true,upside_down=true": { + "model": "anvilcraft:block/smart_block_placer_bottom_overload", + "x": 180, + "y": 180 + }, + "facing=south,overload=false,powered=false,upside_down=false": { "model": "anvilcraft:block/smart_block_placer_bottom", "y": 180 }, - "facing=south,overload=false,powered=true": { + "facing=south,overload=false,powered=false,upside_down=true": { + "model": "anvilcraft:block/smart_block_placer_bottom", + "x": 180 + }, + "facing=south,overload=false,powered=true,upside_down=false": { "model": "anvilcraft:block/smart_block_placer_bottom_off", "y": 180 }, - "facing=south,overload=true,powered=false": { + "facing=south,overload=false,powered=true,upside_down=true": { + "model": "anvilcraft:block/smart_block_placer_bottom_off", + "x": 180 + }, + "facing=south,overload=true,powered=false,upside_down=false": { "model": "anvilcraft:block/smart_block_placer_bottom_overload", "y": 180 }, - "facing=south,overload=true,powered=true": { + "facing=south,overload=true,powered=false,upside_down=true": { + "model": "anvilcraft:block/smart_block_placer_bottom_overload", + "x": 180 + }, + "facing=south,overload=true,powered=true,upside_down=false": { "model": "anvilcraft:block/smart_block_placer_bottom_overload", "y": 180 }, - "facing=west,overload=false,powered=false": { + "facing=south,overload=true,powered=true,upside_down=true": { + "model": "anvilcraft:block/smart_block_placer_bottom_overload", + "x": 180 + }, + "facing=west,overload=false,powered=false,upside_down=false": { "model": "anvilcraft:block/smart_block_placer_bottom", "y": 270 }, - "facing=west,overload=false,powered=true": { + "facing=west,overload=false,powered=false,upside_down=true": { + "model": "anvilcraft:block/smart_block_placer_bottom", + "x": 180, + "y": 90 + }, + "facing=west,overload=false,powered=true,upside_down=false": { "model": "anvilcraft:block/smart_block_placer_bottom_off", "y": 270 }, - "facing=west,overload=true,powered=false": { + "facing=west,overload=false,powered=true,upside_down=true": { + "model": "anvilcraft:block/smart_block_placer_bottom_off", + "x": 180, + "y": 90 + }, + "facing=west,overload=true,powered=false,upside_down=false": { "model": "anvilcraft:block/smart_block_placer_bottom_overload", "y": 270 }, - "facing=west,overload=true,powered=true": { + "facing=west,overload=true,powered=false,upside_down=true": { + "model": "anvilcraft:block/smart_block_placer_bottom_overload", + "x": 180, + "y": 90 + }, + "facing=west,overload=true,powered=true,upside_down=false": { "model": "anvilcraft:block/smart_block_placer_bottom_overload", "y": 270 + }, + "facing=west,overload=true,powered=true,upside_down=true": { + "model": "anvilcraft:block/smart_block_placer_bottom_overload", + "x": 180, + "y": 90 } } } \ No newline at end of file diff --git a/src/generated/resources/assets/anvilcraft/blockstates/structure_scanner.json b/src/generated/resources/assets/anvilcraft/blockstates/structure_scanner.json index 180e2604f4..ceb543c5fa 100644 --- a/src/generated/resources/assets/anvilcraft/blockstates/structure_scanner.json +++ b/src/generated/resources/assets/anvilcraft/blockstates/structure_scanner.json @@ -1,19 +1,38 @@ { "variants": { - "facing=east": { + "facing=east,upside_down=false": { "model": "anvilcraft:block/structure_scanner", "y": 90 }, - "facing=north": { + "facing=east,upside_down=true": { + "model": "anvilcraft:block/structure_scanner", + "x": 180, + "y": 270 + }, + "facing=north,upside_down=false": { "model": "anvilcraft:block/structure_scanner" }, - "facing=south": { + "facing=north,upside_down=true": { + "model": "anvilcraft:block/structure_scanner", + "x": 180, + "y": 180 + }, + "facing=south,upside_down=false": { "model": "anvilcraft:block/structure_scanner", "y": 180 }, - "facing=west": { + "facing=south,upside_down=true": { + "model": "anvilcraft:block/structure_scanner", + "x": 180 + }, + "facing=west,upside_down=false": { "model": "anvilcraft:block/structure_scanner", "y": 270 + }, + "facing=west,upside_down=true": { + "model": "anvilcraft:block/structure_scanner", + "x": 180, + "y": 90 } } } \ No newline at end of file diff --git a/src/main/java/dev/dubhe/anvilcraft/block/entity/SmartBlockPlacerBlockEntity.java b/src/main/java/dev/dubhe/anvilcraft/block/entity/SmartBlockPlacerBlockEntity.java index aa17e71dcb..60fbb9c02f 100644 --- a/src/main/java/dev/dubhe/anvilcraft/block/entity/SmartBlockPlacerBlockEntity.java +++ b/src/main/java/dev/dubhe/anvilcraft/block/entity/SmartBlockPlacerBlockEntity.java @@ -320,12 +320,13 @@ public void loadAdditional(ValueInput input) { } public void loadAdditional(CompoundTag tag, HolderLookup.Provider ignored) { - this.loadFromTag(tag); - // 旧路径也从CompoundTag加载物品栏(向后兼容) + // 先加载物品栏,确保 tryLoadStructure 能正确访问到磁盘物品 + // 否则 loadFromTag 中加载的 cachedStructure 会被 tryLoadStructure 误判清空 loadItemsFromTag(tag, this.diskInventory); this.lastDiskItem = this.diskInventory.getItem(0).copy(); loadItemsFromTag(tag, this.bookInventory); loadItemsFromTag(tag, this.outputBookInventory); + this.loadFromTag(tag); } private void loadFromTag(CompoundTag tag) { @@ -351,10 +352,12 @@ private void loadFromTag(CompoundTag tag) { this.loadedStructure = this.loadStructureData(tag.getCompoundOrEmpty("cachedStructure")); this.loadedStructureName = tag.getStringOr("cachedStructureName", ""); if (tag.contains("cachedStructureUuid")) { - this.loadedStructureUuid = UUIDUtil.CODEC.parse(NbtOps.INSTANCE, tag.getCompoundOrEmpty("cachedStructureUuid")) - .result().orElse(null); + this.loadedStructureUuid = UUIDUtil.CODEC.parse( + NbtOps.INSTANCE, tag.getCompoundOrEmpty("cachedStructureUuid") + ).result().orElse(null); } this.hasStructureDisk = true; + this.hasInvalidStructure = false; } // NBT加载后尝试从磁盘更新结构(如果有磁盘的话) @@ -1277,13 +1280,22 @@ private void prepareBlueprintModeHeldBlock(Level level, BlockPos pos) { // 可堆叠方块:检查容器中是否有足够的数量 int availableCount = this.countBlockItemInContainer(level, pos, requiredBlock); if (availableCount < stackCount) { - // 数量不足,跳过 + // 数量不足,停止模式下不前扫 + if (!this.isSkipMissingMode) { + this.currentHeldBlock = ItemStack.EMPTY; + return; + } continue; } } else { // 普通方块:检查是否有物品 ItemStack blockItem = this.peekSpecificBlockItemFromContainer(level, pos, requiredBlock); if (blockItem.isEmpty()) { + // 停止模式下不前扫 + if (!this.isSkipMissingMode) { + this.currentHeldBlock = ItemStack.EMPTY; + return; + } continue; } this.currentHeldBlock = blockItem.copy(); @@ -1340,6 +1352,12 @@ private void prepareBlueprintModeHeldBlock(Level level, BlockPos pos) { return; } } + + // 停止模式:源方块与当前位置不匹配时,不前扫 + if (!this.isSkipMissingMode) { + this.currentHeldBlock = ItemStack.EMPTY; + return; + } } } @@ -3889,6 +3907,20 @@ public void applySyncDataFromMenu(CompoundTag tag) { this.isPickupMode = tag.getBooleanOr("isPickupMode", false); this.isSkipMissingMode = tag.getBooleanOr("isSkipMissingMode", false); this.loadLayerPositions(tag); + + // 同步结构缓存数据(菜单打开包中包含 cachedStructure), + // 用于客户端预览蓝图和结构信息 + if (tag.contains("cachedStructure")) { + this.loadedStructure = this.loadStructureData(tag.getCompoundOrEmpty("cachedStructure")); + this.loadedStructureName = tag.getStringOr("cachedStructureName", ""); + if (tag.contains("cachedStructureUuid")) { + this.loadedStructureUuid = UUIDUtil.CODEC.parse( + NbtOps.INSTANCE, tag.getCompoundOrEmpty("cachedStructureUuid") + ).result().orElse(null); + } + this.hasStructureDisk = true; + this.hasInvalidStructure = false; + } } void loadLayerPositions(CompoundTag tag) { diff --git a/src/main/java/dev/dubhe/anvilcraft/block/power/consumer/SmartBlockPlacerBlock.java b/src/main/java/dev/dubhe/anvilcraft/block/power/consumer/SmartBlockPlacerBlock.java index 2f9e6d8e4e..8e659f5025 100644 --- a/src/main/java/dev/dubhe/anvilcraft/block/power/consumer/SmartBlockPlacerBlock.java +++ b/src/main/java/dev/dubhe/anvilcraft/block/power/consumer/SmartBlockPlacerBlock.java @@ -56,9 +56,9 @@ public class SmartBlockPlacerBlock extends BetterBaseEntityBlock implements IHam private static final VoxelShape SHAPE_EAST = ShapeUtil.rotate(Direction.Axis.Y, 270, SHAPE_NORTH); // 倒挂状态:使用 Axis.X 旋转 180 度实现 Y 轴翻转 - private static final VoxelShape SHAPE_NORTH_UPSIDE = ShapeUtil.rotate(Direction.Axis.X, 180, SHAPE_NORTH); + private static final VoxelShape SHAPE_NORTH_UPSIDE = ShapeUtil.rotate(Direction.Axis.X, 180, SHAPE_SOUTH); private static final VoxelShape SHAPE_WEST_UPSIDE = ShapeUtil.rotate(Direction.Axis.X, 180, SHAPE_WEST); - private static final VoxelShape SHAPE_SOUTH_UPSIDE = ShapeUtil.rotate(Direction.Axis.X, 180, SHAPE_SOUTH); + private static final VoxelShape SHAPE_SOUTH_UPSIDE = ShapeUtil.rotate(Direction.Axis.X, 180, SHAPE_NORTH); private static final VoxelShape SHAPE_EAST_UPSIDE = ShapeUtil.rotate(Direction.Axis.X, 180, SHAPE_EAST); public SmartBlockPlacerBlock(Properties properties) { diff --git a/src/main/java/dev/dubhe/anvilcraft/client/gui/screen/SmartBlockPlacerScreen.java b/src/main/java/dev/dubhe/anvilcraft/client/gui/screen/SmartBlockPlacerScreen.java index ad20ae70d4..94ea85f5ae 100644 --- a/src/main/java/dev/dubhe/anvilcraft/client/gui/screen/SmartBlockPlacerScreen.java +++ b/src/main/java/dev/dubhe/anvilcraft/client/gui/screen/SmartBlockPlacerScreen.java @@ -29,6 +29,7 @@ import net.minecraft.world.item.Items; import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.HorizontalDirectionalBlock; +import net.minecraft.world.level.block.Rotation; import net.minecraft.world.level.block.state.BlockState; import net.neoforged.neoforge.client.network.ClientPacketDistributor; import org.jspecify.annotations.Nullable; @@ -950,15 +951,22 @@ private LevelLike buildPreviewLevelLike() { int placerY = upsideDown ? 4 : 0; previewLevelLike.setBlockState( new BlockPos(2, placerY - 2, 7), - blockState.getBlock().defaultBlockState() - .setValue(HorizontalDirectionalBlock.FACING, Direction.NORTH) - .setValue(SmartBlockPlacerBlock.UPSIDE_DOWN, upsideDown) + blockState.setValue(HorizontalDirectionalBlock.FACING, Direction.NORTH) ); var rotatedData = SmartBlockPlacerBlockEntity.rotateStructureDataStatic(structure); + int sizeX = structure.diskData.sizeX(); + int sizeZ = structure.diskData.sizeZ(); for (var bp : rotatedData.blocks) { - int renderY = bp.y(); - previewLevelLike.setBlockState(new BlockPos(bp.x(), renderY - 2, bp.z() + 1), bp.state()); + int renderX = upsideDown ? (sizeX - 1 - bp.x()) : bp.x(); + int renderZ = upsideDown ? (sizeZ - bp.z()) : (bp.z() + 1); + int renderY = upsideDown ? (2 - bp.y()) : (bp.y() - 2); + BlockState state = bp.state(); + if (upsideDown) { + state = state.rotate(Rotation.CLOCKWISE_180); + state = SmartBlockPlacerBlockEntity.flipHalfPropertyStatic(state); + } + previewLevelLike.setBlockState(new BlockPos(renderX, renderY, renderZ), state); } } else { // 普通模式:显示 UI 中的选区模式(不读取世界方块) @@ -970,9 +978,7 @@ private LevelLike buildPreviewLevelLike() { int placerY = upsideDown ? 4 : 0; previewLevelLike.setBlockState( new BlockPos(2, placerY - 2, 7), - blockState.getBlock().defaultBlockState() - .setValue(HorizontalDirectionalBlock.FACING, Direction.NORTH) - .setValue(SmartBlockPlacerBlock.UPSIDE_DOWN, upsideDown) + blockState.setValue(HorizontalDirectionalBlock.FACING, Direction.NORTH) ); for (Map.Entry> entry : this.layerPositions.entrySet()) { diff --git a/src/main/java/dev/dubhe/anvilcraft/client/support/StructureDiskPreviewSupport.java b/src/main/java/dev/dubhe/anvilcraft/client/support/StructureDiskPreviewSupport.java index 51d029a3ff..ac4a564cb9 100644 --- a/src/main/java/dev/dubhe/anvilcraft/client/support/StructureDiskPreviewSupport.java +++ b/src/main/java/dev/dubhe/anvilcraft/client/support/StructureDiskPreviewSupport.java @@ -3,68 +3,85 @@ import dev.dubhe.anvilcraft.block.entity.SmartBlockPlacerBlockEntity; import dev.dubhe.anvilcraft.init.item.ModComponents; import dev.dubhe.anvilcraft.item.property.component.StructureDiskData; +import dev.dubhe.anvilcraft.network.StructurePreviewRequestPacket; import dev.dubhe.anvilcraft.util.LevelLike; import dev.dubhe.anvilcraft.util.StructureLoadUtil; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.multiplayer.ClientLevel; import net.minecraft.core.BlockPos; +import net.minecraft.core.HolderLookup; +import net.minecraft.core.registries.Registries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.NbtUtils; import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.state.BlockState; +import net.neoforged.neoforge.client.network.ClientPacketDistributor; import org.jspecify.annotations.Nullable; -import java.util.Comparator; +import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; +import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; /** * 结构磁盘预览支持类 - * 管理结构磁盘的缓存和3D预览渲染 + * 管理结构磁盘的缓存和3D预览渲染。 + * + *

缓存策略(会话级):

+ * */ public class StructureDiskPreviewSupport { private static final int PREVIEW_SIZE = 80; /** - * 预览缓存:使用StructureUUID作为key + * 完整预览缓存(UUID → LevelLike),会话级,永不超时 */ - private static final Map PREVIEW_CACHE = new HashMap<>(); + private static final Map PREVIEW_CACHE = new HashMap<>(); /** - * 缓存过期时间(毫秒) + * 最大缓存条目数(防止内存泄漏) */ - private static final long CACHE_EXPIRY_MS = 5000; + private static final int MAX_CACHE_SIZE = 100; /** - * 最大缓存条目数 + * 服务端返回的原始NBT预览数据(等待构建LevelLike) */ - private static final int MAX_CACHE_SIZE = 50; + private static final Map PENDING_PREVIEW_DATA = new HashMap<>(); /** - * 上次清理时间 + * 已发送请求的UUID集合(防止重复请求) */ - private static long lastCleanupTime = 0; + private static final Set PENDING_REQUESTS = new HashSet<>(); /** - * 清理间隔(毫秒) + * 请求超时时间(毫秒),超时后可重新请求 */ - private static final long CLEANUP_INTERVAL_MS = 10000; + private static final long REQUEST_TIMEOUT_MS = 30000; /** - * 预览缓存数据 + * 请求时间戳记录 */ - private static class PreviewCache { - final StructureLoadUtil.StructureData structureData; - final LevelLike levelLike; - final long timestamp; + private static final Map REQUEST_TIMESTAMPS = new HashMap<>(); + private record PreviewCache( + StructureLoadUtil.StructureData structureData, + LevelLike levelLike, + long creationTime + ) { PreviewCache(StructureLoadUtil.StructureData structureData, LevelLike levelLike) { - this.structureData = structureData; - this.levelLike = levelLike; - this.timestamp = System.currentTimeMillis(); - } - - boolean isExpired() { - return System.currentTimeMillis() - this.timestamp > CACHE_EXPIRY_MS; + this(structureData, levelLike, System.currentTimeMillis()); } } @@ -94,16 +111,13 @@ public static void renderPreviewAt(GuiGraphicsExtractor graphics, ItemStack disk previewX = 5; } - // 渲染背景(轻微透明) graphics.fill(previewX - 2, previewY - 2, previewX + PREVIEW_SIZE + 2, previewY + PREVIEW_SIZE + 2, 0xF0100010); - // 渲染边框 graphics.fill(previewX - 2, previewY - 2, previewX + PREVIEW_SIZE + 2, previewY - 1, 0x505000ff); graphics.fill(previewX - 2, previewY + PREVIEW_SIZE + 2, previewX + PREVIEW_SIZE + 2, previewY + PREVIEW_SIZE + 3, 0x505000ff); graphics.fill(previewX - 2, previewY - 1, previewX - 1, previewY + PREVIEW_SIZE + 3, 0x505000ff); graphics.fill(previewX + PREVIEW_SIZE + 1, previewY - 1, previewX + PREVIEW_SIZE + 2, previewY + PREVIEW_SIZE + 3, 0x505000ff); - // 渲染3D预览 int maxDim = Math.max(cache.structureData.diskData.sizeX(), Math.max(cache.structureData.diskData.sizeY(), cache.structureData.diskData.sizeZ())); @@ -115,42 +129,120 @@ public static void renderPreviewAt(GuiGraphicsExtractor graphics, ItemStack disk ); } + /** + * 接收服务端返回的结构预览NBT数据(由 StructurePreviewResponsePacket 调用) + * 存储原始数据,待 tooltip 渲染时再解析为 LevelLike。 + */ + public static void receiveStructureData(UUID structureUuid, CompoundTag structureData) { + PENDING_PREVIEW_DATA.put(structureUuid, structureData); + PENDING_REQUESTS.remove(structureUuid); + REQUEST_TIMESTAMPS.remove(structureUuid); + } + /** * 获取或创建预览缓存 */ @Nullable private static PreviewCache getOrCreateCache(ItemStack diskStack, ClientLevel level) { - UUID uuid = StructureDiskPreviewSupport.getStructureUuidFromDisk(diskStack); - String cacheKey = uuid == null ? null : uuid.toString(); - if (cacheKey == null) { - cacheKey = "hash_" + diskStack.getComponents().hashCode(); - } + StructureDiskData diskData = diskStack.get(ModComponents.STRUCTURE_DISK_DATA); + if (diskData == null) return null; - cleanupExpiredCache(); + UUID uuid = diskData.uuid(); - PreviewCache cache = PREVIEW_CACHE.get(cacheKey); - if (cache != null && !cache.isExpired()) { + // 1. 命中完整缓存 — 直接返回,永不过期 + PreviewCache cache = PREVIEW_CACHE.get(uuid); + if (cache != null) { return cache; } - StructureLoadUtil.StructureData data = StructureLoadUtil.loadStructureFromDiskForPreview(level, diskStack); - if (data == null || data.isEmpty()) { - PREVIEW_CACHE.remove(cacheKey); + // 2. 检查是否有服务端返回的 NBT 待处理数据 + CompoundTag pendingData = PENDING_PREVIEW_DATA.get(uuid); + if (pendingData != null) { + StructureLoadUtil.StructureData data = parsePreviewNbt(pendingData, diskData, level.registryAccess()); + if (data != null && !data.isEmpty()) { + LevelLike levelLike = buildLevelLike(data); + if (levelLike != null) { + cache = new PreviewCache(data, levelLike); + PREVIEW_CACHE.put(uuid, cache); + PENDING_PREVIEW_DATA.remove(uuid); + evictIfNeeded(); + return cache; + } + } + // 解析失败,清理待处理数据,后续会重新请求 + PENDING_PREVIEW_DATA.remove(uuid); return null; } - cacheKey = data.diskData.uuid().toString(); + // 3. 回退:尝试从本地文件加载(单人模式有效) + StructureLoadUtil.StructureData localData = StructureLoadUtil.loadStructureFromDiskForPreview(level, diskStack); + if (localData != null && !localData.isEmpty()) { + LevelLike levelLike = buildLevelLike(localData); + if (levelLike != null) { + cache = new PreviewCache(localData, levelLike); + PREVIEW_CACHE.put(uuid, cache); + evictIfNeeded(); + return cache; + } + } - LevelLike levelLike = buildLevelLike(data); - if (levelLike == null) { - PREVIEW_CACHE.remove(cacheKey); - return null; + // 4. 未缓存且未请求 → 向服务端发送请求 + if (shouldSendRequest(uuid)) { + PENDING_REQUESTS.add(uuid); + REQUEST_TIMESTAMPS.put(uuid, System.currentTimeMillis()); + ClientPacketDistributor.sendToServer(new StructurePreviewRequestPacket(uuid, diskData.file())); } - cache = new PreviewCache(data, levelLike); - PREVIEW_CACHE.put(cacheKey, cache); + return null; + } - return cache; + /** + * 检查是否应该发送请求(未被请求或已超时) + */ + private static boolean shouldSendRequest(UUID uuid) { + if (!PENDING_REQUESTS.contains(uuid)) return true; + Long timestamp = REQUEST_TIMESTAMPS.get(uuid); + if (timestamp == null) return true; + return System.currentTimeMillis() - timestamp > REQUEST_TIMEOUT_MS; + } + + /** + * 从NBT数据解析为 StructureData + */ + private static StructureLoadUtil.@Nullable StructureData parsePreviewNbt( + CompoundTag tag, + StructureDiskData diskData, + HolderLookup.Provider registry + ) { + ListTag paletteTag = tag.getListOrEmpty("palette"); + ListTag blocksTag = tag.getListOrEmpty("blocks"); + if (paletteTag.isEmpty() || blocksTag.isEmpty()) return null; + + var blockLookup = registry.lookupOrThrow(Registries.BLOCK); + List palette = new ArrayList<>(); + for (int i = 0; i < paletteTag.size(); i++) { + BlockState state = NbtUtils.readBlockState(blockLookup, paletteTag.getCompoundOrEmpty(i)); + palette.add(state); + } + if (palette.isEmpty()) return null; + + StructureLoadUtil.StructureData result = new StructureLoadUtil.StructureData(diskData); + for (int i = 0; i < blocksTag.size(); i++) { + CompoundTag blockTag = blocksTag.getCompoundOrEmpty(i); + ListTag posTag = blockTag.getListOrEmpty("pos"); + if (posTag.size() < 3) continue; + + int x = posTag.getInt(0).orElse(0); + int y = posTag.getInt(1).orElse(0); + int z = posTag.getInt(2).orElse(0); + int stateIndex = blockTag.getInt("state").orElse(-1); + + if (stateIndex >= 0 && stateIndex < palette.size()) { + result.blocks.add(new StructureLoadUtil.BlockPosition(x, y, z, palette.get(stateIndex))); + } + } + + return result; } /** @@ -165,11 +257,9 @@ private static LevelLike buildLevelLike(StructureLoadUtil.StructureData data) { LevelLike levelLike = new LevelLike(minecraft.level); - // 使用统一的旋转逻辑,与服务端放置和智能放置器预览保持一致 StructureLoadUtil.StructureData rotatedData = SmartBlockPlacerBlockEntity.rotateStructureDataStatic(data); - // 计算旋转后结构的中心 int sizeX = data.diskData.sizeX(); int sizeY = data.diskData.sizeY(); int sizeZ = data.diskData.sizeZ(); @@ -192,33 +282,15 @@ private static LevelLike buildLevelLike(StructureLoadUtil.StructureData data) { } /** - * 从磁盘ItemStack中提取StructureUUID - */ - @Nullable - private static UUID getStructureUuidFromDisk(ItemStack diskStack) { - StructureDiskData structureDiskData = diskStack.get(ModComponents.STRUCTURE_DISK_DATA); - if (structureDiskData == null) return null; - return structureDiskData.uuid(); - } - - /** - * 清理过期缓存条目 + * 缓存超过上限时淘汰最旧条目 */ - private static void cleanupExpiredCache() { - long currentTime = System.currentTimeMillis(); - - if (currentTime - lastCleanupTime < CLEANUP_INTERVAL_MS) return; - - lastCleanupTime = currentTime; - - PREVIEW_CACHE.entrySet().removeIf(entry -> entry.getValue().isExpired()); - - if (PREVIEW_CACHE.size() > MAX_CACHE_SIZE) { - PREVIEW_CACHE.entrySet() - .stream() - .sorted(Comparator.comparingLong(entry -> entry.getValue().timestamp)) - .limit(PREVIEW_CACHE.size() - MAX_CACHE_SIZE) - .forEach(entry -> PREVIEW_CACHE.remove(entry.getKey())); - } + private static void evictIfNeeded() { + if (PREVIEW_CACHE.size() <= MAX_CACHE_SIZE) return; + + PREVIEW_CACHE.entrySet() + .stream() + .sorted(java.util.Comparator.comparingLong(e -> e.getValue().creationTime)) + .limit(PREVIEW_CACHE.size() - MAX_CACHE_SIZE) + .forEach(e -> PREVIEW_CACHE.remove(e.getKey())); } } diff --git a/src/main/java/dev/dubhe/anvilcraft/init/block/ModBlocks.java b/src/main/java/dev/dubhe/anvilcraft/init/block/ModBlocks.java index 8700c88ded..658b28f401 100644 --- a/src/main/java/dev/dubhe/anvilcraft/init/block/ModBlocks.java +++ b/src/main/java/dev/dubhe/anvilcraft/init/block/ModBlocks.java @@ -215,6 +215,7 @@ import net.minecraft.client.data.models.BlockModelGenerators; import net.minecraft.client.data.models.MultiVariant; import net.minecraft.client.data.models.blockstates.MultiVariantGenerator; +import net.minecraft.client.data.models.blockstates.PropertyDispatch; import net.minecraft.client.data.models.model.ModelLocationUtils; import net.minecraft.client.data.models.model.ModelTemplates; import net.minecraft.client.data.models.model.TextureMapping; @@ -240,6 +241,7 @@ import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.ColoredFallingBlock; +import net.minecraft.world.level.block.HorizontalDirectionalBlock; import net.minecraft.world.level.block.LiquidBlock; import net.minecraft.world.level.block.RotatedPillarBlock; import net.minecraft.world.level.block.SlabBlock; @@ -933,13 +935,23 @@ public void accept(DataGenContext ctx, RegistrumItemModelGenera Identifier off = ctx.getId().withPrefix("block/").withSuffix("_bottom_off"); Identifier overload = ctx.getId().withPrefix("block/").withSuffix("_bottom_overload"); generator.blockStateOutput.accept(MultiVariantGenerator.dispatch(ctx.get()) - .with(PropertyDispatchWrap.initial(SmartBlockPlacerBlock.OVERLOAD, SmartBlockPlacerBlock.POWERED) - .select(true, true, BlockModelGenerators.plainVariant(overload)) - .select(true, false, BlockModelGenerators.plainVariant(overload)) - .select(false, true, BlockModelGenerators.plainVariant(off)) - .select(false, false, BlockModelGenerators.plainVariant(bottom)) - .dispatch()) - .with(BlockModelGenerators.ROTATION_HORIZONTAL_FACING)); + .with(PropertyDispatch.initial( + SmartBlockPlacerBlock.OVERLOAD, SmartBlockPlacerBlock.POWERED, SmartBlockPlacerBlock + .UPSIDE_DOWN, HorizontalDirectionalBlock.FACING) + .generate((isOverload, isPowered, isUpsideDown, facing) -> { + Identifier model = isOverload ? overload : (isPowered ? off : bottom); + Quadrant baseY = switch (facing) { + case EAST -> Quadrant.R90; + case SOUTH -> Quadrant.R180; + case WEST -> Quadrant.R270; + default -> Quadrant.R0; + }; + Quadrant yrot = isUpsideDown + ? Quadrant.values()[(baseY.ordinal() + 2) % 4] + : baseY; + return BlockModelGenerators.plainVariant(model) + .with(v -> v.withXRot(isUpsideDown ? Quadrant.R180 : Quadrant.R0).withYRot(yrot)); + }))); }) .simpleItem() .tag(BlockTags.MINEABLE_WITH_PICKAXE) @@ -950,7 +962,25 @@ public void accept(DataGenContext ctx, RegistrumItemModelGenera .block("structure_scanner", StructureScannerBlock::new) .initialProperties(() -> Blocks.IRON_BLOCK) .properties(p -> p.noOcclusion().isValidSpawn(Blocks::never)) - .blockstate(DataGenUtil::horizontalFacingBlock) + .blockstate(() -> (ctx, generator) -> { + Identifier model = ctx.getId().withPrefix("block/"); + generator.blockStateOutput.accept(MultiVariantGenerator.dispatch(ctx.get()) + .with(PropertyDispatch.initial( + StructureScannerBlock.UPSIDE_DOWN, HorizontalDirectionalBlock.FACING) + .generate((isUpsideDown, facing) -> { + Quadrant baseY = switch (facing) { + case EAST -> Quadrant.R90; + case SOUTH -> Quadrant.R180; + case WEST -> Quadrant.R270; + default -> Quadrant.R0; + }; + Quadrant yrot = isUpsideDown + ? Quadrant.values()[(baseY.ordinal() + 2) % 4] + : baseY; + return BlockModelGenerators.plainVariant(model) + .with(v -> v.withXRot(isUpsideDown ? Quadrant.R180 : Quadrant.R0).withYRot(yrot)); + }))); + }) .simpleItem() .tag(BlockTags.MINEABLE_WITH_PICKAXE) .recipe(RegistrumBlockRecipeLoader::structureScanner) diff --git a/src/main/java/dev/dubhe/anvilcraft/network/StructurePreviewRequestPacket.java b/src/main/java/dev/dubhe/anvilcraft/network/StructurePreviewRequestPacket.java new file mode 100644 index 0000000000..6e7abb6614 --- /dev/null +++ b/src/main/java/dev/dubhe/anvilcraft/network/StructurePreviewRequestPacket.java @@ -0,0 +1,77 @@ +package dev.dubhe.anvilcraft.network; + +import dev.anvilcraft.lib.v2.network.packet.IPacket; +import dev.anvilcraft.lib.v2.network.packet.IServerboundPacket; +import dev.anvilcraft.lib.v2.util.Util; +import dev.dubhe.anvilcraft.AnvilCraft; +import dev.dubhe.anvilcraft.util.StructureLoadUtil; +import io.netty.buffer.ByteBuf; +import net.minecraft.core.UUIDUtil; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.player.Player; + +import java.util.UUID; + +/** + * 结构磁盘预览请求包(C2S) + * 客户端需要渲染磁盘3D预览时,向服务端请求结构NBT数据。 + * 服务端读取结构文件后,通过 StructurePreviewResponsePacket 返回调色板和方块列表。 + */ +public record StructurePreviewRequestPacket(UUID structureUuid, String structureFile) implements IServerboundPacket { + public static final Type TYPE = IPacket.type( + AnvilCraft.of("structure_preview_request") + ); + + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + UUIDUtil.STREAM_CODEC, + StructurePreviewRequestPacket::structureUuid, + ByteBufCodecs.STRING_UTF8, + StructurePreviewRequestPacket::structureFile, + StructurePreviewRequestPacket::new + ); + + @Override + public Type type() { + return TYPE; + } + + @Override + public void handleOnServer(Player player) { + ServerPlayer serverPlayer = Util.cast(player); + ServerLevel serverLevel = serverPlayer.level(); + if (this.structureFile.isEmpty()) { + return; + } + + // 服务端读取结构文件,提取预览数据 + CompoundTag previewData = StructureLoadUtil.loadPreviewData(serverLevel, this.structureFile); + if (previewData == null) { + AnvilCraft.LOGGER.warn( + "Failed to load preview data for structure: {} (file: {})", + this.structureUuid, this.structureFile + ); + return; + } + + // 校验预览数据同时包含有效的 palette 和 blocks + // 若任一项为空,客户端 parsePreviewNbt 会解析失败且不会重试 + if (!previewData.contains("palette") || !previewData.contains("blocks") + || previewData.getListOrEmpty("palette").isEmpty() + || previewData.getListOrEmpty("blocks").isEmpty()) { + AnvilCraft.LOGGER.warn( + "Preview data has empty palette or blocks for structure: {} (file: {})", + this.structureUuid, this.structureFile + ); + return; + } + + // 发送预览数据回客户端 + serverPlayer.connection.send( + new StructurePreviewResponsePacket(this.structureUuid, previewData) + ); + } +} diff --git a/src/main/java/dev/dubhe/anvilcraft/network/StructurePreviewResponsePacket.java b/src/main/java/dev/dubhe/anvilcraft/network/StructurePreviewResponsePacket.java new file mode 100644 index 0000000000..78b70e06ff --- /dev/null +++ b/src/main/java/dev/dubhe/anvilcraft/network/StructurePreviewResponsePacket.java @@ -0,0 +1,43 @@ +package dev.dubhe.anvilcraft.network; + +import dev.anvilcraft.lib.v2.network.packet.IClientboundPacket; +import dev.dubhe.anvilcraft.AnvilCraft; +import dev.dubhe.anvilcraft.client.support.StructureDiskPreviewSupport; +import io.netty.buffer.ByteBuf; +import net.minecraft.core.UUIDUtil; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.world.entity.player.Player; + +import java.util.UUID; + +/** + * 结构磁盘预览数据同步包(S2C) + * 服务端响应客户端的预览请求,发送结构NBT中的调色板和方块列表。 + * 客户端收到后缓存到 StructureDiskPreviewSupport,下次 tooltip 渲染时显示3D预览。 + */ +public record StructurePreviewResponsePacket(UUID structureUuid, CompoundTag structureData) implements IClientboundPacket { + public static final Type TYPE = new Type<>( + AnvilCraft.of("structure_preview_response") + ); + + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + UUIDUtil.STREAM_CODEC, + StructurePreviewResponsePacket::structureUuid, + ByteBufCodecs.COMPOUND_TAG, + StructurePreviewResponsePacket::structureData, + StructurePreviewResponsePacket::new + ); + + @Override + public Type type() { + return TYPE; + } + + @Override + public void handleOnClient(Player player) { + // 将服务端返回的结构数据写入预览缓存 + StructureDiskPreviewSupport.receiveStructureData(this.structureUuid, this.structureData); + } +} diff --git a/src/main/java/dev/dubhe/anvilcraft/util/StructureLoadUtil.java b/src/main/java/dev/dubhe/anvilcraft/util/StructureLoadUtil.java index ef2ceadd0b..64f2e50472 100644 --- a/src/main/java/dev/dubhe/anvilcraft/util/StructureLoadUtil.java +++ b/src/main/java/dev/dubhe/anvilcraft/util/StructureLoadUtil.java @@ -48,6 +48,7 @@ public class StructureLoadUtil { // Whitelist pattern for structure file names: only allow alphanumeric, underscore, hyphen, and dot (for .nbt extension) private static final Pattern VALID_STRUCTURE_FILE = Pattern.compile("^[a-zA-Z0-9_\\-]+_[a-f0-9\\-]+\\.nbt$"); private static final int MAX_STRUCTURE_FILE_LENGTH = 128; + private static final int MAX_PREVIEW_BLOCKS = 4096; /** * 从结构磁盘读取结构数据(不过滤多方块方块,用于预览) @@ -87,7 +88,7 @@ public static StructureData loadStructureFromDisk(Level level, ItemStack diskSta String fileName = structureDiskData.file(); // Validate and sanitize structure file name to prevent path traversal - if (!isValidStructureFile(fileName)) { + if (isInvalidStructureFile(fileName)) { LOGGER.error("Invalid structure file name: {}", fileName); return null; } @@ -98,7 +99,7 @@ public static StructureData loadStructureFromDisk(Level level, ItemStack diskSta Path structureFile = baseDir.resolve(fileName); // Validate the resolved path stays within the intended directory - if (!isPathWithinBaseDirectory(structureFile, baseDir)) { + if (isPathOutsideBaseDirectory(structureFile, baseDir)) { LOGGER.error("Path traversal attempt detected: {}", fileName); return null; } @@ -228,43 +229,106 @@ private static Path getClientStructureDirectory() { // 最后的备选方案:使用当前工作目录 } + /** + * 加载结构预览数据(仅调色板和方块列表),用于网络同步到客户端 + * + * @param level 世界实例(服务端) + * @param fileName 结构文件名(已校验) + * @return 预览CompoundTag(含palette+blocks),加载失败返回null + */ + @Nullable + public static CompoundTag loadPreviewData(Level level, String fileName) { + if (isInvalidStructureFile(fileName)) { + LOGGER.warn("Invalid structure file name for preview: {}", fileName); + return null; + } + + try { + Path baseDir = getStructureDirectory(level); + Path structureFile = baseDir.resolve(fileName); + + if (isPathOutsideBaseDirectory(structureFile, baseDir)) { + LOGGER.error("Path traversal detected for preview: {}", fileName); + return null; + } + + if (!Files.exists(structureFile)) { + LOGGER.warn("Structure file not found for preview: {}", fileName); + return null; + } + + CompoundTag fullTag = NbtIo.readCompressed(structureFile, NbtAccounter.create(128L * 1024 * 1024)); + CompoundTag previewTag = new CompoundTag(); + + // 复制调色板 + if (fullTag.contains("palette")) { + previewTag.put("palette", fullTag.getListOrEmpty("palette").copy()); + } + + // 复制方块列表,超过上限时截断并记录警告 + if (fullTag.contains("blocks")) { + ListTag allBlocks = fullTag.getListOrEmpty("blocks"); + int totalBlocks = allBlocks.size(); + if (totalBlocks > MAX_PREVIEW_BLOCKS) { + LOGGER.warn( + "Preview data truncated: {} blocks (max {}) for file: {}", + totalBlocks, MAX_PREVIEW_BLOCKS, fileName + ); + ListTag truncated = new ListTag(); + for (int i = 0; i < MAX_PREVIEW_BLOCKS; i++) { + truncated.add(allBlocks.get(i).copy()); + } + previewTag.put("blocks", truncated); + } else { + previewTag.put("blocks", allBlocks.copy()); + } + } + + return previewTag.isEmpty() ? null : previewTag; + + } catch (IOException e) { + LOGGER.error("Failed to load preview data: {}", e.getMessage()); + return null; + } + } + /** * Validate structure file name to prevent path traversal attacks * File names must match the pattern: name_uuid.nbt */ - private static boolean isValidStructureFile(String fileName) { + public static boolean isInvalidStructureFile(String fileName) { if (fileName.trim().isEmpty()) { - return false; + return true; } // Check length if (fileName.length() > MAX_STRUCTURE_FILE_LENGTH) { - return false; + return true; } // Validate against whitelist pattern if (!VALID_STRUCTURE_FILE.matcher(fileName).matches()) { - return false; + return true; } // Additional safety: ensure no path separators - return !fileName.contains("/") && !fileName.contains("\\") && !fileName.contains(".."); + return fileName.contains("/") || fileName.contains("\\") || fileName.contains(".."); } /** - * Validate that the resolved path stays within the base directory + * Validate that the resolved path escapes the base directory * Prevents path traversal attacks using sequences */ - private static boolean isPathWithinBaseDirectory(Path resolvedPath, Path baseDir) { + private static boolean isPathOutsideBaseDirectory(Path resolvedPath, Path baseDir) { try { Path normalizedResolved = resolvedPath.toAbsolutePath().normalize(); Path normalizedBase = baseDir.toAbsolutePath().normalize(); - // Check if the resolved path starts with the base directory - return normalizedResolved.startsWith(normalizedBase); + // Check if the resolved path escapes the base directory + return !normalizedResolved.startsWith(normalizedBase); } catch (Exception e) { LOGGER.error("Error validating path: {}", e.getMessage()); - return false; + return true; } } @@ -336,14 +400,14 @@ public static boolean isMultiblockSecondaryPart(BlockState state) { // 检查原版床:FOOT是主体部件,HEAD是次要部件 switch (block) { - case BedBlock bedBlock -> { + case BedBlock _ -> { return state.hasProperty(BlockStateProperties.BED_PART) && state.getValue(BlockStateProperties.BED_PART) == BedPart.HEAD; } // 检查原版门:LOWER是主体部件,UPPER是次要部件 - case DoorBlock doorBlock -> { + case DoorBlock _ -> { return state.hasProperty(BlockStateProperties.DOUBLE_BLOCK_HALF) && state.getValue(BlockStateProperties.DOUBLE_BLOCK_HALF) == DoubleBlockHalf.UPPER; } diff --git a/src/main/java/dev/dubhe/anvilcraft/util/StructureSaveUtil.java b/src/main/java/dev/dubhe/anvilcraft/util/StructureSaveUtil.java index 6a6c3c9c3b..3b06258397 100644 --- a/src/main/java/dev/dubhe/anvilcraft/util/StructureSaveUtil.java +++ b/src/main/java/dev/dubhe/anvilcraft/util/StructureSaveUtil.java @@ -86,7 +86,7 @@ public static void saveStructureToDisk(Level level, StructureScannerBlockEntity Path structureFile = baseDir.resolve(fileName); // Validate the resolved path stays within the intended directory - if (!isPathWithinBaseDirectory(structureFile, baseDir)) { + if (isPathOutsideBaseDirectory(structureFile, baseDir)) { LOGGER.error("Path traversal attempt detected: {}", structureFile); return; } @@ -234,19 +234,19 @@ private static Path getStructureDirectory(Level level) { } /** - * Validate that the resolved path stays within the base directory + * Validate that the resolved path escapes the base directory * Prevents path traversal attacks using sequences */ - private static boolean isPathWithinBaseDirectory(Path resolvedPath, Path baseDir) { + private static boolean isPathOutsideBaseDirectory(Path resolvedPath, Path baseDir) { try { Path normalizedResolved = resolvedPath.toAbsolutePath().normalize(); Path normalizedBase = baseDir.toAbsolutePath().normalize(); - // Check if the resolved path starts with the base directory - return normalizedResolved.startsWith(normalizedBase); + // Check if the resolved path escapes the base directory + return !normalizedResolved.startsWith(normalizedBase); } catch (Exception e) { LOGGER.error("Error validating path: {}", e.getMessage()); - return false; + return true; } } }