From fc3f12cb5fdc4eb5ebc8443c8e3d6cf519c58bc1 Mon Sep 17 00:00:00 2001 From: devin Date: Fri, 3 Apr 2026 11:02:00 +0200 Subject: [PATCH 1/3] Add Drone View upgrade with WASD-controlled top-down camera MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the Drone View and Drone Range upgrades for the Portable Beehive. Pressing V spawns a mechanical bee drone 25 blocks above, switching to a top-down camera. WASD moves the drone within a configurable radius while the player stays in place. The drone follows terrain height, is invisible to all players, and the Construction Planner works fully from the aerial perspective — including ghost preview, schematic deployment, and Create's tool system. Player input is locked (no attacks, head rotation, or hotbar scrolling) while the drone is active. Also includes upgrade grid system, fluid handler, and related backpack changes. Co-Authored-By: Claude Opus 4.6 --- .../mixin/SchematicHandlerFindMixin.java | 31 +- .../mixin/SchematicHandlerHudMixin.java | 11 +- .../mixin/ToolSelectionScreenMixin.java | 5 +- .../kotlin/de/devin/cbbees/CreateBuzzyBeez.kt | 14 + .../de/devin/cbbees/config/CBBeesConfig.kt | 32 +++ .../content/backpack/BeehiveContainer.kt | 51 +--- .../content/backpack/BeehiveFluidHandler.kt | 67 +++++ .../cbbees/content/backpack/BeehiveScreen.kt | 142 +++++++--- .../content/backpack/PortableBeehiveItem.kt | 107 +++++-- .../client/BeehiveTooltipComponent.kt | 73 +++-- .../backpack/client/UpgradeGridWidget.kt | 266 ++++++++++++++++++ .../cbbees/content/bee/MechanicalBeeEntity.kt | 77 ++++- .../content/bee/MechanicalBeeRenderer.kt | 22 +- .../cbbees/content/drone/DroneViewManager.kt | 157 +++++++++++ .../drone/client/DroneViewClientEvents.kt | 204 ++++++++++++++ .../drone/client/DroneViewClientState.kt | 106 +++++++ .../content/drone/client/DroneViewHUD.kt | 65 +++++ .../client/ConstructionPlannerClientEvents.kt | 5 +- .../client/ConstructionPlannerHandler.kt | 17 +- .../client/SchematicHoverPreview.kt | 16 +- .../cbbees/content/upgrades/BeeContext.kt | 8 +- .../cbbees/content/upgrades/BeeUpgradeItem.kt | 32 ++- .../cbbees/content/upgrades/UpgradeGrid.kt | 182 ++++++++++++ .../cbbees/content/upgrades/UpgradeShape.kt | 74 +++++ .../kotlin/de/devin/cbbees/items/AllItems.kt | 80 ++++++ .../de/devin/cbbees/network/AllPackets.kt | 29 ++ .../devin/cbbees/network/CCRServerEvents.kt | 16 ++ .../cbbees/network/DroneViewSyncPacket.kt | 44 +++ .../cbbees/network/GridPlaceUpgradePacket.kt | 65 +++++ .../cbbees/network/GridRemoveUpgradePacket.kt | 71 +++++ .../devin/cbbees/network/MoveDronePacket.kt | 56 ++++ .../cbbees/network/ToggleDroneViewPacket.kt | 37 +++ .../cbbees/registry/AllDataComponents.kt | 7 + .../de/devin/cbbees/registry/AllKeys.kt | 12 + .../resources/assets/cbbees/lang/en_us.json | 44 ++- .../cbbees/textures/gui/portable_beehive.png | Bin 11421 -> 8735 bytes 36 files changed, 2076 insertions(+), 149 deletions(-) create mode 100644 src/main/kotlin/de/devin/cbbees/content/backpack/BeehiveFluidHandler.kt create mode 100644 src/main/kotlin/de/devin/cbbees/content/backpack/client/UpgradeGridWidget.kt create mode 100644 src/main/kotlin/de/devin/cbbees/content/drone/DroneViewManager.kt create mode 100644 src/main/kotlin/de/devin/cbbees/content/drone/client/DroneViewClientEvents.kt create mode 100644 src/main/kotlin/de/devin/cbbees/content/drone/client/DroneViewClientState.kt create mode 100644 src/main/kotlin/de/devin/cbbees/content/drone/client/DroneViewHUD.kt create mode 100644 src/main/kotlin/de/devin/cbbees/content/upgrades/UpgradeGrid.kt create mode 100644 src/main/kotlin/de/devin/cbbees/content/upgrades/UpgradeShape.kt create mode 100644 src/main/kotlin/de/devin/cbbees/network/DroneViewSyncPacket.kt create mode 100644 src/main/kotlin/de/devin/cbbees/network/GridPlaceUpgradePacket.kt create mode 100644 src/main/kotlin/de/devin/cbbees/network/GridRemoveUpgradePacket.kt create mode 100644 src/main/kotlin/de/devin/cbbees/network/MoveDronePacket.kt create mode 100644 src/main/kotlin/de/devin/cbbees/network/ToggleDroneViewPacket.kt diff --git a/src/main/java/de/devin/cbbees/mixin/SchematicHandlerFindMixin.java b/src/main/java/de/devin/cbbees/mixin/SchematicHandlerFindMixin.java index 6fe4e87..4b4db7b 100644 --- a/src/main/java/de/devin/cbbees/mixin/SchematicHandlerFindMixin.java +++ b/src/main/java/de/devin/cbbees/mixin/SchematicHandlerFindMixin.java @@ -2,7 +2,9 @@ import com.simibubi.create.AllDataComponents; import com.simibubi.create.content.schematics.client.SchematicHandler; +import de.devin.cbbees.content.drone.client.DroneViewClientState; import de.devin.cbbees.items.AllItems; +import net.minecraft.world.entity.player.Inventory; import net.minecraft.world.entity.player.Player; import net.minecraft.world.item.ItemStack; import org.spongepowered.asm.mixin.Mixin; @@ -29,19 +31,40 @@ public abstract class SchematicHandlerFindMixin { /** * If Create's check returns null (item not a Create SchematicItem), - * we re-check for the Construction Planner. + * we re-check for the Construction Planner — first in main hand, + * then anywhere in inventory when drone view is active. */ @Inject(method = "findBlueprintInHand", at = @At("RETURN"), cancellable = true) private void ccr$findConstructionPlanner(Player player, CallbackInfoReturnable cir) { if (cir.getReturnValue() != null) return; + // Check main hand first (normal behavior) ItemStack stack = player.getMainHandItem(); - if (AllItems.INSTANCE.getCONSTRUCTION_PLANNER().isIn(stack) - && stack.has(AllDataComponents.SCHEMATIC_FILE) - && stack.getOrDefault(AllDataComponents.SCHEMATIC_DEPLOYED, false)) { + if (ccr$isDeployedPlanner(stack)) { activeSchematicItem = stack; activeHotbarSlot = player.getInventory().selected; cir.setReturnValue(stack); + return; + } + + // During drone view, search entire inventory for a deployed planner + if (DroneViewClientState.INSTANCE.getActive()) { + Inventory inv = player.getInventory(); + for (int i = 0; i < inv.getContainerSize(); i++) { + ItemStack candidate = inv.getItem(i); + if (ccr$isDeployedPlanner(candidate)) { + activeSchematicItem = candidate; + activeHotbarSlot = i; + cir.setReturnValue(candidate); + return; + } + } } } + + private static boolean ccr$isDeployedPlanner(ItemStack stack) { + return AllItems.INSTANCE.getCONSTRUCTION_PLANNER().isIn(stack) + && stack.has(AllDataComponents.SCHEMATIC_FILE) + && stack.getOrDefault(AllDataComponents.SCHEMATIC_DEPLOYED, false); + } } diff --git a/src/main/java/de/devin/cbbees/mixin/SchematicHandlerHudMixin.java b/src/main/java/de/devin/cbbees/mixin/SchematicHandlerHudMixin.java index bd290f2..720eced 100644 --- a/src/main/java/de/devin/cbbees/mixin/SchematicHandlerHudMixin.java +++ b/src/main/java/de/devin/cbbees/mixin/SchematicHandlerHudMixin.java @@ -2,6 +2,7 @@ import com.simibubi.create.AllDataComponents; import com.simibubi.create.content.schematics.client.SchematicHandler; +import de.devin.cbbees.content.drone.client.DroneViewClientState; import de.devin.cbbees.content.schematics.ConstructionPlannerItem; import de.devin.cbbees.content.schematics.client.ConstructionToolState; import de.devin.cbbees.items.AllItems; @@ -75,9 +76,9 @@ public abstract class SchematicHandlerHudMixin { Minecraft mc = Minecraft.getInstance(); if (mc.player == null) return; - // Ensure player still holds the planner - ItemStack mainHand = mc.player.getMainHandItem(); - if (!AllItems.INSTANCE.getCONSTRUCTION_PLANNER().isIn(mainHand)) { + // Find the planner (main hand, or inventory during drone view) + ItemStack mainHand = DroneViewClientState.findActivePlanner(mc.player); + if (mainHand.isEmpty()) { ConstructionToolState.setActiveTool(ConstructionToolState.CustomTool.NONE); return; } @@ -119,8 +120,8 @@ public abstract class SchematicHandlerHudMixin { if (AllKeys.INSTANCE.getSTART_ACTION().matches(key, 0)) { Minecraft mc = Minecraft.getInstance(); if (mc.player != null) { - ItemStack mainHand = mc.player.getMainHandItem(); - if (AllItems.INSTANCE.getCONSTRUCTION_PLANNER().isIn(mainHand)) { + ItemStack mainHand = DroneViewClientState.findActivePlanner(mc.player); + if (!mainHand.isEmpty()) { ccr$sendConstructionPacket(mainHand); // Clear client state immediately so Create deactivates cleanly ConstructionPlannerItem.Companion.clearSchematic(mainHand); diff --git a/src/main/java/de/devin/cbbees/mixin/ToolSelectionScreenMixin.java b/src/main/java/de/devin/cbbees/mixin/ToolSelectionScreenMixin.java index a8e0620..c9d4332 100644 --- a/src/main/java/de/devin/cbbees/mixin/ToolSelectionScreenMixin.java +++ b/src/main/java/de/devin/cbbees/mixin/ToolSelectionScreenMixin.java @@ -7,6 +7,7 @@ import com.simibubi.create.content.schematics.client.ToolSelectionScreen; import com.simibubi.create.content.schematics.client.tools.ToolType; import com.simibubi.create.foundation.gui.AllIcons; +import de.devin.cbbees.content.drone.client.DroneViewClientState; import de.devin.cbbees.content.schematics.client.ConstructionToolState; import de.devin.cbbees.items.AllItems; import net.minecraft.client.Minecraft; @@ -66,8 +67,8 @@ public abstract class ToolSelectionScreenMixin { private boolean ccr$hasExtraTools() { Minecraft mc = Minecraft.getInstance(); if (mc.player == null) return false; - ItemStack mainHand = mc.player.getMainHandItem(); - if (!AllItems.INSTANCE.getCONSTRUCTION_PLANNER().isIn(mainHand)) return false; + ItemStack planner = DroneViewClientState.findActivePlanner(mc.player); + if (planner.isEmpty()) return false; return CreateClient.SCHEMATIC_HANDLER.isDeployed(); } diff --git a/src/main/kotlin/de/devin/cbbees/CreateBuzzyBeez.kt b/src/main/kotlin/de/devin/cbbees/CreateBuzzyBeez.kt index 14c4136..0911150 100644 --- a/src/main/kotlin/de/devin/cbbees/CreateBuzzyBeez.kt +++ b/src/main/kotlin/de/devin/cbbees/CreateBuzzyBeez.kt @@ -19,6 +19,8 @@ import de.devin.cbbees.content.schematics.client.ConstructionPlannerClientEvents import de.devin.cbbees.content.schematics.client.ConstructionPlannerHUD import de.devin.cbbees.content.schematics.client.ConstructionRenderer import de.devin.cbbees.content.domain.events.PlayerTickEvent +import de.devin.cbbees.content.drone.client.DroneViewClientEvents +import de.devin.cbbees.content.drone.client.DroneViewHUD import de.devin.cbbees.content.schematics.client.DeconstructionClientEvents import de.devin.cbbees.content.schematics.client.DeconstructionRenderer import de.devin.cbbees.content.schematics.external.CreateModSchematicSource @@ -112,6 +114,7 @@ object CreateBuzzyBeez { NeoForge.EVENT_BUS.register(NetworkHighlightHandler::class.java) NeoForge.EVENT_BUS.register(ConstructionRenderer::class.java) NeoForge.EVENT_BUS.register(CargoPortLinkRenderer::class.java) + NeoForge.EVENT_BUS.register(DroneViewClientEvents::class.java) MOD_BUS.addListener { onClientSetup(it) } MOD_BUS.addListener { AllKeys.register(it) } MOD_BUS.addListener { event -> @@ -127,6 +130,12 @@ object CreateBuzzyBeez { ) { guiGraphics, deltaTracker -> DeconstructionRenderer.renderHUD(guiGraphics, deltaTracker) } + event.registerAbove( + VanillaGuiLayers.CHAT, + asResource("drone_view_hud") + ) { guiGraphics, deltaTracker -> + DroneViewHUD.renderHUD(guiGraphics, deltaTracker) + } } } @@ -144,6 +153,11 @@ object CreateBuzzyBeez { AllBlockEntityTypes.LOGISTICS_PORT.get(), { be, side -> be.getItemHandler(be.world) } ) + event.registerItem( + Capabilities.FluidHandler.ITEM, + { stack, _ -> de.devin.cbbees.content.backpack.BeehiveFluidHandler(stack) }, + de.devin.cbbees.items.AllItems.PORTABLE_BEEHIVE.get() + ) } NeoForge.EVENT_BUS.addListener { diff --git a/src/main/kotlin/de/devin/cbbees/config/CBBeesConfig.kt b/src/main/kotlin/de/devin/cbbees/config/CBBeesConfig.kt index d691b32..637fdff 100644 --- a/src/main/kotlin/de/devin/cbbees/config/CBBeesConfig.kt +++ b/src/main/kotlin/de/devin/cbbees/config/CBBeesConfig.kt @@ -28,6 +28,13 @@ object CBBeesConfig { val honeyEfficiencyBreakSpeedReduction: ModConfigSpec.DoubleValue val honeyEfficiencyCarryBonus: ModConfigSpec.IntValue val honeyEfficiencyFuelReduction: ModConfigSpec.DoubleValue + val honeyTankCapacityBonus: ModConfigSpec.IntValue + val reinforcedPlatingSpringBonus: ModConfigSpec.DoubleValue + + // Drone view settings + val droneBaseRange: ModConfigSpec.DoubleValue + val droneRangeBonus: ModConfigSpec.DoubleValue + val droneMoveSpeed: ModConfigSpec.DoubleValue // Spring (clockwork) settings val springDrainPlace: ModConfigSpec.DoubleValue @@ -125,6 +132,31 @@ object CBBeesConfig { .comment("Fuel consumption reduction per Honey Efficiency upgrade (0.15 = 15% less fuel)") .defineInRange("honeyEfficiencyFuelReduction", 0.15, 0.01, 1.0) + honeyTankCapacityBonus = builder + .comment("Extra honey capacity per Honey Tank upgrade") + .defineInRange("honeyTankCapacityBonus", 200, 50, 5000) + + reinforcedPlatingSpringBonus = builder + .comment("Spring efficiency bonus per Reinforced Plating upgrade (0.25 = +25%)") + .defineInRange("reinforcedPlatingSpringBonus", 0.25, 0.01, 2.0) + + builder.pop() + + builder.comment("Drone View Settings — controls drone camera behavior") + .push("drone_view") + + droneBaseRange = builder + .comment("Base range (blocks) the drone can move from the player without upgrades") + .defineInRange("droneBaseRange", 32.0, 8.0, 256.0) + + droneRangeBonus = builder + .comment("Extra range (blocks) per Drone Range upgrade") + .defineInRange("droneRangeBonus", 16.0, 4.0, 128.0) + + droneMoveSpeed = builder + .comment("Drone movement speed in blocks per tick when controlled by WASD") + .defineInRange("droneMoveSpeed", 1.5, 0.1, 5.0) + builder.pop() builder.comment("Clockwork Spring Settings — controls per-action energy drain on bees") diff --git a/src/main/kotlin/de/devin/cbbees/content/backpack/BeehiveContainer.kt b/src/main/kotlin/de/devin/cbbees/content/backpack/BeehiveContainer.kt index ca6d0ee..dc66872 100644 --- a/src/main/kotlin/de/devin/cbbees/content/backpack/BeehiveContainer.kt +++ b/src/main/kotlin/de/devin/cbbees/content/backpack/BeehiveContainer.kt @@ -3,7 +3,6 @@ package de.devin.cbbees.content.backpack import de.devin.cbbees.config.CBBeesConfig import de.devin.cbbees.content.bee.MechanicalBeeItem import de.devin.cbbees.content.bee.MechanicalBumbleBeeItem -import de.devin.cbbees.content.upgrades.BeeUpgradeItem import de.devin.cbbees.registry.AllDataComponents import net.minecraft.core.NonNullList import net.minecraft.core.component.DataComponents @@ -58,7 +57,7 @@ class BeehiveContainer : AbstractContainerMenu { this.fuelData = object : ContainerData { override fun get(index: Int): Int = when (index) { 0 -> backpackStack.getOrDefault(AllDataComponents.HONEY_FUEL.get(), 0) - 1 -> CBBeesConfig.portableMaxHoney.get() + 1 -> (backpackStack.item as? PortableBeehiveItem)?.getMaxHoney(backpackStack) ?: CBBeesConfig.portableMaxHoney.get() else -> 0 } @@ -66,7 +65,10 @@ class BeehiveContainer : AbstractContainerMenu { override fun getCount(): Int = 2 } - // Load contents from the backpack item + // Migrate old flat upgrades to grid if needed + PortableBeehiveItem.migrateUpgradesToGrid(backpackStack) + + // Load contents from the backpack item (robot slots only) val contents = backpackStack.get(DataComponents.CONTAINER) if (contents != null) { val items = NonNullList.withSize(PortableBeehiveItem.TOTAL_SLOTS, ItemStack.EMPTY) @@ -82,30 +84,20 @@ class BeehiveContainer : AbstractContainerMenu { private fun setupSlots() { addDataSlots(fuelData) - // Updated positions for Create-style GUI (FILTER + PLAYER_INVENTORY backgrounds) - // FILTER background is 214x99, PLAYER_INVENTORY is 176x108 - // 4px gap between them - - // Robot slots (2 slots, stacked vertically) - positions from custom texture + // Robot slots (2 slots, stacked vertically) val beeSlotPositions = listOf(8 to 24, 8 to 51) for (i in 0 until PortableBeehiveItem.ROBOT_SLOTS) { val (x, y) = beeSlotPositions[i] addSlot(RobotSlot(backpackInventory, i, x, y)) } - // Upgrade slots (4 slots in a row) - positions from custom texture - val upgradeSlotPositions = listOf(99 to 38, 120 to 38, 141 to 38, 162 to 38) - for (i in 0 until PortableBeehiveItem.UPGRADE_SLOTS) { - val (x, y) = upgradeSlotPositions[i] - addSlot(UpgradeSlot(backpackInventory, PortableBeehiveItem.ROBOT_SLOTS + i, x, y)) - } + // Upgrades are now managed via the grid system (no container slots) // Add player inventory slots - // Custom background height (102) + gap (4) = 106, then PLAYER_INVENTORY starts - // PLAYER_INVENTORY is centered: (200 - 176) / 2 = 12 offset - // Player inventory slots start at y=106+18=124 (18px from top of PLAYER_INVENTORY for label) - val invX = 12 + 8 // Center offset + standard inventory padding - val invY = 106 + 17 + // Must match screen's renderPlayerInventory position: slots start at renderX+8, renderY+18 + // Screen renders at x = (imageWidth - 176) / 2 = 11, y = BG_HEIGHT + 3 = 115 + val invX = 11 + 8 // 19 + val invY = 115 + 18 // 133 for (row in 0 until 3) { for (col in 0 until 9) { addSlot(Slot(playerInventory, col + row * 9 + 9, invX + col * 18, invY + row * 18)) @@ -134,16 +126,12 @@ class BeehiveContainer : AbstractContainerMenu { return ItemStack.EMPTY } } else { - // Moving from player inventory to backpack - // Try robot slots first, then upgrade slots + // Moving from player inventory to backpack — only robot slots + // Upgrades are placed via the grid system, not shift-click if (slotStack.item is MechanicalBeeItem || slotStack.item is MechanicalBumbleBeeItem) { if (!moveItemStackTo(slotStack, 0, PortableBeehiveItem.ROBOT_SLOTS, false)) { return ItemStack.EMPTY } - } else if (slotStack.item is BeeUpgradeItem) { - if (!moveItemStackTo(slotStack, PortableBeehiveItem.ROBOT_SLOTS, backpackSlotCount, false)) { - return ItemStack.EMPTY - } } else { return ItemStack.EMPTY } @@ -176,7 +164,7 @@ class BeehiveContainer : AbstractContainerMenu { } private fun saveBackpackContents() { - // Save the container contents back to the backpack item + // Save robot slot contents back to the backpack item val items = mutableListOf() for (i in 0 until backpackInventory.containerSize) { items.add(backpackInventory.getItem(i)) @@ -195,17 +183,6 @@ class BeehiveContainer : AbstractContainerMenu { override fun getMaxStackSize(): Int = MechanicalBeeItem.MAX_STACK_SIZE } - /** - * Slot that only accepts upgrade items - */ - inner class UpgradeSlot(container: Container, index: Int, x: Int, y: Int) : Slot(container, index, x, y) { - override fun mayPlace(stack: ItemStack): Boolean { - return stack.item is BeeUpgradeItem - } - - override fun getMaxStackSize(): Int = 1 - } - override fun clickMenuButton(player: Player, id: Int): Boolean { if (id == 0) { // Accept button — save and close diff --git a/src/main/kotlin/de/devin/cbbees/content/backpack/BeehiveFluidHandler.kt b/src/main/kotlin/de/devin/cbbees/content/backpack/BeehiveFluidHandler.kt new file mode 100644 index 0000000..b669fff --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/backpack/BeehiveFluidHandler.kt @@ -0,0 +1,67 @@ +package de.devin.cbbees.content.backpack + +import com.simibubi.create.AllFluids +import de.devin.cbbees.config.CBBeesConfig +import de.devin.cbbees.registry.AllDataComponents +import net.minecraft.world.item.ItemStack +import net.neoforged.neoforge.fluids.FluidStack +import net.neoforged.neoforge.fluids.capability.IFluidHandlerItem + +/** + * Fluid handler that allows the Portable Beehive to be filled with honey via Create's Spout. + * + * Converts honey fluid (mB) to internal honey fuel units. + * 1 mB of honey = 1 honey fuel unit. + */ +class BeehiveFluidHandler(private val stack: ItemStack) : IFluidHandlerItem { + + override fun getTanks(): Int = 1 + + override fun getFluidInTank(tank: Int): FluidStack { + val fuel = stack.getOrDefault(AllDataComponents.HONEY_FUEL.get(), 0) + if (fuel <= 0) return FluidStack.EMPTY + return FluidStack(AllFluids.HONEY.get().source, fuel) + } + + override fun getTankCapacity(tank: Int): Int { + val beehiveItem = stack.item as? PortableBeehiveItem ?: return CBBeesConfig.portableMaxHoney.get() + return beehiveItem.getMaxHoney(stack) + } + + override fun isFluidValid(tank: Int, fluidStack: FluidStack): Boolean { + return fluidStack.fluid.isSame(AllFluids.HONEY.get().source) + } + + override fun fill(resource: FluidStack, action: net.neoforged.neoforge.fluids.capability.IFluidHandler.FluidAction): Int { + if (!isFluidValid(0, resource)) return 0 + + val current = stack.getOrDefault(AllDataComponents.HONEY_FUEL.get(), 0) + val max = getTankCapacity(0) + val space = max - current + if (space <= 0) return 0 + + val toFill = minOf(space, resource.amount) + if (action.execute()) { + stack.set(AllDataComponents.HONEY_FUEL.get(), current + toFill) + } + return toFill + } + + override fun drain(resource: FluidStack, action: net.neoforged.neoforge.fluids.capability.IFluidHandler.FluidAction): FluidStack { + if (!resource.fluid.isSame(AllFluids.HONEY.get().source)) return FluidStack.EMPTY + return drain(resource.amount, action) + } + + override fun drain(maxDrain: Int, action: net.neoforged.neoforge.fluids.capability.IFluidHandler.FluidAction): FluidStack { + val current = stack.getOrDefault(AllDataComponents.HONEY_FUEL.get(), 0) + if (current <= 0) return FluidStack.EMPTY + + val drained = minOf(current, maxDrain) + if (action.execute()) { + stack.set(AllDataComponents.HONEY_FUEL.get(), current - drained) + } + return FluidStack(AllFluids.HONEY.get().source, drained) + } + + override fun getContainer(): ItemStack = stack +} diff --git a/src/main/kotlin/de/devin/cbbees/content/backpack/BeehiveScreen.kt b/src/main/kotlin/de/devin/cbbees/content/backpack/BeehiveScreen.kt index 0f883c1..97ffd87 100644 --- a/src/main/kotlin/de/devin/cbbees/content/backpack/BeehiveScreen.kt +++ b/src/main/kotlin/de/devin/cbbees/content/backpack/BeehiveScreen.kt @@ -5,6 +5,8 @@ import com.simibubi.create.foundation.gui.AllIcons import com.simibubi.create.foundation.gui.menu.AbstractSimiContainerScreen import com.simibubi.create.foundation.gui.widget.IconButton import de.devin.cbbees.CreateBuzzyBeez +import de.devin.cbbees.content.backpack.client.UpgradeGridWidget +import de.devin.cbbees.content.upgrades.BeeUpgradeItem import net.minecraft.client.gui.GuiGraphics import net.minecraft.network.chat.Component import net.minecraft.resources.ResourceLocation @@ -13,12 +15,10 @@ import net.minecraft.world.entity.player.Inventory /** * Screen/GUI for the Constructor Backpack. * - * Uses a custom 200x102 background texture with Create's PLAYER_INVENTORY below. - * * Displays: * - 2 bee slots (stacked vertically) - * - 4 upgrade slots (horizontal row) - * - Honey fuel gauge + * - 6x5 upgrade grid (separate widget) + * - Shape preview for carried / hovered upgrade * - Player inventory */ class BeehiveScreen( @@ -34,26 +34,28 @@ class BeehiveScreen( const val TEXTURE_SIZE = 256 // Custom background dimensions - const val BG_WIDTH = 200 - const val BG_HEIGHT = 102 + const val BG_WIDTH = 199 + const val BG_HEIGHT = 112 + + // Fuel gauge position and size within the GUI + const val FUEL_W = 16 + const val FUEL_X = 8 + const val FUEL_Y = 71 + const val FUEL_H = 37 // Filled fuel bar on the atlas - const val FUEL_U = 206 - const val FUEL_V = 1 - const val FUEL_W = 13 - const val FUEL_H = 42 - - // Position of the fuel gauge within the GUI (where the empty outline is) - const val FUEL_X = 71 - const val FUEL_Y = 24 + const val FUEL_U = 210 + const val FUEL_V = 5 + + // Grid widget origin relative to the window + const val GRID_X = 29 + const val GRID_Y = 19 } private lateinit var confirmButton: IconButton + private lateinit var gridWidget: UpgradeGridWidget init { - // Set window size to accommodate custom background + player inventory - // Custom background is 200x102, PLAYER_INVENTORY is 176x108 - // Add 4px gap between them imageWidth = maxOf(BG_WIDTH, PLAYER_INV.width) imageHeight = BG_HEIGHT + 4 + PLAYER_INV.height } @@ -61,45 +63,109 @@ class BeehiveScreen( override fun init() { super.init() - confirmButton = IconButton(leftPos + 167, topPos + 78, AllIcons.I_CONFIRM) + // Confirm button + confirmButton = IconButton(leftPos + 166, topPos + 88, AllIcons.I_CONFIRM) confirmButton.withCallback(Runnable { minecraft?.gameMode?.handleInventoryButtonClick(menu.containerId, 0) }) addRenderableWidget(confirmButton) + + // Upgrade grid widget + gridWidget = UpgradeGridWidget( + leftPos + GRID_X, + topPos + GRID_Y, + menu + ) { menu.backpackStack } + addRenderableWidget(gridWidget) } + // ── background ── + override fun renderBg(guiGraphics: GuiGraphics, partialTick: Float, mouseX: Int, mouseY: Int) { val x = leftPos val y = topPos - // Draw the custom background texture (200x102 at 0,0 on the atlas) - guiGraphics.blit( - TEXTURE, - x, y, - 0f, 0f, - BG_WIDTH, BG_HEIGHT, - TEXTURE_SIZE, TEXTURE_SIZE - ) + // Custom background texture + guiGraphics.blit(TEXTURE, x, y, 0f, 0f, BG_WIDTH, BG_HEIGHT, TEXTURE_SIZE, TEXTURE_SIZE) - // Draw the player inventory background below + // Player inventory val invX = leftPos + (imageWidth - PLAYER_INV.width) / 2 val invY = topPos + BG_HEIGHT + 3 renderPlayerInventory(guiGraphics, invX, invY) + } + + // ── foreground (above slots, below tooltips) ── + + override fun render(guiGraphics: GuiGraphics, mouseX: Int, mouseY: Int, partialTick: Float) { + super.render(guiGraphics, mouseX, mouseY, partialTick) + + // Fuel gauge — rendered above the upgrade grid, fills from bottom up + renderFuelGauge(guiGraphics) + + // Fuel gauge tooltip + if (isHoveringFuelGauge(mouseX, mouseY)) { + val fuel = menu.fuelData.get(0) + val maxFuel = menu.fuelData.get(1) + guiGraphics.renderTooltip( + font, + Component.translatable("tooltip.cbbees.beehive.honey", fuel, maxFuel), + mouseX, mouseY + ) + } - // Fuel gauge — render filled portion from bottom up + // Render tooltip last + renderTooltip(guiGraphics, mouseX, mouseY) + } + + private fun isHoveringFuelGauge(mouseX: Int, mouseY: Int): Boolean { + val gx = leftPos + FUEL_X + val gy = topPos + FUEL_Y + return mouseX >= gx && mouseX < gx + FUEL_W && mouseY >= gy && mouseY < gy + FUEL_H + } + + private fun renderFuelGauge(guiGraphics: GuiGraphics) { val fuel = menu.fuelData.get(0) val maxFuel = menu.fuelData.get(1) - if (maxFuel > 0 && fuel > 0) { - val fillHeight = (fuel * FUEL_H / maxFuel).coerceAtMost(FUEL_H) - val emptyHeight = FUEL_H - fillHeight - guiGraphics.blit( - TEXTURE, - x + FUEL_X, y + FUEL_Y + emptyHeight, - FUEL_U.toFloat(), (FUEL_V + emptyHeight).toFloat(), - FUEL_W, fillHeight, - TEXTURE_SIZE, TEXTURE_SIZE - ) + if (maxFuel <= 0 || fuel <= 0) return + + val fillHeight = (fuel * FUEL_H / maxFuel).coerceAtMost(FUEL_H) + val emptyHeight = FUEL_H - fillHeight + guiGraphics.blit( + TEXTURE, + leftPos + FUEL_X, topPos + FUEL_Y + emptyHeight, + FUEL_U.toFloat(), (FUEL_V + emptyHeight).toFloat(), + FUEL_W, fillHeight, + TEXTURE_SIZE, TEXTURE_SIZE + ) + } + + // ── mouse events ── + + override fun mouseClicked(mouseX: Double, mouseY: Double, button: Int): Boolean { + // Right-click anywhere with upgrade on cursor: rotate preview + if (button == 1 && ::gridWidget.isInitialized) { + val carried = menu.carried + if (!carried.isEmpty && carried.item is BeeUpgradeItem) { + gridWidget.currentRotation = (gridWidget.currentRotation + 1) % 4 + return true + } } + return super.mouseClicked(mouseX, mouseY, button) } + override fun mouseScrolled(mouseX: Double, mouseY: Double, scrollX: Double, scrollY: Double): Boolean { + // Scroll anywhere with upgrade on cursor: rotate preview + if (::gridWidget.isInitialized) { + val carried = menu.carried + if (!carried.isEmpty && carried.item is BeeUpgradeItem) { + gridWidget.currentRotation = if (scrollY > 0) { + (gridWidget.currentRotation + 1) % 4 + } else { + (gridWidget.currentRotation + 3) % 4 + } + return true + } + } + return super.mouseScrolled(mouseX, mouseY, scrollX, scrollY) + } } diff --git a/src/main/kotlin/de/devin/cbbees/content/backpack/PortableBeehiveItem.kt b/src/main/kotlin/de/devin/cbbees/content/backpack/PortableBeehiveItem.kt index b3ebef9..912c981 100644 --- a/src/main/kotlin/de/devin/cbbees/content/backpack/PortableBeehiveItem.kt +++ b/src/main/kotlin/de/devin/cbbees/content/backpack/PortableBeehiveItem.kt @@ -4,6 +4,7 @@ import de.devin.cbbees.content.bee.MechanicalBeeItem import de.devin.cbbees.content.bee.MechanicalBumbleBeeItem import de.devin.cbbees.content.upgrades.BeeUpgradeItem import de.devin.cbbees.content.upgrades.BeeContext +import de.devin.cbbees.content.upgrades.UpgradeGrid import de.devin.cbbees.content.upgrades.UpgradeType import de.devin.cbbees.config.CBBeesConfig import de.devin.cbbees.registry.AllDataComponents @@ -93,11 +94,70 @@ class PortableBeehiveItem(properties: Properties) : ArmorItem(ArmorMaterials.IRO companion object { const val ROBOT_SLOTS = 2 - const val UPGRADE_SLOTS = 4 - const val TOTAL_SLOTS = ROBOT_SLOTS + UPGRADE_SLOTS + const val UPGRADE_SLOTS = 0 + const val TOTAL_SLOTS = ROBOT_SLOTS // NBT keys for the container const val TAG_INVENTORY = "BackpackInventory" + + /** Legacy slot count for migration from old flat upgrade layout */ + const val LEGACY_TOTAL_SLOTS = 6 + const val LEGACY_UPGRADE_SLOTS = 4 + + /** + * One-time migration: reads old upgrades from CONTAINER slots 2-5, + * auto-places them into a new UpgradeGrid, and trims the CONTAINER to robot-only slots. + */ + fun migrateUpgradesToGrid(stack: ItemStack) { + // Skip if grid already exists + if (stack.has(AllDataComponents.UPGRADE_GRID.get())) return + + val contents = stack.get(DataComponents.CONTAINER) ?: return + val items = NonNullList.withSize(LEGACY_TOTAL_SLOTS, ItemStack.EMPTY) + contents.copyInto(items) + + // Check if there are any upgrades in legacy slots (indices 2-5) + val legacyUpgrades = mutableListOf() + for (i in ROBOT_SLOTS until LEGACY_TOTAL_SLOTS) { + val s = items[i] + val upgradeItem = s.item as? BeeUpgradeItem + if (upgradeItem != null && !s.isEmpty) { + legacyUpgrades.add(upgradeItem.upgradeType) + } + } + + if (legacyUpgrades.isEmpty()) return + + // Auto-place into grid + val grid = UpgradeGrid() + for (type in legacyUpgrades) { + // Try all positions and rotations to find a valid placement + var placed = false + for (rotation in 0 until 4) { + if (placed) break + for (y in 0 until UpgradeGrid.ROWS) { + if (placed) break + for (x in 0 until UpgradeGrid.COLS) { + if (grid.canPlace(type, x, y, rotation)) { + grid.place(type, x, y, rotation) + placed = true + break + } + } + } + } + } + + // Save grid + stack.set(AllDataComponents.UPGRADE_GRID.get(), grid) + + // Trim CONTAINER to robot-only slots + val robotItems = mutableListOf() + for (i in 0 until ROBOT_SLOTS) { + robotItems.add(items[i]) + } + stack.set(DataComponents.CONTAINER, ItemContainerContents.fromItems(robotItems)) + } } override fun use(level: Level, player: Player, usedHand: InteractionHand): InteractionResultHolder { @@ -112,7 +172,7 @@ class PortableBeehiveItem(properties: Properties) : ArmorItem(ArmorMaterials.IRO if (fuelValue > 0) { if (!level.isClientSide) { val current = stack.getOrDefault(AllDataComponents.HONEY_FUEL.get(), 0) - val max = CBBeesConfig.portableMaxHoney.get() + val max = getMaxHoney(stack) if (current >= max) { player.displayClientMessage(Component.translatable("cbbees.beehive.honey_full"), true) } else { @@ -185,15 +245,14 @@ class PortableBeehiveItem(properties: Properties) : ArmorItem(ArmorMaterials.IRO override fun isBarVisible(stack: ItemStack): Boolean { val honey = stack.getOrDefault(AllDataComponents.HONEY_FUEL.get(), 0) - val maxHoney = CBBeesConfig.portableMaxHoney.get() - return honey < maxHoney + return honey < getMaxHoney(stack) } override fun getBarWidth(stack: ItemStack): Int { - val maxHoney = CBBeesConfig.portableMaxHoney.get() - if (maxHoney <= 0) return 0 + val max = getMaxHoney(stack) + if (max <= 0) return 0 val honey = stack.getOrDefault(AllDataComponents.HONEY_FUEL.get(), 0) - return (13.0f * honey / maxHoney).roundToInt() + return (13.0f * honey / max).roundToInt() } override fun getBarColor(stack: ItemStack): Int { @@ -218,7 +277,7 @@ class PortableBeehiveItem(properties: Properties) : ArmorItem(ArmorMaterials.IRO ) val honey = stack.getOrDefault(AllDataComponents.HONEY_FUEL.get(), 0) - val maxHoney = CBBeesConfig.portableMaxHoney.get() + val maxHoney = getMaxHoney(stack) tooltipComponents.add( Component.translatable("tooltip.cbbees.beehive.honey", honey, maxHoney) .withStyle(ChatFormatting.GOLD) @@ -260,21 +319,11 @@ class PortableBeehiveItem(properties: Properties) : ArmorItem(ArmorMaterials.IRO } /** - * Retrieves all upgrades and their counts from the backpack. + * Retrieves all upgrades and their counts from the backpack's upgrade grid. */ fun getUpgrades(stack: ItemStack): Map { - val contents = stack.get(DataComponents.CONTAINER) ?: return emptyMap() - val items = NonNullList.withSize(TOTAL_SLOTS, ItemStack.EMPTY) - contents.copyInto(items) - - val upgrades = mutableMapOf() - items.subList(ROBOT_SLOTS, TOTAL_SLOTS).forEach { itemStack -> - val upgradeItem = itemStack.item as? BeeUpgradeItem - if (upgradeItem != null) { - upgrades[upgradeItem.upgradeType] = upgrades.getOrDefault(upgradeItem.upgradeType, 0) + 1 - } - } - return upgrades + val grid = stack.get(AllDataComponents.UPGRADE_GRID.get()) ?: return emptyMap() + return grid.getUpgradeCounts() } /** @@ -351,10 +400,11 @@ class PortableBeehiveItem(properties: Properties) : ArmorItem(ArmorMaterials.IRO } /** - * Gets the count of a specific upgrade type. + * Gets the count of a specific upgrade type from the upgrade grid. */ fun getUpgradeCount(stack: ItemStack, type: UpgradeType): Int { - return getUpgrades(stack).getOrDefault(type, 0) + val grid = stack.get(AllDataComponents.UPGRADE_GRID.get()) ?: return 0 + return grid.getUpgradeCounts().getOrDefault(type, 0) } /** @@ -364,6 +414,15 @@ class PortableBeehiveItem(properties: Properties) : ArmorItem(ArmorMaterials.IRO return UpgradeType.fromBackpack(stack) } + /** + * Gets the maximum honey capacity, including bonuses from Honey Tank upgrades. + */ + fun getMaxHoney(stack: ItemStack): Int { + val base = CBBeesConfig.portableMaxHoney.get() + val context = getBeeContext(stack) + return base + context.honeyCapacityBonus + } + // ICurioItem implementation override fun canEquip(slotContext: SlotContext, stack: ItemStack): Boolean { diff --git a/src/main/kotlin/de/devin/cbbees/content/backpack/client/BeehiveTooltipComponent.kt b/src/main/kotlin/de/devin/cbbees/content/backpack/client/BeehiveTooltipComponent.kt index f04dbb6..f4d03b2 100644 --- a/src/main/kotlin/de/devin/cbbees/content/backpack/client/BeehiveTooltipComponent.kt +++ b/src/main/kotlin/de/devin/cbbees/content/backpack/client/BeehiveTooltipComponent.kt @@ -3,6 +3,9 @@ package de.devin.cbbees.content.backpack.client import com.simibubi.create.foundation.gui.AllGuiTextures import de.devin.cbbees.content.backpack.BeehiveTooltipData import de.devin.cbbees.content.backpack.PortableBeehiveItem +import de.devin.cbbees.content.upgrades.UpgradeGrid +import de.devin.cbbees.content.upgrades.UpgradeType +import de.devin.cbbees.registry.AllDataComponents import net.minecraft.client.gui.Font import net.minecraft.client.gui.GuiGraphics import net.minecraft.client.gui.screens.inventory.tooltip.ClientTooltipComponent @@ -12,52 +15,88 @@ import net.minecraft.world.item.ItemStack /** * Client-side renderer for the backpack tooltip preview. - * - * Renders a small grid showing robots and upgrades currently in the backpack. - * Layout: 1 row of 4 robot slots, 1 row of 6 upgrade slots. + * + * Renders robot slots and a mini 6x4 upgrade grid. */ class BeehiveTooltipComponent(val data: BeehiveTooltipData) : ClientTooltipComponent { private val items: NonNullList = NonNullList.withSize(PortableBeehiveItem.TOTAL_SLOTS, ItemStack.EMPTY) + private val grid: UpgradeGrid? init { val contents = data.stack.get(DataComponents.CONTAINER) contents?.copyInto(items) + grid = data.stack.get(AllDataComponents.UPGRADE_GRID.get()) + } + + companion object { + const val MINI_CELL = 6 // Each mini grid cell size in pixels } override fun getHeight(): Int { - // 1 row of robots (18px) + 1 row of upgrades (18px) + padding - return 2 * 18 + 4 + // Robot row (18px) + gap (2px) + mini grid (ROWS * MINI_CELL) + padding + return 18 + 2 + UpgradeGrid.ROWS * MINI_CELL + 4 } override fun getWidth(font: Font): Int { - // 6 columns for upgrades (18px each) - widest row - return 6 * 18 + 2 + // Wider of: robot slots or mini grid + val robotWidth = PortableBeehiveItem.ROBOT_SLOTS * 18 + 2 + val gridWidth = UpgradeGrid.COLS * MINI_CELL + 2 + return maxOf(robotWidth, gridWidth) } override fun renderImage(font: Font, x: Int, y: Int, guiGraphics: GuiGraphics) { var currentY = y - // Render robots (1x4 grid, centered) - val robotOffset = (6 - PortableBeehiveItem.ROBOT_SLOTS) * 18 / 2 // Center the 4 slots in 6-wide space + // Render robots for (i in 0 until PortableBeehiveItem.ROBOT_SLOTS) { val stack = items[i] - renderSlot(guiGraphics, x + robotOffset + i * 18, currentY, stack, font) + renderSlot(guiGraphics, x + i * 18, currentY, stack, font) } - // Render upgrades (1x6 grid) + // Render mini upgrade grid below robots currentY += 18 + 2 - for (i in 0 until PortableBeehiveItem.UPGRADE_SLOTS) { - val index = PortableBeehiveItem.ROBOT_SLOTS + i - val stack = items[index] - renderSlot(guiGraphics, x + i * 18, currentY, stack, font) + renderMiniGrid(guiGraphics, x, currentY) + } + + private fun renderMiniGrid(guiGraphics: GuiGraphics, x: Int, y: Int) { + val g = grid ?: return + + // Draw empty cell backgrounds + for (row in 0 until UpgradeGrid.ROWS) { + for (col in 0 until UpgradeGrid.COLS) { + val cx = x + col * MINI_CELL + val cy = y + row * MINI_CELL + guiGraphics.fill(cx, cy, cx + MINI_CELL - 1, cy + MINI_CELL - 1, 0xFF333333.toInt()) + } + } + + // Draw colored cells for placed upgrades + for (row in 0 until UpgradeGrid.ROWS) { + for (col in 0 until UpgradeGrid.COLS) { + val type = g.occupied[row][col] ?: continue + val cx = x + col * MINI_CELL + val cy = y + row * MINI_CELL + guiGraphics.fill(cx, cy, cx + MINI_CELL - 1, cy + MINI_CELL - 1, getUpgradeColor(type)) + } } } + private fun getUpgradeColor(type: UpgradeType): Int = when (type) { + UpgradeType.RAPID_WINGS -> 0xFFFF6600.toInt() // Orange + UpgradeType.SWARM_INTELLIGENCE -> 0xFF00AAFF.toInt() // Blue + UpgradeType.HONEY_EFFICIENCY -> 0xFFFFDD00.toInt() // Yellow + UpgradeType.SOFT_TOUCH -> 0xFF00FF88.toInt() // Green + UpgradeType.DROP_ITEMS -> 0xFFFF4444.toInt() // Red + UpgradeType.HONEY_TANK -> 0xFFD97F00.toInt() // Amber + UpgradeType.REINFORCED_PLATING -> 0xFF8888AA.toInt() // Steel + UpgradeType.DRONE_VIEW -> 0xFF9933FF.toInt() // Purple + UpgradeType.DRONE_RANGE -> 0xFF00CCCC.toInt() // Cyan + } + private fun renderSlot(guiGraphics: GuiGraphics, x: Int, y: Int, stack: ItemStack, font: Font) { - // Draw slot background AllGuiTextures.JEI_SLOT.render(guiGraphics, x, y) - + if (!stack.isEmpty) { guiGraphics.renderItem(stack, x + 1, y + 1) guiGraphics.renderItemDecorations(font, stack, x + 1, y + 1) diff --git a/src/main/kotlin/de/devin/cbbees/content/backpack/client/UpgradeGridWidget.kt b/src/main/kotlin/de/devin/cbbees/content/backpack/client/UpgradeGridWidget.kt new file mode 100644 index 0000000..1dbf4e0 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/backpack/client/UpgradeGridWidget.kt @@ -0,0 +1,266 @@ +package de.devin.cbbees.content.backpack.client + +import com.simibubi.create.foundation.gui.AllGuiTextures +import de.devin.cbbees.content.upgrades.BeeUpgradeItem +import de.devin.cbbees.content.upgrades.UpgradeGrid +import de.devin.cbbees.content.upgrades.UpgradeType +import de.devin.cbbees.items.AllItems +import de.devin.cbbees.network.GridPlaceUpgradePacket +import de.devin.cbbees.network.GridRemoveUpgradePacket +import de.devin.cbbees.registry.AllDataComponents +import net.minecraft.client.Minecraft +import net.minecraft.client.gui.GuiGraphics +import net.minecraft.client.gui.components.AbstractWidget +import net.minecraft.client.gui.narration.NarrationElementOutput +import net.minecraft.network.chat.Component +import net.minecraft.world.inventory.AbstractContainerMenu +import net.minecraft.world.item.ItemStack +import net.neoforged.neoforge.network.PacketDistributor + +/** + * Self-contained widget for the 5x3 upgrade grid using standard 18x18 item slots. + * + * Maintains a client-side [UpgradeGrid] for instant feedback (optimistic prediction). + * Server is authoritative — packets are sent for every mutation. + */ +class UpgradeGridWidget( + x: Int, + y: Int, + private val menu: AbstractContainerMenu, + private val backpackStackGetter: () -> ItemStack +) : AbstractWidget(x, y, UpgradeGrid.COLS * CELL_SIZE, UpgradeGrid.ROWS * CELL_SIZE, Component.empty()) { + + companion object { + const val CELL_SIZE = 18 + const val PREVIEW_CELL = 8 + } + + /** Client-side grid for optimistic rendering. */ + var clientGrid: UpgradeGrid = loadGridFromStack() + private set + + /** Current rotation for placement preview (0-3). Resets when the carried upgrade type changes. */ + var currentRotation: Int = 0 + private var rotationForType: UpgradeType? = null + + fun getRotationFor(type: UpgradeType): Int { + if (type != rotationForType) { + currentRotation = 0 + rotationForType = type + } + return currentRotation + } + + private fun loadGridFromStack(): UpgradeGrid { + return backpackStackGetter().get(AllDataComponents.UPGRADE_GRID.get())?.copy() ?: UpgradeGrid() + } + + fun resyncFromStack() { + clientGrid = loadGridFromStack() + } + + private fun cellAt(mouseX: Double, mouseY: Double): Pair? { + if (mouseX < x || mouseY < y) return null + val col = ((mouseX - x) / CELL_SIZE).toInt() + val row = ((mouseY - y) / CELL_SIZE).toInt() + if (col < 0 || col >= UpgradeGrid.COLS || row < 0 || row >= UpgradeGrid.ROWS) return null + return col to row + } + + // ── rendering ── + + override fun renderWidget(guiGraphics: GuiGraphics, mouseX: Int, mouseY: Int, partialTick: Float) { + val grid = clientGrid + + // 1. Slot backgrounds + for (row in 0 until UpgradeGrid.ROWS) { + for (col in 0 until UpgradeGrid.COLS) { + AllGuiTextures.JEI_SLOT.render(guiGraphics, x + col * CELL_SIZE, y + row * CELL_SIZE) + } + } + + // 2. Placed upgrade item icons + outlines + for (placement in grid.placements) { + val shape = placement.type.shape.rotated(placement.rotation) + val itemStack = getItemForUpgrade(placement.type) + val cellSet = shape.cells.map { (dx, dy) -> (placement.x + dx) to (placement.y + dy) }.toSet() + val outlineColor = getUpgradeColor(placement.type) + + for ((dx, dy) in shape.cells) { + val gx = placement.x + dx + val gy = placement.y + dy + val cx = x + gx * CELL_SIZE + val cy = y + gy * CELL_SIZE + + // Item icon + guiGraphics.renderItem(itemStack, cx + 1, cy + 1) + + // Outline edges — draw a 2px border on sides not adjacent to same placement + val borderW = 2 + // Top + if ((gx to (gy - 1)) !in cellSet) + guiGraphics.fill(cx, cy, cx + CELL_SIZE, cy + borderW, outlineColor) + // Bottom + if ((gx to (gy + 1)) !in cellSet) + guiGraphics.fill(cx, cy + CELL_SIZE - borderW, cx + CELL_SIZE, cy + CELL_SIZE, outlineColor) + // Left + if (((gx - 1) to gy) !in cellSet) + guiGraphics.fill(cx, cy, cx + borderW, cy + CELL_SIZE, outlineColor) + // Right + if (((gx + 1) to gy) !in cellSet) + guiGraphics.fill(cx + CELL_SIZE - borderW, cy, cx + CELL_SIZE, cy + CELL_SIZE, outlineColor) + } + } + + // 3. Hover overlays + val cell = cellAt(mouseX.toDouble(), mouseY.toDouble()) + val carried = menu.carried + + if (cell != null && !carried.isEmpty && carried.item is BeeUpgradeItem) { + val type = (carried.item as BeeUpgradeItem).upgradeType + val rot = getRotationFor(type) + val shape = type.shape.rotated(rot) + val canPlace = grid.canPlace(type, cell.first, cell.second, rot) + val color = if (canPlace) 0x6000FF00.toInt() else 0x60FF0000.toInt() + for ((dx, dy) in shape.cells) { + val px = cell.first + dx + val py = cell.second + dy + if (px in 0 until UpgradeGrid.COLS && py in 0 until UpgradeGrid.ROWS) { + val cx = x + px * CELL_SIZE + val cy = y + py * CELL_SIZE + guiGraphics.fill(cx + 1, cy + 1, cx + CELL_SIZE - 1, cy + CELL_SIZE - 1, color) + } + } + } else if (cell != null && carried.isEmpty) { + val occupant = grid.occupied[cell.second][cell.first] + if (occupant != null) { + val placement = grid.placements.find { p -> + val s = p.type.shape.rotated(p.rotation) + s.cells.any { (dx, dy) -> p.x + dx == cell.first && p.y + dy == cell.second } + } + if (placement != null) { + val shape = placement.type.shape.rotated(placement.rotation) + val gold = 0x60FFD700.toInt() + for ((dx, dy) in shape.cells) { + val cx = x + (placement.x + dx) * CELL_SIZE + val cy = y + (placement.y + dy) * CELL_SIZE + guiGraphics.fill(cx + 1, cy + 1, cx + CELL_SIZE - 1, cy + CELL_SIZE - 1, gold) + } + } + } + } + } + + /** Renders a small shape preview beside the grid. */ + fun renderShapePreview(guiGraphics: GuiGraphics, previewX: Int, previewY: Int) { + val carried = menu.carried + if (carried.isEmpty || carried.item !is BeeUpgradeItem) return + + val type = (carried.item as BeeUpgradeItem).upgradeType + val rot = getRotationFor(type) + val shape = type.shape.rotated(rot) + val color = getUpgradeColor(type) + + guiGraphics.drawString( + Minecraft.getInstance().font, + Component.translatable("gui.cbbees.grid.shape_preview"), + previewX, previewY - 10, 0xFFFFFF, false + ) + + for ((dx, dy) in shape.cells) { + val cx = previewX + dx * PREVIEW_CELL + val cy = previewY + dy * PREVIEW_CELL + guiGraphics.fill(cx, cy, cx + PREVIEW_CELL - 1, cy + PREVIEW_CELL - 1, color) + } + + val hintY = previewY + shape.height() * PREVIEW_CELL + 4 + guiGraphics.drawString( + Minecraft.getInstance().font, + Component.translatable("gui.cbbees.grid.rotate_hint"), + previewX, hintY, 0xAAAAAA, false + ) + } + + /** Renders the base shape for an upgrade the mouse is hovering over in a slot. */ + fun renderSlotShapeHint(guiGraphics: GuiGraphics, type: UpgradeType, hintX: Int, hintY: Int) { + val color = getUpgradeColor(type) + for ((dx, dy) in type.shape.cells) { + val cx = hintX + dx * PREVIEW_CELL + val cy = hintY + dy * PREVIEW_CELL + guiGraphics.fill(cx, cy, cx + PREVIEW_CELL - 1, cy + PREVIEW_CELL - 1, color) + } + } + + // ── mouse interaction ── + + override fun mouseClicked(mouseX: Double, mouseY: Double, button: Int): Boolean { + val cell = cellAt(mouseX, mouseY) ?: return false + + if (button == 0) { + val carried = menu.carried + if (!carried.isEmpty && carried.item is BeeUpgradeItem) { + val type = (carried.item as BeeUpgradeItem).upgradeType + val rot = getRotationFor(type) + if (clientGrid.canPlace(type, cell.first, cell.second, rot)) { + clientGrid.place(type, cell.first, cell.second, rot) + carried.shrink(1) + PacketDistributor.sendToServer(GridPlaceUpgradePacket(cell.first, cell.second, rot)) + } + return true + } else if (carried.isEmpty && clientGrid.occupied[cell.second][cell.first] != null) { + val removed = clientGrid.removeAt(cell.first, cell.second) + if (removed != null) { + menu.setCarried(getItemForUpgrade(removed.type)) + PacketDistributor.sendToServer(GridRemoveUpgradePacket(cell.first, cell.second)) + } + return true + } + } + + if (button == 1 && !menu.carried.isEmpty && menu.carried.item is BeeUpgradeItem) { + currentRotation = (currentRotation + 1) % 4 + return true + } + + return false + } + + override fun mouseScrolled(mouseX: Double, mouseY: Double, scrollX: Double, scrollY: Double): Boolean { + val carried = menu.carried + if (!carried.isEmpty && carried.item is BeeUpgradeItem) { + currentRotation = if (scrollY > 0) (currentRotation + 1) % 4 else (currentRotation + 3) % 4 + return true + } + return false + } + + override fun updateWidgetNarration(narrationElementOutput: NarrationElementOutput) { + defaultButtonNarrationText(narrationElementOutput) + } + + // ── util ── + + private fun getItemForUpgrade(type: UpgradeType): ItemStack = when (type) { + UpgradeType.RAPID_WINGS -> ItemStack(AllItems.RAPID_WINGS.get()) + UpgradeType.SWARM_INTELLIGENCE -> ItemStack(AllItems.SWARM_INTELLIGENCE.get()) + UpgradeType.HONEY_EFFICIENCY -> ItemStack(AllItems.HONEY_EFFICIENCY.get()) + UpgradeType.SOFT_TOUCH -> ItemStack(AllItems.SOFT_TOUCH.get()) + UpgradeType.DROP_ITEMS -> ItemStack(AllItems.DROP_ITEMS.get()) + UpgradeType.HONEY_TANK -> ItemStack(AllItems.HONEY_TANK.get()) + UpgradeType.REINFORCED_PLATING -> ItemStack(AllItems.REINFORCED_PLATING.get()) + UpgradeType.DRONE_VIEW -> ItemStack(AllItems.DRONE_VIEW.get()) + UpgradeType.DRONE_RANGE -> ItemStack(AllItems.DRONE_RANGE.get()) + } + + private fun getUpgradeColor(type: UpgradeType): Int = when (type) { + UpgradeType.RAPID_WINGS -> 0xFFFF6600.toInt() + UpgradeType.SWARM_INTELLIGENCE -> 0xFF00AAFF.toInt() + UpgradeType.HONEY_EFFICIENCY -> 0xFFFFDD00.toInt() + UpgradeType.SOFT_TOUCH -> 0xFF00FF88.toInt() + UpgradeType.DROP_ITEMS -> 0xFFFF4444.toInt() + UpgradeType.HONEY_TANK -> 0xFFD97F00.toInt() + UpgradeType.REINFORCED_PLATING -> 0xFF8888AA.toInt() + UpgradeType.DRONE_VIEW -> 0xFF9933FF.toInt() + UpgradeType.DRONE_RANGE -> 0xFF00CCCC.toInt() + } +} diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/MechanicalBeeEntity.kt b/src/main/kotlin/de/devin/cbbees/content/bee/MechanicalBeeEntity.kt index 5ae611d..4f33fb6 100644 --- a/src/main/kotlin/de/devin/cbbees/content/bee/MechanicalBeeEntity.kt +++ b/src/main/kotlin/de/devin/cbbees/content/bee/MechanicalBeeEntity.kt @@ -69,8 +69,12 @@ class MechanicalBeeEntity(entityType: EntityType, level: Leve SynchedEntityData.defineId(MechanicalBeeEntity::class.java, EntityDataSerializers.OPTIONAL_BLOCK_POS) private val SPRING_TENSION: EntityDataAccessor = SynchedEntityData.defineId(MechanicalBeeEntity::class.java, EntityDataSerializers.FLOAT) + private val IS_DRONE: EntityDataAccessor = + SynchedEntityData.defineId(MechanicalBeeEntity::class.java, EntityDataSerializers.BOOLEAN) const val WORK_RANGE: Double = 2.5 + const val DRONE_ALTITUDE: Double = 25.0 + const val DRONE_MAX_SPEED: Double = 2.0 fun createAttributes(): AttributeSupplier.Builder { return createMobAttributes() @@ -100,6 +104,16 @@ class MechanicalBeeEntity(entityType: EntityType, level: Leve val workRange: Double = WORK_RANGE + var isDrone: Boolean + get() = entityData.get(IS_DRONE) + set(value) = entityData.set(IS_DRONE, value) + + /** Drone offset from owner position (server-side only) */ + var droneOffsetX: Double = 0.0 + var droneOffsetZ: Double = 0.0 + /** Max range the drone can fly from the owner */ + var droneMaxRange: Double = 32.0 + override var springTension: Float get() = entityData.get(SPRING_TENSION) set(value) = entityData.set(SPRING_TENSION, value.coerceIn(0.0f, 1.0f)) @@ -169,6 +183,8 @@ class MechanicalBeeEntity(entityType: EntityType, level: Leve } override fun customServerAiStep() { + if (isDrone) return + this.level().profiler.push("beeBrain") this.getBrain().tick(this.level() as ServerLevel, this) this.level().profiler.pop() @@ -201,6 +217,7 @@ class MechanicalBeeEntity(entityType: EntityType, level: Leve builder.define(BEEHIVE_ID, Optional.empty()) builder.define(TARGET_POS, Optional.empty()) builder.define(SPRING_TENSION, 1.0f) + builder.define(IS_DRONE, false) } override fun createNavigation(level: Level): PathNavigation = @@ -210,7 +227,7 @@ class MechanicalBeeEntity(entityType: EntityType, level: Leve MechanicalBeelike.travelFlying(this, travelVector) override fun remove(reason: RemovalReason) { - if (!level().isClientSide) { + if (!level().isClientSide && !isDrone) { network()?.releaseReservations(this.uuid) // Release current batch so it can be retried by another bee val batch = getBrain().getMemory(BeeMemoryModules.CURRENT_TASK.get()).orElse(null) @@ -233,6 +250,11 @@ class MechanicalBeeEntity(entityType: EntityType, level: Leve super.tick() if (level().isClientSide) return + if (isDrone) { + tickDrone() + return + } + syncTargetPos() if (rechargeFinishTick < 0) { BeeSeparation.applyFlightOffset(this) @@ -243,6 +265,47 @@ class MechanicalBeeEntity(entityType: EntityType, level: Leve } } + private fun tickDrone() { + val owner = getOwnerPlayer() ?: run { + discard() + return + } + + val targetX = owner.x + droneOffsetX + val targetZ = owner.z + droneOffsetZ + + // Follow terrain height below the drone rather than fixed offset from player + val groundY = level().getHeight( + net.minecraft.world.level.levelgen.Heightmap.Types.MOTION_BLOCKING, + net.minecraft.core.BlockPos.containing(targetX, 0.0, targetZ).x, + net.minecraft.core.BlockPos.containing(targetX, 0.0, targetZ).z + ).toDouble() + val targetY = (groundY + DRONE_ALTITUDE).coerceAtMost(level().maxBuildHeight.toDouble() - 1.0) + + // Snap directly to target position for stiff, responsive movement + setPos(targetX, targetY, targetZ) + setDeltaMovement(0.0, 0.0, 0.0) + xRot = 90f + setNoGravity(true) + } + + /** + * Applies a movement delta to the drone offset, clamped to the max range. + */ + fun applyDroneMovement(dx: Double, dz: Double) { + if (!isDrone) return + + droneOffsetX += dx + droneOffsetZ += dz + + // Clamp to max range circle + val dist = kotlin.math.sqrt(droneOffsetX * droneOffsetX + droneOffsetZ * droneOffsetZ) + if (dist > droneMaxRange) { + droneOffsetX = droneOffsetX / dist * droneMaxRange + droneOffsetZ = droneOffsetZ / dist * droneMaxRange + } + } + private fun syncTargetPos() { val brain = getBrain() val walkTarget = brain.getMemory(MemoryModuleType.WALK_TARGET).orElse(null) @@ -283,6 +346,10 @@ class MechanicalBeeEntity(entityType: EntityType, level: Leve compound.putUUID("NetworkId", networkId) compound.putFloat("SpringTension", springTension) compound.putLong("RechargeFinishTick", rechargeFinishTick) + compound.putBoolean("IsDrone", isDrone) + compound.putDouble("DroneOffsetX", droneOffsetX) + compound.putDouble("DroneOffsetZ", droneOffsetZ) + compound.putDouble("DroneMaxRange", droneMaxRange) val itemsTag = ListTag() for (i in 0 until inventory.containerSize) { @@ -313,6 +380,14 @@ class MechanicalBeeEntity(entityType: EntityType, level: Leve if (compound.contains("RechargeFinishTick")) { rechargeFinishTick = compound.getLong("RechargeFinishTick") } + if (compound.contains("IsDrone")) { + isDrone = compound.getBoolean("IsDrone") + } + if (compound.contains("DroneOffsetX")) { + droneOffsetX = compound.getDouble("DroneOffsetX") + droneOffsetZ = compound.getDouble("DroneOffsetZ") + droneMaxRange = compound.getDouble("DroneMaxRange") + } if (compound.contains("BeeInventory")) { val itemsTag = compound.getList("BeeInventory", 10) diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/MechanicalBeeRenderer.kt b/src/main/kotlin/de/devin/cbbees/content/bee/MechanicalBeeRenderer.kt index 938cdac..b1a8fc7 100644 --- a/src/main/kotlin/de/devin/cbbees/content/bee/MechanicalBeeRenderer.kt +++ b/src/main/kotlin/de/devin/cbbees/content/bee/MechanicalBeeRenderer.kt @@ -1,18 +1,34 @@ package de.devin.cbbees.content.bee +import com.mojang.blaze3d.vertex.PoseStack +import net.minecraft.client.Minecraft +import net.minecraft.client.renderer.MultiBufferSource import net.minecraft.client.renderer.entity.EntityRendererProvider import software.bernie.geckolib.renderer.GeoEntityRenderer /** * Renderer for the Mechanical Bee entity using GeckoLib. - * + * * Uses the custom model and flying animation. */ -class MechanicalBeeRenderer(context: EntityRendererProvider.Context) : +class MechanicalBeeRenderer(context: EntityRendererProvider.Context) : GeoEntityRenderer(context, MechanicalBeeModel()) { - + init { // Shadow radius for the robot this.shadowRadius = 0.3f } + + override fun render( + entity: MechanicalBeeEntity, + entityYaw: Float, + partialTick: Float, + poseStack: PoseStack, + bufferSource: MultiBufferSource, + packedLight: Int + ) { + // Drones are invisible to all players — they exist only as a camera anchor + if (entity.isDrone) return + super.render(entity, entityYaw, partialTick, poseStack, bufferSource, packedLight) + } } diff --git a/src/main/kotlin/de/devin/cbbees/content/drone/DroneViewManager.kt b/src/main/kotlin/de/devin/cbbees/content/drone/DroneViewManager.kt new file mode 100644 index 0000000..1c78285 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/drone/DroneViewManager.kt @@ -0,0 +1,157 @@ +package de.devin.cbbees.content.drone + +import de.devin.cbbees.content.backpack.PortableBeehiveItem +import de.devin.cbbees.content.bee.MechanicalBeeEntity +import de.devin.cbbees.content.upgrades.UpgradeType +import de.devin.cbbees.network.DroneViewSyncPacket +import de.devin.cbbees.registry.AllEntityTypes +import de.devin.cbbees.util.ServerSide +import de.devin.cbbees.content.upgrades.BeeContext +import net.minecraft.network.chat.Component +import net.minecraft.server.level.ServerPlayer +import net.minecraft.world.item.ItemStack +import net.neoforged.neoforge.network.PacketDistributor +import top.theillusivec4.curios.api.CuriosApi +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +@ServerSide +object DroneViewManager { + + /** playerUUID -> droneEntityUUID */ + private val activeDrones = ConcurrentHashMap() + + fun toggleDrone(player: ServerPlayer) { + if (activeDrones.containsKey(player.uuid)) { + deactivateDrone(player) + } else { + activateDrone(player) + } + } + + private fun activateDrone(player: ServerPlayer) { + val backpack = findBackpack(player) + if (backpack == null) { + player.displayClientMessage(Component.translatable("cbbees.drone_view.no_backpack"), true) + return + } + + val beehiveItem = backpack.item as PortableBeehiveItem + + // Check upgrade + if (beehiveItem.getUpgradeCount(backpack, UpgradeType.DRONE_VIEW) <= 0) { + player.displayClientMessage(Component.translatable("cbbees.drone_view.no_upgrade"), true) + return + } + + // Check bees + if (beehiveItem.getTotalRobotCount(backpack) <= 0) { + player.displayClientMessage(Component.translatable("cbbees.drone_view.no_bees"), true) + return + } + + // Consume bee + beehiveItem.consumeBee(backpack) + + // Calculate max range from upgrades + val beeContext = UpgradeType.fromBackpack(backpack) + + // Spawn drone entity + val level = player.serverLevel() + val drone = MechanicalBeeEntity(AllEntityTypes.MECHANICAL_BEE.get(), level) + val targetY = (player.y + MechanicalBeeEntity.DRONE_ALTITUDE).coerceAtMost(level.maxBuildHeight.toDouble() - 1.0) + drone.moveTo(player.x, targetY, player.z, 0f, 90f) + drone.setOwner(player.uuid) + drone.isDrone = true + drone.droneMaxRange = beeContext.droneRange + drone.setNoGravity(true) + level.addFreshEntity(drone) + + activeDrones[player.uuid] = drone.uuid + + // Sync to client (include max range so HUD can display it) + PacketDistributor.sendToPlayer(player, DroneViewSyncPacket(drone.id, beeContext.droneRange.toFloat())) + + player.displayClientMessage(Component.translatable("cbbees.drone_view.activated"), true) + } + + private fun deactivateDrone(player: ServerPlayer) { + val droneUUID = activeDrones.remove(player.uuid) ?: return + + val level = player.serverLevel() + val entity = level.getEntity(droneUUID) + + if (entity is MechanicalBeeEntity) { + entity.discard() + } + + // Return bee to backpack + val backpack = findBackpack(player) + if (backpack != null) { + val beehiveItem = backpack.item as PortableBeehiveItem + beehiveItem.addRobot(backpack, ItemStack(de.devin.cbbees.items.AllItems.MECHANICAL_BEE.get(), 1)) + } + + // Sync to client + PacketDistributor.sendToPlayer(player, DroneViewSyncPacket(-1, 0f)) + + player.displayClientMessage(Component.translatable("cbbees.drone_view.deactivated"), true) + } + + fun despawnDrone(player: ServerPlayer) { + val droneUUID = activeDrones.remove(player.uuid) ?: return + + val level = player.serverLevel() + val entity = level.getEntity(droneUUID) + + if (entity is MechanicalBeeEntity) { + entity.discard() + } + + // Return bee to backpack + val backpack = findBackpack(player) + if (backpack != null) { + val beehiveItem = backpack.item as PortableBeehiveItem + beehiveItem.addRobot(backpack, ItemStack(de.devin.cbbees.items.AllItems.MECHANICAL_BEE.get(), 1)) + } + } + + fun isActive(player: ServerPlayer): Boolean = activeDrones.containsKey(player.uuid) + + fun getDroneUUID(player: ServerPlayer): UUID? = activeDrones[player.uuid] + + fun findBackpack(player: ServerPlayer): ItemStack? { + // Check Curios back slot + val curios = CuriosApi.getCuriosHelper().findFirstCurio(player) { it.item is PortableBeehiveItem } + if (curios.isPresent) return curios.get().stack() + + // Check chestplate armor slot + val armorStack = player.inventory.armor[2] + if (armorStack.item is PortableBeehiveItem) return armorStack + + return null + } + + fun clear() { + activeDrones.clear() + } + + /** + * Called periodically to validate active drones (e.g., backpack still equipped). + */ + fun validateDrones() { + val toRemove = mutableListOf() + for ((playerId, _) in activeDrones) { + val server = net.neoforged.neoforge.server.ServerLifecycleHooks.getCurrentServer() ?: continue + val player = server.playerList.getPlayer(playerId) + if (player == null) { + toRemove.add(playerId) + continue + } + if (findBackpack(player) == null) { + despawnDrone(player) + } + } + toRemove.forEach { activeDrones.remove(it) } + } +} diff --git a/src/main/kotlin/de/devin/cbbees/content/drone/client/DroneViewClientEvents.kt b/src/main/kotlin/de/devin/cbbees/content/drone/client/DroneViewClientEvents.kt new file mode 100644 index 0000000..c2dc140 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/drone/client/DroneViewClientEvents.kt @@ -0,0 +1,204 @@ +package de.devin.cbbees.content.drone.client + +import com.simibubi.create.CreateClient +import de.devin.cbbees.config.CBBeesConfig +import de.devin.cbbees.content.schematics.ConstructionPlannerItem +import de.devin.cbbees.content.schematics.client.ConstructionPlannerHandler +import de.devin.cbbees.network.MoveDronePacket +import de.devin.cbbees.network.ToggleDroneViewPacket +import de.devin.cbbees.registry.AllKeys +import de.devin.cbbees.util.ClientSide +import net.minecraft.client.Minecraft +import net.minecraft.core.BlockPos +import net.neoforged.bus.api.EventPriority +import net.neoforged.bus.api.SubscribeEvent +import net.neoforged.neoforge.client.event.CalculatePlayerTurnEvent +import net.neoforged.neoforge.client.event.ClientPlayerNetworkEvent +import net.neoforged.neoforge.client.event.InputEvent +import net.neoforged.neoforge.client.event.MovementInputUpdateEvent +import net.neoforged.neoforge.client.event.RenderHandEvent +import net.neoforged.neoforge.client.event.ViewportEvent +import net.neoforged.neoforge.client.event.ClientTickEvent +import net.neoforged.neoforge.network.PacketDistributor +import org.lwjgl.glfw.GLFW + +@ClientSide +object DroneViewClientEvents { + + private var lastDroneBlockPos: BlockPos? = null + + @SubscribeEvent + @JvmStatic + fun onClientTick(event: ClientTickEvent.Post) { + // Consume key press for toggle + while (AllKeys.DRONE_VIEW.consumeClick()) { + PacketDistributor.sendToServer(ToggleDroneViewPacket()) + } + + DroneViewClientState.tick() + + // Send drone movement input + move schematic with drone + if (DroneViewClientState.active) { + sendDroneMovement() + updateSchematicPosition() + } else { + lastDroneBlockPos = null + } + } + + /** + * Moves the deployed schematic to follow the drone's XZ position. + */ + private fun updateSchematicPosition() { + val mc = Minecraft.getInstance() + val drone = mc.level?.getEntity(DroneViewClientState.droneEntityId) ?: return + val currentPos = drone.blockPosition() + + val last = lastDroneBlockPos + lastDroneBlockPos = currentPos + + if (last == null || last == currentPos) return + + val dx = currentPos.x - last.x + val dz = currentPos.z - last.z + if (dx == 0 && dz == 0) return + + val handler = CreateClient.SCHEMATIC_HANDLER + if (handler.isDeployed) { + handler.transformation.move(dx, 0, dz) + handler.markDirty() + } + } + + private fun sendDroneMovement() { + val mc = Minecraft.getInstance() + val opts = mc.options + + var dx = 0f + var dz = 0f + + // Fixed orientation: yaw=180 means north (-Z) is "up" on screen + // W = north (-Z), S = south (+Z), A = west (-X), D = east (+X) + if (opts.keyUp.isDown) dz -= 1f + if (opts.keyDown.isDown) dz += 1f + if (opts.keyLeft.isDown) dx -= 1f + if (opts.keyRight.isDown) dx += 1f + + if (dx == 0f && dz == 0f) return + + // Normalize diagonal movement + val len = kotlin.math.sqrt((dx * dx + dz * dz).toDouble()).toFloat() + val speed = CBBeesConfig.droneMoveSpeed.get().toFloat() + dx = dx / len * speed + dz = dz / len * speed + + PacketDistributor.sendToServer(MoveDronePacket(dx, dz)) + } + + /** + * Suppress player movement when drone view is active. + * The player stays in place; WASD controls the drone instead. + */ + @SubscribeEvent + @JvmStatic + fun onMovementInput(event: MovementInputUpdateEvent) { + if (DroneViewClientState.active) { + val input = event.input + input.forwardImpulse = 0f + input.leftImpulse = 0f + input.jumping = false + input.shiftKeyDown = false + } + } + + @SubscribeEvent + @JvmStatic + fun onRenderHand(event: RenderHandEvent) { + if (DroneViewClientState.active) { + event.isCanceled = true + } + } + + /** + * Intercept right-click during drone view to route to the Construction Planner + * and Create's SchematicHandler, since the player may not be holding the planner. + */ + @SubscribeEvent + @JvmStatic + fun onMouseButton(event: InputEvent.MouseButton.Pre) { + if (!DroneViewClientState.active) return + if (event.button != GLFW.GLFW_MOUSE_BUTTON_RIGHT || event.action != GLFW.GLFW_PRESS) return + + val mc = Minecraft.getInstance() + if (mc.screen != null) return + val player = mc.player ?: return + val planner = DroneViewClientState.findActivePlanner(player) + if (planner.isEmpty) return + + val handler = CreateClient.SCHEMATIC_HANDLER + if (handler.isActive && handler.isDeployed) { + // Deployed state: route to Create's tool handler + handler.onMouseInput(GLFW.GLFW_MOUSE_BUTTON_RIGHT, true) + } else if (!ConstructionPlannerItem.hasSchematic(planner)) { + // Browsing state: enter group or select schematic + if (player.isShiftKeyDown) { + ConstructionPlannerHandler.instantConstruct() + } else { + ConstructionPlannerHandler.confirmSelection() + } + } + event.isCanceled = true + } + + /** + * Block all vanilla interactions (attack, use, pick block) during drone view. + * We handle right-click ourselves via onMouseButton above. + */ + @SubscribeEvent + @JvmStatic + fun onInteraction(event: InputEvent.InteractionKeyMappingTriggered) { + if (DroneViewClientState.active) { + event.isCanceled = true + event.setSwingHand(false) + } + } + + /** + * Freeze player head rotation by zeroing mouse sensitivity during drone view. + */ + @SubscribeEvent + @JvmStatic + fun onPlayerTurn(event: CalculatePlayerTurnEvent) { + if (DroneViewClientState.active) { + event.mouseSensitivity = 0.0 + } + } + + /** + * Block hotbar scrolling during drone view. + * Low priority so Create's tool scroll and our planner scroll run first. + */ + @SubscribeEvent(priority = EventPriority.LOW) + @JvmStatic + fun onMouseScroll(event: InputEvent.MouseScrollingEvent) { + if (DroneViewClientState.active) { + event.isCanceled = true + } + } + + @SubscribeEvent + @JvmStatic + fun onCameraAngles(event: ViewportEvent.ComputeCameraAngles) { + if (DroneViewClientState.active) { + event.pitch = 90f + event.yaw = 180f // North = up on screen + } + } + + @SubscribeEvent + @JvmStatic + fun onLogout(event: ClientPlayerNetworkEvent.LoggingOut) { + DroneViewClientState.reset() + lastDroneBlockPos = null + } +} diff --git a/src/main/kotlin/de/devin/cbbees/content/drone/client/DroneViewClientState.kt b/src/main/kotlin/de/devin/cbbees/content/drone/client/DroneViewClientState.kt new file mode 100644 index 0000000..476662a --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/drone/client/DroneViewClientState.kt @@ -0,0 +1,106 @@ +package de.devin.cbbees.content.drone.client + +import de.devin.cbbees.items.AllItems +import de.devin.cbbees.util.ClientSide +import net.minecraft.client.Minecraft +import net.minecraft.world.entity.player.Player +import net.minecraft.world.item.ItemStack + +@ClientSide +object DroneViewClientState { + + var active: Boolean = false + private set + var droneEntityId: Int = -1 + private set + var maxRange: Float = 0f + private set + private var pendingEntityId: Int = -1 + private var pendingMaxRange: Float = 0f + private var pendingRetries: Int = 0 + + fun activate(entityId: Int, range: Float) { + val mc = Minecraft.getInstance() + val level = mc.level ?: return + + val entity = level.getEntity(entityId) + if (entity != null) { + active = true + droneEntityId = entityId + maxRange = range + pendingEntityId = -1 + mc.setCameraEntity(entity) + } else { + // Entity not yet synced — retry in tick + pendingEntityId = entityId + pendingMaxRange = range + pendingRetries = 0 + } + } + + fun deactivate() { + val mc = Minecraft.getInstance() + active = false + droneEntityId = -1 + maxRange = 0f + pendingEntityId = -1 + pendingRetries = 0 + mc.player?.let { mc.setCameraEntity(it) } + } + + fun tick() { + val mc = Minecraft.getInstance() + + // Retry pending entity lookup + if (pendingEntityId != -1) { + pendingRetries++ + val entity = mc.level?.getEntity(pendingEntityId) + if (entity != null) { + active = true + droneEntityId = pendingEntityId + maxRange = pendingMaxRange + pendingEntityId = -1 + mc.setCameraEntity(entity) + } else if (pendingRetries > 100) { + // Give up after ~5 seconds + pendingEntityId = -1 + pendingRetries = 0 + } + return + } + + // Validate drone is still alive + if (active) { + val drone = mc.level?.getEntity(droneEntityId) + if (drone == null || !drone.isAlive) { + deactivate() + } + } + } + + fun reset() { + active = false + droneEntityId = -1 + maxRange = 0f + pendingEntityId = -1 + pendingRetries = 0 + } + + /** + * Finds the Construction Planner: main hand first, then any inventory slot + * if drone view is active. Returns [ItemStack.EMPTY] if not found. + */ + @JvmStatic + fun findActivePlanner(player: Player): ItemStack { + val mainHand = player.mainHandItem + if (AllItems.CONSTRUCTION_PLANNER.isIn(mainHand)) return mainHand + if (active) { + val inv = player.inventory + for (i in 0 until inv.containerSize) { + val stack = inv.getItem(i) + if (AllItems.CONSTRUCTION_PLANNER.isIn(stack)) return stack + } + } + return ItemStack.EMPTY + } +} diff --git a/src/main/kotlin/de/devin/cbbees/content/drone/client/DroneViewHUD.kt b/src/main/kotlin/de/devin/cbbees/content/drone/client/DroneViewHUD.kt new file mode 100644 index 0000000..9ea92b6 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/drone/client/DroneViewHUD.kt @@ -0,0 +1,65 @@ +package de.devin.cbbees.content.drone.client + +import de.devin.cbbees.content.bee.MechanicalBeeEntity +import de.devin.cbbees.util.ClientSide +import net.minecraft.client.DeltaTracker +import net.minecraft.client.Minecraft +import net.minecraft.client.gui.GuiGraphics +import net.minecraft.network.chat.Component + +@ClientSide +object DroneViewHUD { + + fun renderHUD(guiGraphics: GuiGraphics, deltaTracker: DeltaTracker) { + if (!DroneViewClientState.active) return + + val mc = Minecraft.getInstance() + val font = mc.font + val screenWidth = guiGraphics.guiWidth() + + // Title bar + val title = Component.translatable("cbbees.drone_view.hud") + val titleWidth = font.width(title) + val titleX = (screenWidth - titleWidth) / 2 + val titleY = 5 + + guiGraphics.fill(titleX - 4, titleY - 2, titleX + titleWidth + 4, titleY + font.lineHeight + 2, 0x80000000.toInt()) + guiGraphics.drawString(font, title, titleX, titleY, 0xFF9933FF.toInt(), true) + + // Range indicator + val player = mc.player ?: return + val drone = mc.level?.getEntity(DroneViewClientState.droneEntityId) as? MechanicalBeeEntity ?: return + + val dx = drone.x - player.x + val dz = drone.z - player.z + val dist = kotlin.math.sqrt(dx * dx + dz * dz) + val maxRange = DroneViewClientState.maxRange + + val rangeText = Component.translatable( + "cbbees.drone_view.hud.range", + String.format("%.0f", dist), + String.format("%.0f", maxRange) + ) + val rangeWidth = font.width(rangeText) + val rangeX = (screenWidth - rangeWidth) / 2 + val rangeY = titleY + font.lineHeight + 4 + + // Color shifts from green to red as range fills + val ratio = (dist / maxRange).coerceIn(0.0, 1.0) + val rangeColor = if (ratio > 0.85) 0xFFFF4444.toInt() + else if (ratio > 0.6) 0xFFFFDD00.toInt() + else 0xFF00FF88.toInt() + + guiGraphics.fill(rangeX - 4, rangeY - 2, rangeX + rangeWidth + 4, rangeY + font.lineHeight + 2, 0x80000000.toInt()) + guiGraphics.drawString(font, rangeText, rangeX, rangeY, rangeColor, true) + + // Movement hint + val hintText = Component.translatable("cbbees.drone_view.hud.controls") + val hintWidth = font.width(hintText) + val hintX = (screenWidth - hintWidth) / 2 + val hintY = rangeY + font.lineHeight + 4 + + guiGraphics.fill(hintX - 4, hintY - 2, hintX + hintWidth + 4, hintY + font.lineHeight + 2, 0x80000000.toInt()) + guiGraphics.drawString(font, hintText, hintX, hintY, 0xFFAAAAAA.toInt(), true) + } +} diff --git a/src/main/kotlin/de/devin/cbbees/content/schematics/client/ConstructionPlannerClientEvents.kt b/src/main/kotlin/de/devin/cbbees/content/schematics/client/ConstructionPlannerClientEvents.kt index b776fdf..a516d6a 100644 --- a/src/main/kotlin/de/devin/cbbees/content/schematics/client/ConstructionPlannerClientEvents.kt +++ b/src/main/kotlin/de/devin/cbbees/content/schematics/client/ConstructionPlannerClientEvents.kt @@ -1,5 +1,6 @@ package de.devin.cbbees.content.schematics.client +import de.devin.cbbees.content.drone.client.DroneViewClientState import de.devin.cbbees.items.AllItems import de.devin.cbbees.registry.AllKeys import net.minecraft.client.Minecraft @@ -30,7 +31,7 @@ object ConstructionPlannerClientEvents { // Clear custom tool state if player is no longer holding the planner if (ConstructionToolState.activeTool != ConstructionToolState.CustomTool.NONE) { val player = Minecraft.getInstance().player - if (player == null || !AllItems.CONSTRUCTION_PLANNER.isIn(player.mainHandItem)) { + if (player == null || DroneViewClientState.findActivePlanner(player).isEmpty) { ConstructionToolState.activeTool = ConstructionToolState.CustomTool.NONE } } @@ -39,7 +40,7 @@ object ConstructionPlannerClientEvents { // when the player switches to holding the planner val player = Minecraft.getInstance().player if (AllKeys.OPEN_SCHEMATIC_BROWSER.consumeClick()) { - if (player != null && AllItems.CONSTRUCTION_PLANNER.isIn(player.mainHandItem)) { + if (player != null && !DroneViewClientState.findActivePlanner(player).isEmpty) { Minecraft.getInstance().setScreen(ConstructionPlannerScreen()) } } diff --git a/src/main/kotlin/de/devin/cbbees/content/schematics/client/ConstructionPlannerHandler.kt b/src/main/kotlin/de/devin/cbbees/content/schematics/client/ConstructionPlannerHandler.kt index 293e6a6..ca7a5cc 100644 --- a/src/main/kotlin/de/devin/cbbees/content/schematics/client/ConstructionPlannerHandler.kt +++ b/src/main/kotlin/de/devin/cbbees/content/schematics/client/ConstructionPlannerHandler.kt @@ -3,6 +3,7 @@ package de.devin.cbbees.content.schematics.client import com.simibubi.create.AllDataComponents import com.simibubi.create.CreateClient import com.simibubi.create.content.schematics.SchematicItem +import de.devin.cbbees.content.drone.client.DroneViewClientState import de.devin.cbbees.content.schematics.ConstructionPlannerItem import com.simibubi.create.foundation.utility.RaycastHelper import de.devin.cbbees.items.AllItems @@ -85,8 +86,8 @@ object ConstructionPlannerHandler { */ fun isActive(): Boolean { val player = Minecraft.getInstance().player ?: return false - val stack = player.mainHandItem - if (!AllItems.CONSTRUCTION_PLANNER.isIn(stack)) return false + val stack = DroneViewClientState.findActivePlanner(player) + if (stack.isEmpty) return false // State 3: deployed → Create owns it, our HUD is inactive if (stack.getOrDefault(AllDataComponents.SCHEMATIC_DEPLOYED, false)) return false return true @@ -94,13 +95,13 @@ object ConstructionPlannerHandler { fun tick() { val player = Minecraft.getInstance().player - val stack = player?.mainHandItem // Pump upload chunks SchematicUploader.tick() // Not holding a planner — pause preview rendering but keep internal state - if (stack == null || !AllItems.CONSTRUCTION_PLANNER.isIn(stack)) { + val stack = if (player != null) DroneViewClientState.findActivePlanner(player) else null + if (stack == null || stack.isEmpty) { createWasActive = false if (isBrowsingPreview) { isBrowsingPreview = false @@ -315,8 +316,8 @@ object ConstructionPlannerHandler { private fun deploySchematic(filename: String): Boolean { val mc = Minecraft.getInstance() val player = mc.player ?: return false - val stack = player.mainHandItem - if (!AllItems.CONSTRUCTION_PLANNER.isIn(stack)) return false + val stack = DroneViewClientState.findActivePlanner(player) + if (stack.isEmpty) return false if (SchematicUploader.isUploading()) return false @@ -342,8 +343,8 @@ object ConstructionPlannerHandler { // Closure that finishes deployment — called immediately or after upload completes val finishDeploy = { - val currentStack = player.mainHandItem - if (AllItems.CONSTRUCTION_PLANNER.isIn(currentStack)) { + val currentStack = DroneViewClientState.findActivePlanner(player) + if (!currentStack.isEmpty) { // Set all data components on the client item — Create will pick this up currentStack.set(AllDataComponents.SCHEMATIC_FILE, filename) currentStack.set(AllDataComponents.SCHEMATIC_OWNER, player.gameProfile.name) diff --git a/src/main/kotlin/de/devin/cbbees/content/schematics/client/SchematicHoverPreview.kt b/src/main/kotlin/de/devin/cbbees/content/schematics/client/SchematicHoverPreview.kt index 1d05594..2f061eb 100644 --- a/src/main/kotlin/de/devin/cbbees/content/schematics/client/SchematicHoverPreview.kt +++ b/src/main/kotlin/de/devin/cbbees/content/schematics/client/SchematicHoverPreview.kt @@ -6,6 +6,7 @@ import com.simibubi.create.AllDataComponents import de.devin.cbbees.config.CBBeesClientConfig import com.simibubi.create.content.schematics.SchematicItem import com.simibubi.create.foundation.utility.RaycastHelper +import de.devin.cbbees.content.drone.client.DroneViewClientState import dev.engine_room.flywheel.lib.transform.TransformStack import net.createmod.catnip.impl.client.render.ColoringVertexConsumer import net.createmod.catnip.levelWrappers.SchematicLevel @@ -22,6 +23,7 @@ import net.minecraft.world.level.block.Mirror import net.minecraft.world.level.block.Rotation import net.minecraft.world.level.levelgen.structure.templatesystem.StructurePlaceSettings import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplate +import net.minecraft.world.level.ClipContext import net.minecraft.world.phys.AABB import net.minecraft.world.phys.HitResult import net.minecraft.world.phys.Vec3 @@ -170,7 +172,19 @@ object SchematicHoverPreview { val mc = Minecraft.getInstance() val player = mc.player ?: run { anchorPos = null; return } - val hitResult = RaycastHelper.rayTraceRange(player.level(), player, 75.0) + val hitResult = if (DroneViewClientState.active) { + // During drone view, raycast straight down from the drone + val drone = mc.level?.getEntity(DroneViewClientState.droneEntityId) + if (drone != null) { + val origin = drone.position() + val target = origin.add(0.0, -75.0, 0.0) + val ctx = ClipContext(origin, target, ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, player) + player.level().clip(ctx) + } else null + } else { + RaycastHelper.rayTraceRange(player.level(), player, 75.0) + } + if (hitResult == null || hitResult.type != HitResult.Type.BLOCK) { anchorPos = null return diff --git a/src/main/kotlin/de/devin/cbbees/content/upgrades/BeeContext.kt b/src/main/kotlin/de/devin/cbbees/content/upgrades/BeeContext.kt index da8a1ac..fac9d0f 100644 --- a/src/main/kotlin/de/devin/cbbees/content/upgrades/BeeContext.kt +++ b/src/main/kotlin/de/devin/cbbees/content/upgrades/BeeContext.kt @@ -18,5 +18,11 @@ data class BeeContext( var maxContributedBees: Int = CBBeesConfig.defaultMaxActiveRobots.get(), var fuelConsumptionMultiplier: Double = 1.0, /** Higher RPM → tighter wound spring → less drain per action */ - var springEfficiency: Double = 1.0 + var springEfficiency: Double = 1.0, + /** Bonus honey capacity from upgrades */ + var honeyCapacityBonus: Int = 0, + /** Whether the drone view ability is available */ + var droneViewAvailable: Boolean = false, + /** Maximum range the drone can fly from the player */ + var droneRange: Double = CBBeesConfig.droneBaseRange.get() ) diff --git a/src/main/kotlin/de/devin/cbbees/content/upgrades/BeeUpgradeItem.kt b/src/main/kotlin/de/devin/cbbees/content/upgrades/BeeUpgradeItem.kt index 5e3b0d6..5de9c29 100644 --- a/src/main/kotlin/de/devin/cbbees/content/upgrades/BeeUpgradeItem.kt +++ b/src/main/kotlin/de/devin/cbbees/content/upgrades/BeeUpgradeItem.kt @@ -10,29 +10,43 @@ import net.minecraft.world.item.ItemStack enum class UpgradeType( val maxStackInBackpack: Int, val descriptionKey: String, + val shape: UpgradeShape, val logic: IUpgrade ) { - RAPID_WINGS(4, "tooltip.cbbees.upgrade.rapid_wings", IUpgrade { ctx, count -> + RAPID_WINGS(4, "tooltip.cbbees.upgrade.rapid_wings", UpgradeShape.L_SHAPE, IUpgrade { ctx, count -> ctx.speedMultiplier += count * CBBeesConfig.rapidWingsSpeedBonus.get() }), - SWARM_INTELLIGENCE(3, "tooltip.cbbees.upgrade.swarm_intelligence", IUpgrade { ctx, count -> + SWARM_INTELLIGENCE(3, "tooltip.cbbees.upgrade.swarm_intelligence", UpgradeShape.T_SHAPE, IUpgrade { ctx, count -> ctx.maxActiveRobots += count * CBBeesConfig.swarmIntelligenceBeeBonus.get() }), - HONEY_EFFICIENCY(2, "tooltip.cbbees.upgrade.honey_efficiency", IUpgrade { ctx, count -> + HONEY_EFFICIENCY(2, "tooltip.cbbees.upgrade.honey_efficiency", UpgradeShape.BAR_2, IUpgrade { ctx, count -> ctx.breakSpeedMultiplier -= count * CBBeesConfig.honeyEfficiencyBreakSpeedReduction.get() ctx.carryCapacity += count * CBBeesConfig.honeyEfficiencyCarryBonus.get() ctx.fuelConsumptionMultiplier -= count * CBBeesConfig.honeyEfficiencyFuelReduction.get() }), - SOFT_TOUCH(1, "tooltip.cbbees.upgrade.soft_touch", IUpgrade { ctx, count -> + SOFT_TOUCH(1, "tooltip.cbbees.upgrade.soft_touch", UpgradeShape.SINGLE, IUpgrade { ctx, count -> if (count > 0) ctx.silkTouchEnabled = true }), - DROP_ITEMS(1, "tooltip.cbbees.upgrade.drop_items", IUpgrade { ctx, count -> + DROP_ITEMS(1, "tooltip.cbbees.upgrade.drop_items", UpgradeShape.SINGLE, IUpgrade { ctx, count -> if (count > 0) ctx.dropItemsEnabled = true + }), + HONEY_TANK(2, "tooltip.cbbees.upgrade.honey_tank", UpgradeShape.SQUARE_2X2, IUpgrade { ctx, count -> + ctx.honeyCapacityBonus += count * CBBeesConfig.honeyTankCapacityBonus.get() + }), + REINFORCED_PLATING(2, "tooltip.cbbees.upgrade.reinforced_plating", UpgradeShape.S_SHAPE, IUpgrade { ctx, count -> + ctx.springEfficiency += count * CBBeesConfig.reinforcedPlatingSpringBonus.get() + }), + DRONE_VIEW(1, "tooltip.cbbees.upgrade.drone_view", UpgradeShape.BAR_2, IUpgrade { ctx, count -> + if (count > 0) ctx.droneViewAvailable = true + }), + DRONE_RANGE(3, "tooltip.cbbees.upgrade.drone_range", UpgradeShape.L_SHAPE, IUpgrade { ctx, count -> + ctx.droneRange += count * CBBeesConfig.droneRangeBonus.get() }); companion object { /** * Creates a [BeeContext] based on the upgrades found in the given backpack stack. + * Reads from the UPGRADE_GRID data component. */ fun fromBackpack(stack: ItemStack): BeeContext { val context = BeeContext() @@ -74,3 +88,11 @@ class HoneyEfficiencyUpgrade(properties: Properties) : BeeUpgradeItem(UpgradeTyp class SoftTouchUpgrade(properties: Properties) : BeeUpgradeItem(UpgradeType.SOFT_TOUCH, properties) class DropItemsUpgrade(properties: Properties) : BeeUpgradeItem(UpgradeType.DROP_ITEMS, properties) + +class HoneyTankUpgrade(properties: Properties) : BeeUpgradeItem(UpgradeType.HONEY_TANK, properties) + +class ReinforcedPlatingUpgrade(properties: Properties) : BeeUpgradeItem(UpgradeType.REINFORCED_PLATING, properties) + +class DroneViewUpgrade(properties: Properties) : BeeUpgradeItem(UpgradeType.DRONE_VIEW, properties) + +class DroneRangeUpgrade(properties: Properties) : BeeUpgradeItem(UpgradeType.DRONE_RANGE, properties) diff --git a/src/main/kotlin/de/devin/cbbees/content/upgrades/UpgradeGrid.kt b/src/main/kotlin/de/devin/cbbees/content/upgrades/UpgradeGrid.kt new file mode 100644 index 0000000..8a1d9cd --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/upgrades/UpgradeGrid.kt @@ -0,0 +1,182 @@ +package de.devin.cbbees.content.upgrades + +import com.mojang.serialization.Codec +import com.mojang.serialization.codecs.RecordCodecBuilder +import io.netty.buffer.ByteBuf +import net.minecraft.network.RegistryFriendlyByteBuf +import net.minecraft.network.codec.StreamCodec + +/** + * A single upgrade placed on the grid at a specific position and rotation. + */ +data class UpgradePlacement( + val type: UpgradeType, + val x: Int, + val y: Int, + val rotation: Int +) { + companion object { + val CODEC: Codec = RecordCodecBuilder.create { builder -> + builder.group( + Codec.STRING.fieldOf("type").forGetter { it.type.name }, + Codec.INT.fieldOf("x").forGetter { it.x }, + Codec.INT.fieldOf("y").forGetter { it.y }, + Codec.INT.fieldOf("rotation").forGetter { it.rotation } + ).apply(builder) { typeName, x, y, rotation -> + UpgradePlacement(UpgradeType.valueOf(typeName), x, y, rotation) + } + } + + val STREAM_CODEC: StreamCodec = StreamCodec.of( + { buf, p -> + buf.writeInt(p.type.ordinal) + buf.writeInt(p.x) + buf.writeInt(p.y) + buf.writeInt(p.rotation) + }, + { buf -> + UpgradePlacement( + UpgradeType.entries[buf.readInt()], + buf.readInt(), + buf.readInt(), + buf.readInt() + ) + } + ) + } +} + +/** + * 5x4 grid that holds shaped upgrade placements. + */ +class UpgradeGrid { + + val placements: MutableList = mutableListOf() + + @Transient + var occupied: Array> = Array(ROWS) { arrayOfNulls(COLS) } + private set + + fun canPlace(type: UpgradeType, x: Int, y: Int, rotation: Int): Boolean { + val shape = type.shape.rotated(rotation) + for ((dx, dy) in shape.cells) { + val cx = x + dx + val cy = y + dy + if (cx < 0 || cx >= COLS || cy < 0 || cy >= ROWS) return false + if (occupied[cy][cx] != null) return false + } + // Check max count for this upgrade type + val currentCount = placements.count { it.type == type } + if (currentCount >= type.maxStackInBackpack) return false + return true + } + + fun place(type: UpgradeType, x: Int, y: Int, rotation: Int): Boolean { + if (!canPlace(type, x, y, rotation)) return false + val placement = UpgradePlacement(type, x, y, rotation) + placements.add(placement) + val shape = type.shape.rotated(rotation) + for ((dx, dy) in shape.cells) { + occupied[y + dy][x + dx] = type + } + return true + } + + /** + * Removes the placement covering cell (x, y). + * @return the removed placement, or null if cell was empty + */ + fun removeAt(x: Int, y: Int): UpgradePlacement? { + if (x < 0 || x >= COLS || y < 0 || y >= ROWS) return null + val occupant = occupied[y][x] ?: return null + + // Find the placement that covers this cell + val placement = placements.find { p -> + val shape = p.type.shape.rotated(p.rotation) + shape.cells.any { (dx, dy) -> p.x + dx == x && p.y + dy == y } + } ?: return null + + placements.remove(placement) + // Clear occupied cells for this placement + val shape = placement.type.shape.rotated(placement.rotation) + for ((dx, dy) in shape.cells) { + occupied[placement.y + dy][placement.x + dx] = null + } + return placement + } + + fun getUpgradeCounts(): Map { + val counts = mutableMapOf() + for (p in placements) { + counts[p.type] = (counts[p.type] ?: 0) + 1 + } + return counts + } + + /** + * Recomputes [occupied] from [placements]. Call after deserialization. + */ + fun rebuild() { + occupied = Array(ROWS) { arrayOfNulls(COLS) } + for (p in placements) { + val shape = p.type.shape.rotated(p.rotation) + for ((dx, dy) in shape.cells) { + val cx = p.x + dx + val cy = p.y + dy + if (cx in 0 until COLS && cy in 0 until ROWS) { + occupied[cy][cx] = p.type + } + } + } + } + + fun copy(): UpgradeGrid { + val grid = UpgradeGrid() + for (p in placements) { + grid.placements.add(p.copy()) + } + grid.rebuild() + return grid + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is UpgradeGrid) return false + return placements == other.placements + } + + override fun hashCode(): Int = placements.hashCode() + + companion object { + const val COLS = 6 + const val ROWS = 5 + + val CODEC: Codec = UpgradePlacement.CODEC.listOf().xmap( + { list -> + val grid = UpgradeGrid() + list.forEach { grid.placements.add(it) } + grid.rebuild() + grid + }, + { grid -> grid.placements.toList() } + ) + + val STREAM_CODEC: StreamCodec = StreamCodec.of( + { buf, grid -> + buf.writeInt(grid.placements.size) + for (p in grid.placements) { + UpgradePlacement.STREAM_CODEC.encode(buf, p) + } + }, + { buf -> + val grid = UpgradeGrid() + val count = buf.readInt() + repeat(count) { + grid.placements.add(UpgradePlacement.STREAM_CODEC.decode(buf)) + } + grid.rebuild() + grid + } + ) + } +} diff --git a/src/main/kotlin/de/devin/cbbees/content/upgrades/UpgradeShape.kt b/src/main/kotlin/de/devin/cbbees/content/upgrades/UpgradeShape.kt new file mode 100644 index 0000000..100e4cc --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/upgrades/UpgradeShape.kt @@ -0,0 +1,74 @@ +package de.devin.cbbees.content.upgrades + +/** + * Defines the spatial shape of an upgrade on the grid. + * Each shape is a list of (col, row) offsets relative to the placement origin. + */ +data class UpgradeShape(val cells: List>) { + + /** + * Returns this shape rotated clockwise [times] times (each 90 degrees). + * In screen coordinates (Y down), CW rotation is: (x, y) -> (-y, x). + * Result is normalized so all coords are non-negative. + */ + fun rotated(times: Int): UpgradeShape { + val t = ((times % 4) + 4) % 4 + if (t == 0) return this + + var current = cells + repeat(t) { + current = current.map { (x, y) -> Pair(-y, x) } + val minX = current.minOf { it.first } + val minY = current.minOf { it.second } + current = current.map { (x, y) -> Pair(x - minX, y - minY) } + } + return UpgradeShape(current) + } + + fun width(): Int = cells.maxOf { it.first } + 1 + fun height(): Int = cells.maxOf { it.second } + 1 + + companion object { + /** L-shape: 3 cells + * ``` + * ██ + * █ + * ``` + */ + val L_SHAPE = UpgradeShape(listOf(0 to 0, 1 to 0, 0 to 1)) + + /** T-shape: 4 cells + * ``` + * █ + * ███ + * ``` + */ + val T_SHAPE = UpgradeShape(listOf(1 to 0, 0 to 1, 1 to 1, 2 to 1)) + + /** 2x1 horizontal bar: 2 cells + * ``` + * ██ + * ``` + */ + val BAR_2 = UpgradeShape(listOf(0 to 0, 1 to 0)) + + /** Single cell: 1 cell */ + val SINGLE = UpgradeShape(listOf(0 to 0)) + + /** 2x2 square: 4 cells + * ``` + * ██ + * ██ + * ``` + */ + val SQUARE_2X2 = UpgradeShape(listOf(0 to 0, 1 to 0, 0 to 1, 1 to 1)) + + /** S-shape: 4 cells + * ``` + * ██ + * ██ + * ``` + */ + val S_SHAPE = UpgradeShape(listOf(1 to 0, 2 to 0, 0 to 1, 1 to 1)) + } +} diff --git a/src/main/kotlin/de/devin/cbbees/items/AllItems.kt b/src/main/kotlin/de/devin/cbbees/items/AllItems.kt index ed05550..a066461 100644 --- a/src/main/kotlin/de/devin/cbbees/items/AllItems.kt +++ b/src/main/kotlin/de/devin/cbbees/items/AllItems.kt @@ -227,6 +227,86 @@ object AllItems { .properties { it.stacksTo(1).rarity(Rarity.UNCOMMON) } .register() + // Honey Tank - increases honey capacity + val HONEY_TANK: ItemEntry = CreateBuzzyBeez.REGISTRATE + .item("honey_tank") { props -> + HoneyTankUpgrade(props) + } + .model { _, _ -> } + .recipe { c, p -> + ShapedRecipeBuilder.shaped(RecipeCategory.MISC, c.get()) + .define('W', UPGRADE_TEMPLATE.get()) + .define('V', Items.HONEY_BLOCK) + .define('B', AllItems.COPPER_SHEET) + .pattern("BVB") + .pattern("VWV") + .pattern("BVB") + .unlockedBy("has_upgrade_base", RegistrateRecipeProvider.has(UPGRADE_TEMPLATE.get())) + .save(p, CreateBuzzyBeez.asResource("crafting/" + c.name)) + } + .properties { it.stacksTo(1).rarity(Rarity.UNCOMMON) } + .register() + + // Reinforced Plating - increases spring efficiency + val REINFORCED_PLATING: ItemEntry = CreateBuzzyBeez.REGISTRATE + .item("reinforced_plating") { props -> + ReinforcedPlatingUpgrade(props) + } + .model { _, _ -> } + .recipe { c, p -> + ShapedRecipeBuilder.shaped(RecipeCategory.MISC, c.get()) + .define('W', UPGRADE_TEMPLATE.get()) + .define('V', AllItems.STURDY_SHEET) + .define('B', AllItems.BRASS_INGOT.get()) + .pattern(" V ") + .pattern("BWB") + .pattern(" V ") + .unlockedBy("has_upgrade_base", RegistrateRecipeProvider.has(UPGRADE_TEMPLATE.get())) + .save(p, CreateBuzzyBeez.asResource("crafting/" + c.name)) + } + .properties { it.stacksTo(1).rarity(Rarity.UNCOMMON) } + .register() + + // Drone View - enables top-down drone camera + val DRONE_VIEW: ItemEntry = CreateBuzzyBeez.REGISTRATE + .item("drone_view") { props -> + DroneViewUpgrade(props) + } + .model { _, _ -> } + .recipe { c, p -> + ShapedRecipeBuilder.shaped(RecipeCategory.MISC, c.get()) + .define('W', UPGRADE_TEMPLATE.get()) + .define('V', Items.SPYGLASS) + .define('B', Items.ENDER_PEARL) + .pattern(" V ") + .pattern("BWB") + .pattern(" B ") + .unlockedBy("has_upgrade_base", RegistrateRecipeProvider.has(UPGRADE_TEMPLATE.get())) + .save(p, CreateBuzzyBeez.asResource("crafting/" + c.name)) + } + .properties { it.stacksTo(1).rarity(Rarity.RARE) } + .register() + + // Drone Range - extends drone view radius + val DRONE_RANGE: ItemEntry = CreateBuzzyBeez.REGISTRATE + .item("drone_range") { props -> + DroneRangeUpgrade(props) + } + .model { _, _ -> } + .recipe { c, p -> + ShapedRecipeBuilder.shaped(RecipeCategory.MISC, c.get()) + .define('W', UPGRADE_TEMPLATE.get()) + .define('V', Items.ENDER_EYE) + .define('B', AllItems.COPPER_SHEET) + .pattern(" V ") + .pattern("BWB") + .pattern(" B ") + .unlockedBy("has_upgrade_base", RegistrateRecipeProvider.has(UPGRADE_TEMPLATE.get())) + .save(p, CreateBuzzyBeez.asResource("crafting/" + c.name)) + } + .properties { it.stacksTo(1).rarity(Rarity.UNCOMMON) } + .register() + // Mechanical Bee Chassis - output of sequenced assembly, glued into mechanical bee val MECHANICAL_BEE_CHASSIS: ItemEntry = CreateBuzzyBeez.REGISTRATE .item("mechanical_bee_chassis") { props -> Item(props) } diff --git a/src/main/kotlin/de/devin/cbbees/network/AllPackets.kt b/src/main/kotlin/de/devin/cbbees/network/AllPackets.kt index c574de7..a5d4020 100644 --- a/src/main/kotlin/de/devin/cbbees/network/AllPackets.kt +++ b/src/main/kotlin/de/devin/cbbees/network/AllPackets.kt @@ -78,5 +78,34 @@ object AllPackets { RequestPlayerJobsPacket.STREAM_CODEC, RequestPlayerJobsPacket.Companion::handle ) + + // Grid upgrade packets + registrar.playToServer( + GridPlaceUpgradePacket.TYPE, + GridPlaceUpgradePacket.STREAM_CODEC, + GridPlaceUpgradePacket.Companion::handle + ) + registrar.playToServer( + GridRemoveUpgradePacket.TYPE, + GridRemoveUpgradePacket.STREAM_CODEC, + GridRemoveUpgradePacket.Companion::handle + ) + + // Drone view packets + registrar.playToServer( + ToggleDroneViewPacket.TYPE, + ToggleDroneViewPacket.STREAM_CODEC, + ToggleDroneViewPacket.Companion::handle + ) + registrar.playToClient( + DroneViewSyncPacket.TYPE, + DroneViewSyncPacket.STREAM_CODEC, + DroneViewSyncPacket.Companion::handle + ) + registrar.playToServer( + MoveDronePacket.TYPE, + MoveDronePacket.STREAM_CODEC, + MoveDronePacket.Companion::handle + ) } } diff --git a/src/main/kotlin/de/devin/cbbees/network/CCRServerEvents.kt b/src/main/kotlin/de/devin/cbbees/network/CCRServerEvents.kt index 4480d76..f7e9ec3 100644 --- a/src/main/kotlin/de/devin/cbbees/network/CCRServerEvents.kt +++ b/src/main/kotlin/de/devin/cbbees/network/CCRServerEvents.kt @@ -4,7 +4,10 @@ import de.devin.cbbees.content.bee.debug.BeeDebug import de.devin.cbbees.content.domain.GlobalJobPool import de.devin.cbbees.content.domain.TransportDispatcher import de.devin.cbbees.content.domain.network.ServerBeeNetworkManager +import de.devin.cbbees.content.drone.DroneViewManager +import net.minecraft.server.level.ServerPlayer import net.neoforged.bus.api.SubscribeEvent +import net.neoforged.neoforge.event.entity.living.LivingDeathEvent import net.neoforged.neoforge.event.entity.player.PlayerEvent import net.neoforged.neoforge.event.server.ServerStoppingEvent import net.neoforged.neoforge.event.tick.ServerTickEvent @@ -37,6 +40,7 @@ object CCRServerEvents { GlobalJobPool.tick(gameTime) TransportDispatcher.tick(gameTime) ServerBeeNetworkManager.getNetworks().forEach { it.cleanupReservations(gameTime) } + DroneViewManager.validateDrones() // Sync packets every 40 ticks (2 seconds) to reduce network and serialization overhead syncCounter++ @@ -55,9 +59,20 @@ object CCRServerEvents { @SubscribeEvent @JvmStatic fun onPlayerLoggedOut(event: PlayerEvent.PlayerLoggedOutEvent) { + val player = event.entity as? ServerPlayer + if (player != null) { + DroneViewManager.despawnDrone(player) + } ServerBeeNetworkManager.unregisterWorker(event.entity.uuid) } + @SubscribeEvent + @JvmStatic + fun onPlayerDeath(event: LivingDeathEvent) { + val player = event.entity as? ServerPlayer ?: return + DroneViewManager.despawnDrone(player) + } + /** * Clears networks on server stop to prevent stale data between world loads. */ @@ -70,5 +85,6 @@ object CCRServerEvents { TransportDispatcher.clear() BeeDebug.clear() PlannerUploadPacket.shutdown() + DroneViewManager.clear() } } diff --git a/src/main/kotlin/de/devin/cbbees/network/DroneViewSyncPacket.kt b/src/main/kotlin/de/devin/cbbees/network/DroneViewSyncPacket.kt new file mode 100644 index 0000000..8294bc8 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/network/DroneViewSyncPacket.kt @@ -0,0 +1,44 @@ +package de.devin.cbbees.network + +import de.devin.cbbees.CreateBuzzyBeez +import de.devin.cbbees.content.drone.client.DroneViewClientState +import de.devin.cbbees.util.ClientSide +import net.minecraft.network.RegistryFriendlyByteBuf +import net.minecraft.network.codec.StreamCodec +import net.minecraft.network.protocol.common.custom.CustomPacketPayload +import net.neoforged.neoforge.network.handling.IPayloadContext + +/** + * Server -> Client: sync drone view state. + * entityId = -1 means deactivate, otherwise the entity ID of the drone. + * maxRange = maximum drone range from player (for HUD display). + */ +class DroneViewSyncPacket(val entityId: Int, val maxRange: Float) : CustomPacketPayload { + + companion object { + val TYPE = CustomPacketPayload.Type( + CreateBuzzyBeez.asResource("drone_view_sync") + ) + + val STREAM_CODEC: StreamCodec = StreamCodec.of( + { buf, pkt -> + buf.writeVarInt(pkt.entityId) + buf.writeFloat(pkt.maxRange) + }, + { buf -> DroneViewSyncPacket(buf.readVarInt(), buf.readFloat()) } + ) + + @ClientSide + fun handle(payload: DroneViewSyncPacket, ctx: IPayloadContext) { + ctx.enqueueWork { + if (payload.entityId == -1) { + DroneViewClientState.deactivate() + } else { + DroneViewClientState.activate(payload.entityId, payload.maxRange) + } + } + } + } + + override fun type(): CustomPacketPayload.Type = TYPE +} diff --git a/src/main/kotlin/de/devin/cbbees/network/GridPlaceUpgradePacket.kt b/src/main/kotlin/de/devin/cbbees/network/GridPlaceUpgradePacket.kt new file mode 100644 index 0000000..f41fd93 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/network/GridPlaceUpgradePacket.kt @@ -0,0 +1,65 @@ +package de.devin.cbbees.network + +import de.devin.cbbees.CreateBuzzyBeez +import de.devin.cbbees.content.backpack.BeehiveContainer +import de.devin.cbbees.content.upgrades.BeeUpgradeItem +import de.devin.cbbees.content.upgrades.UpgradeGrid +import de.devin.cbbees.registry.AllDataComponents +import net.minecraft.network.RegistryFriendlyByteBuf +import net.minecraft.network.codec.StreamCodec +import net.minecraft.network.protocol.common.custom.CustomPacketPayload +import net.minecraft.server.level.ServerPlayer +import net.neoforged.neoforge.network.handling.IPayloadContext + +/** + * Client → Server: place an upgrade from the cursor (carried item) onto the backpack grid. + */ +class GridPlaceUpgradePacket( + val gridX: Int, + val gridY: Int, + val rotation: Int +) : CustomPacketPayload { + + companion object { + val TYPE = CustomPacketPayload.Type( + CreateBuzzyBeez.asResource("grid_place_upgrade") + ) + + val STREAM_CODEC: StreamCodec = StreamCodec.of( + { buf, p -> + buf.writeVarInt(p.gridX) + buf.writeVarInt(p.gridY) + buf.writeVarInt(p.rotation) + }, + { buf -> + GridPlaceUpgradePacket( + buf.readVarInt(), + buf.readVarInt(), + buf.readVarInt() + ) + } + ) + + fun handle(payload: GridPlaceUpgradePacket, ctx: IPayloadContext) { + ctx.enqueueWork { + val player = ctx.player() as? ServerPlayer ?: return@enqueueWork + val menu = player.containerMenu as? BeehiveContainer ?: return@enqueueWork + + // The upgrade is on the cursor (carried item) + val carried = menu.carried + val upgradeItem = carried.item as? BeeUpgradeItem ?: return@enqueueWork + + val backpackStack = menu.backpackStack + val grid = backpackStack.get(AllDataComponents.UPGRADE_GRID.get())?.copy() ?: UpgradeGrid() + + if (grid.canPlace(upgradeItem.upgradeType, payload.gridX, payload.gridY, payload.rotation)) { + grid.place(upgradeItem.upgradeType, payload.gridX, payload.gridY, payload.rotation) + carried.shrink(1) + backpackStack.set(AllDataComponents.UPGRADE_GRID.get(), grid) + } + } + } + } + + override fun type(): CustomPacketPayload.Type = TYPE +} diff --git a/src/main/kotlin/de/devin/cbbees/network/GridRemoveUpgradePacket.kt b/src/main/kotlin/de/devin/cbbees/network/GridRemoveUpgradePacket.kt new file mode 100644 index 0000000..be2814f --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/network/GridRemoveUpgradePacket.kt @@ -0,0 +1,71 @@ +package de.devin.cbbees.network + +import de.devin.cbbees.CreateBuzzyBeez +import de.devin.cbbees.content.backpack.BeehiveContainer +import de.devin.cbbees.content.upgrades.UpgradeGrid +import de.devin.cbbees.items.AllItems +import de.devin.cbbees.registry.AllDataComponents +import net.minecraft.network.RegistryFriendlyByteBuf +import net.minecraft.network.codec.StreamCodec +import net.minecraft.network.protocol.common.custom.CustomPacketPayload +import net.minecraft.server.level.ServerPlayer +import net.minecraft.world.item.ItemStack +import net.neoforged.neoforge.network.handling.IPayloadContext + +/** + * Client → Server: remove an upgrade from the backpack grid and return it to the player. + */ +class GridRemoveUpgradePacket( + val gridX: Int, + val gridY: Int +) : CustomPacketPayload { + + companion object { + val TYPE = CustomPacketPayload.Type( + CreateBuzzyBeez.asResource("grid_remove_upgrade") + ) + + val STREAM_CODEC: StreamCodec = StreamCodec.of( + { buf, p -> + buf.writeVarInt(p.gridX) + buf.writeVarInt(p.gridY) + }, + { buf -> + GridRemoveUpgradePacket(buf.readVarInt(), buf.readVarInt()) + } + ) + + fun handle(payload: GridRemoveUpgradePacket, ctx: IPayloadContext) { + ctx.enqueueWork { + val player = ctx.player() as? ServerPlayer ?: return@enqueueWork + val menu = player.containerMenu as? BeehiveContainer ?: return@enqueueWork + + // Only allow removal when cursor is empty + if (!menu.carried.isEmpty) return@enqueueWork + + val backpackStack = menu.backpackStack + val grid = backpackStack.get(AllDataComponents.UPGRADE_GRID.get())?.copy() ?: return@enqueueWork + + val removed = grid.removeAt(payload.gridX, payload.gridY) ?: return@enqueueWork + + // Put the upgrade item on the cursor + val returnStack = when (removed.type) { + de.devin.cbbees.content.upgrades.UpgradeType.RAPID_WINGS -> ItemStack(AllItems.RAPID_WINGS.get()) + de.devin.cbbees.content.upgrades.UpgradeType.SWARM_INTELLIGENCE -> ItemStack(AllItems.SWARM_INTELLIGENCE.get()) + de.devin.cbbees.content.upgrades.UpgradeType.HONEY_EFFICIENCY -> ItemStack(AllItems.HONEY_EFFICIENCY.get()) + de.devin.cbbees.content.upgrades.UpgradeType.SOFT_TOUCH -> ItemStack(AllItems.SOFT_TOUCH.get()) + de.devin.cbbees.content.upgrades.UpgradeType.DROP_ITEMS -> ItemStack(AllItems.DROP_ITEMS.get()) + de.devin.cbbees.content.upgrades.UpgradeType.HONEY_TANK -> ItemStack(AllItems.HONEY_TANK.get()) + de.devin.cbbees.content.upgrades.UpgradeType.REINFORCED_PLATING -> ItemStack(AllItems.REINFORCED_PLATING.get()) + de.devin.cbbees.content.upgrades.UpgradeType.DRONE_VIEW -> ItemStack(AllItems.DRONE_VIEW.get()) + de.devin.cbbees.content.upgrades.UpgradeType.DRONE_RANGE -> ItemStack(AllItems.DRONE_RANGE.get()) + } + + menu.setCarried(returnStack) + backpackStack.set(AllDataComponents.UPGRADE_GRID.get(), grid) + } + } + } + + override fun type(): CustomPacketPayload.Type = TYPE +} diff --git a/src/main/kotlin/de/devin/cbbees/network/MoveDronePacket.kt b/src/main/kotlin/de/devin/cbbees/network/MoveDronePacket.kt new file mode 100644 index 0000000..53aded7 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/network/MoveDronePacket.kt @@ -0,0 +1,56 @@ +package de.devin.cbbees.network + +import de.devin.cbbees.CreateBuzzyBeez +import de.devin.cbbees.content.bee.MechanicalBeeEntity +import de.devin.cbbees.content.drone.DroneViewManager +import de.devin.cbbees.util.ServerSide +import net.minecraft.network.RegistryFriendlyByteBuf +import net.minecraft.network.codec.StreamCodec +import net.minecraft.network.protocol.common.custom.CustomPacketPayload +import net.minecraft.server.level.ServerPlayer +import net.neoforged.neoforge.network.handling.IPayloadContext + +/** + * Client -> Server: move the drone by a delta offset. + * dx/dz are world-space deltas clamped to max speed on the server. + */ +class MoveDronePacket(val dx: Float, val dz: Float) : CustomPacketPayload { + + companion object { + val TYPE = CustomPacketPayload.Type( + CreateBuzzyBeez.asResource("move_drone") + ) + + val STREAM_CODEC: StreamCodec = StreamCodec.of( + { buf, pkt -> + buf.writeFloat(pkt.dx) + buf.writeFloat(pkt.dz) + }, + { buf -> MoveDronePacket(buf.readFloat(), buf.readFloat()) } + ) + + @ServerSide + fun handle(payload: MoveDronePacket, ctx: IPayloadContext) { + ctx.enqueueWork { + val player = ctx.player() as? ServerPlayer ?: return@enqueueWork + + val droneUUID = DroneViewManager.getDroneUUID(player) ?: return@enqueueWork + val entity = player.serverLevel().getEntity(droneUUID) as? MechanicalBeeEntity ?: return@enqueueWork + if (!entity.isDrone) return@enqueueWork + + // Clamp magnitude to prevent speed hacking + var dx = payload.dx.toDouble() + var dz = payload.dz.toDouble() + val mag = kotlin.math.sqrt(dx * dx + dz * dz) + if (mag > MechanicalBeeEntity.DRONE_MAX_SPEED) { + dx = dx / mag * MechanicalBeeEntity.DRONE_MAX_SPEED + dz = dz / mag * MechanicalBeeEntity.DRONE_MAX_SPEED + } + + entity.applyDroneMovement(dx, dz) + } + } + } + + override fun type(): CustomPacketPayload.Type = TYPE +} diff --git a/src/main/kotlin/de/devin/cbbees/network/ToggleDroneViewPacket.kt b/src/main/kotlin/de/devin/cbbees/network/ToggleDroneViewPacket.kt new file mode 100644 index 0000000..d4927eb --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/network/ToggleDroneViewPacket.kt @@ -0,0 +1,37 @@ +package de.devin.cbbees.network + +import de.devin.cbbees.CreateBuzzyBeez +import de.devin.cbbees.content.drone.DroneViewManager +import de.devin.cbbees.util.ServerSide +import net.minecraft.network.RegistryFriendlyByteBuf +import net.minecraft.network.codec.StreamCodec +import net.minecraft.network.protocol.common.custom.CustomPacketPayload +import net.minecraft.server.level.ServerPlayer +import net.neoforged.neoforge.network.handling.IPayloadContext + +/** + * Client -> Server: toggle drone view on/off. + */ +class ToggleDroneViewPacket : CustomPacketPayload { + + companion object { + val TYPE = CustomPacketPayload.Type( + CreateBuzzyBeez.asResource("toggle_drone_view") + ) + + val STREAM_CODEC: StreamCodec = StreamCodec.of( + { _, _ -> /* no data */ }, + { _ -> ToggleDroneViewPacket() } + ) + + @ServerSide + fun handle(payload: ToggleDroneViewPacket, ctx: IPayloadContext) { + ctx.enqueueWork { + val player = ctx.player() as? ServerPlayer ?: return@enqueueWork + DroneViewManager.toggleDrone(player) + } + } + } + + override fun type(): CustomPacketPayload.Type = TYPE +} diff --git a/src/main/kotlin/de/devin/cbbees/registry/AllDataComponents.kt b/src/main/kotlin/de/devin/cbbees/registry/AllDataComponents.kt index cea06bc..305c203 100644 --- a/src/main/kotlin/de/devin/cbbees/registry/AllDataComponents.kt +++ b/src/main/kotlin/de/devin/cbbees/registry/AllDataComponents.kt @@ -1,6 +1,7 @@ package de.devin.cbbees.registry import de.devin.cbbees.CreateBuzzyBeez +import de.devin.cbbees.content.upgrades.UpgradeGrid import net.minecraft.core.component.DataComponentType import net.minecraft.core.registries.Registries import net.minecraft.network.codec.ByteBufCodecs @@ -21,6 +22,12 @@ object AllDataComponents { .networkSynchronized(ByteBufCodecs.VAR_INT) } + val UPGRADE_GRID: DeferredHolder, DataComponentType> = + REGISTER.registerComponentType("upgrade_grid") { builder -> + builder.persistent(UpgradeGrid.CODEC) + .networkSynchronized(UpgradeGrid.STREAM_CODEC) + } + fun register(bus: IEventBus) { REGISTER.register(bus) } diff --git a/src/main/kotlin/de/devin/cbbees/registry/AllKeys.kt b/src/main/kotlin/de/devin/cbbees/registry/AllKeys.kt index ca4dbd1..54b3d34 100644 --- a/src/main/kotlin/de/devin/cbbees/registry/AllKeys.kt +++ b/src/main/kotlin/de/devin/cbbees/registry/AllKeys.kt @@ -66,6 +66,17 @@ object AllKeys { "key.categories.${CreateBuzzyBeez.ID}" ) + /** + * Keybinding to toggle drone view. + * Default key: V + */ + val DRONE_VIEW: KeyMapping = KeyMapping( + "key.${CreateBuzzyBeez.ID}.drone_view", + InputConstants.Type.KEYSYM, + GLFW.GLFW_KEY_V, + "key.categories.${CreateBuzzyBeez.ID}" + ) + /** * Registers all keybindings with the game. * Called from RegisterKeyMappingsEvent. @@ -76,6 +87,7 @@ object AllKeys { event.register(OPEN_SCHEMATIC_BROWSER) event.register(ROTATE_PREVIEW) event.register(MIRROR_PREVIEW) + event.register(DRONE_VIEW) } } diff --git a/src/main/resources/assets/cbbees/lang/en_us.json b/src/main/resources/assets/cbbees/lang/en_us.json index d50f279..5602f66 100644 --- a/src/main/resources/assets/cbbees/lang/en_us.json +++ b/src/main/resources/assets/cbbees/lang/en_us.json @@ -17,6 +17,8 @@ "item.cbbees.honey_efficiency": "Honey Efficiency", "item.cbbees.soft_touch": "Soft Touch", "item.cbbees.drop_items": "Drop Items", + "item.cbbees.honey_tank": "Honey Tank", + "item.cbbees.reinforced_plating": "Reinforced Plating", "item.cbbees.upgrade_template": "Upgrade Template", "container.cbbees.portable_beehive": "Portable Beehive", "item.cbbees.portable_beehive.tooltip": "Mobile Construction Backpack", @@ -89,6 +91,8 @@ "tooltip.cbbees.upgrade.honey_efficiency": "Bees have increased carry capacity, break speed and honey efficiency", "tooltip.cbbees.upgrade.soft_touch": "Deconstruction preserves blocks intact", "tooltip.cbbees.upgrade.drop_items": "Bees drop items on deconstruction instead of picking them up", + "tooltip.cbbees.upgrade.honey_tank": "+200 honey capacity per upgrade", + "tooltip.cbbees.upgrade.reinforced_plating": "+25% spring efficiency per upgrade", "tooltip.cbbees.upgrade.max_stack": "Max in hive: %d", "tooltip.cbbees.upgrade.unique": "Only one can be installed", "cbbees.construction.stopped": "Swarm recalled!", @@ -298,5 +302,43 @@ "gui.cbbees.job_detail.title": "Construction Job", "gui.cbbees.job_detail.cancel": "Cancel Job", - "gui.cbbees.job_detail.not_found": "Job not found" + "gui.cbbees.job_detail.not_found": "Job not found", + + "item.cbbees.honey_tank.tooltip": "Capacity Upgrade", + "item.cbbees.honey_tank.tooltip.summary": "Increases the _honey storage capacity_ of the Portable Beehive by _200_ per upgrade. Install in the _Portable Beehive_.", + "item.cbbees.honey_tank.tooltip.condition1": "Max Stack", + "item.cbbees.honey_tank.tooltip.behaviour1": "Up to _2_ can be installed in a single beehive.", + + "item.cbbees.reinforced_plating.tooltip": "Durability Upgrade", + "item.cbbees.reinforced_plating.tooltip.summary": "Increases _spring efficiency_ by _25%_ per upgrade, allowing bees to work longer before needing to recharge. Install in the _Portable Beehive_.", + "item.cbbees.reinforced_plating.tooltip.condition1": "Max Stack", + "item.cbbees.reinforced_plating.tooltip.behaviour1": "Up to _2_ can be installed in a single beehive.", + + "gui.cbbees.grid.rotate_hint": "Right-click / scroll to rotate", + "gui.cbbees.grid.shape_preview": "Shape:", + + "item.cbbees.drone_view": "Drone View", + "tooltip.cbbees.upgrade.drone_view": "Enables top-down drone camera (press V)", + "key.cbbees.drone_view": "Toggle Drone View", + "cbbees.drone_view.no_upgrade": "Drone View upgrade not installed!", + "cbbees.drone_view.no_bees": "No bees available for drone!", + "cbbees.drone_view.activated": "Drone View activated", + "cbbees.drone_view.deactivated": "Drone View deactivated", + "cbbees.drone_view.hud": "Drone View [V to exit]", + "cbbees.drone_view.no_backpack": "No backpack equipped!", + + "item.cbbees.drone_view.tooltip": "Drone Camera Upgrade", + "item.cbbees.drone_view.tooltip.summary": "Enables a top-down _drone camera_ by consuming a bee from the backpack. Press _V_ to toggle the elevated view for planning schematics. Use _WASD_ to fly the drone within range.", + "item.cbbees.drone_view.tooltip.condition1": "Unique", + "item.cbbees.drone_view.tooltip.behaviour1": "Only _1_ can be installed per beehive.", + + "item.cbbees.drone_range": "Drone Range", + "tooltip.cbbees.upgrade.drone_range": "+16 blocks drone camera range", + "cbbees.drone_view.hud.range": "Range: %s / %s blocks", + "cbbees.drone_view.hud.controls": "WASD to fly | V to exit", + + "item.cbbees.drone_range.tooltip": "Drone Range Upgrade", + "item.cbbees.drone_range.tooltip.summary": "Extends the maximum range of the _Drone View_ camera by _16 blocks_ per upgrade. Allows scouting further from the player.", + "item.cbbees.drone_range.tooltip.condition1": "Max Stack", + "item.cbbees.drone_range.tooltip.behaviour1": "Up to _3_ can be installed in a single beehive." } diff --git a/src/main/resources/assets/cbbees/textures/gui/portable_beehive.png b/src/main/resources/assets/cbbees/textures/gui/portable_beehive.png index dd7daf316801e240a2d55c0c23dc61b2e34d69bc..4fe1ec6460ecd35da1e83a306de9b2c3c54a22dd 100644 GIT binary patch literal 8735 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 From 4bf31a978260488da0d7576160c9db2cd2ca4b4c Mon Sep 17 00:00:00 2001 From: devin Date: Sat, 11 Apr 2026 13:25:12 +0200 Subject: [PATCH 2/3] Insane update --- gradle.properties | 2 +- .../mixin/SchematicHandlerHudMixin.java | 23 + .../cbbees/mixin/SchematicToolBaseMixin.java | 103 ++ .../mixin/ToolSelectionScreenMixin.java | 15 +- .../kotlin/de/devin/cbbees/CreateBuzzyBeez.kt | 10 + .../de/devin/cbbees/blocks/AllBlocks.kt | 23 + .../devin/cbbees/config/CBBeesClientConfig.kt | 10 + .../de/devin/cbbees/config/CBBeesConfig.kt | 40 +- .../content/backpack/PortableBeehiveItem.kt | 25 +- .../cbbees/content/bee/BeePathNavigation.kt | 131 ++ .../devin/cbbees/content/bee/BeeSeparation.kt | 7 +- .../content/bee/BumbleBeeCarriedItemLayer.kt | 5 + .../cbbees/content/bee/MechanicalBeeEntity.kt | 128 +- .../cbbees/content/bee/MechanicalBeeItem.kt | 4 +- .../content/bee/MechanicalBeeRenderer.kt | 9 +- .../cbbees/content/bee/MechanicalBeelike.kt | 76 +- .../content/bee/MechanicalBumbleBeeEntity.kt | 86 +- .../bee/MechanicalBumbleBeeRenderer.kt | 20 + .../brain/behavior/DepositAtTargetBehavior.kt | 71 +- .../bee/brain/behavior/ExecuteTaskBehavior.kt | 26 +- .../bee/brain/behavior/GatherItemsBehavior.kt | 23 +- .../behavior/PickUpFromSourceBehavior.kt | 29 +- .../bee/brain/behavior/StuckSafetyBehavior.kt | 5 + .../content/bee/client/BeeClientTracker.kt | 84 ++ .../bee/client/BeeTargetLineHandler.kt | 31 +- .../content/bee/client/BeeWorldRenderer.kt | 177 +++ .../content/bee/client/ClientBeeData.kt | 77 ++ .../cbbees/content/bee/debug/BeeDebug.kt | 21 + .../content/bee/debug/BeeDebugCommand.kt | 137 +++ .../content/bee/flight/CheckpointActions.kt | 320 +++++ .../content/bee/flight/ClientBeeFlightData.kt | 232 ++++ .../cbbees/content/bee/flight/FlightPlan.kt | 119 ++ .../content/bee/flight/FlightPlanComputer.kt | 385 ++++++ .../cbbees/content/bee/server/BeeWorker.kt | 49 + .../content/bee/server/ServerBeeData.kt | 271 +++++ .../content/bee/server/ServerBeeManager.kt | 317 +++++ .../cbbees/content/bee/state/BeeState.kt | 66 + .../bee/state/ConstructionBeeStateMachine.kt | 922 ++++++++++++++ .../bee/state/TransportBeeStateMachine.kt | 519 ++++++++ .../beehive/MechanicalBeehiveBlockEntity.kt | 91 +- .../beehive/client/BeehiveRangeHandler.kt | 8 + .../cbbees/content/deployer/DeployMode.kt | 6 + .../deployer/ProgrammedSchematicItem.kt | 96 ++ .../deployer/SchematicDeployerBlock.kt | 172 +++ .../deployer/SchematicDeployerBlockEntity.kt | 441 +++++++ .../deployer/SchematicDeployerItemHandler.kt | 58 + .../content/deployer/SchematicProgram.kt | 232 ++++ .../client/DeployerPreviewRenderer.kt | 319 +++++ .../client/ProgrammedSchematicRenderer.kt | 170 +++ .../client/SchematicDeployerRenderer.kt | 60 + .../client/SchematicDeployerScreen.kt | 488 ++++++++ .../cbbees/content/domain/GlobalJobPool.kt | 118 +- .../content/domain/TransportDispatcher.kt | 60 +- .../cbbees/content/domain/action/BeeAction.kt | 29 +- .../domain/action/ItemConsumingAction.kt | 22 +- .../domain/action/impl/DropOffItemsAction.kt | 145 +-- .../domain/action/impl/PickupItemsAction.kt | 48 + .../domain/action/impl/PlaceBeltAction.kt | 8 +- .../domain/action/impl/PlaceBlockAction.kt | 8 +- .../domain/action/impl/RemoveBlockAction.kt | 70 +- .../content/domain/bee/BeeInventoryManager.kt | 30 +- .../cbbees/content/domain/beehive/BeeHive.kt | 2 +- .../content/domain/beehive/PortableBeeHive.kt | 45 +- .../content/domain/events/PlayerTickEvent.kt | 19 +- .../devin/cbbees/content/domain/job/BeeJob.kt | 7 +- .../domain/job/JobCalculationProgress.kt | 144 +++ .../domain/job/client/JobProgressClient.kt | 28 + .../domain/job/client/JobProgressToast.kt | 143 +++ .../logistics/PortReservationManager.kt | 56 +- .../domain/logistics/ReservablePort.kt | 12 + .../content/domain/network/BeeNetwork.kt | 25 +- .../domain/network/BeeNetworkManager.kt | 53 +- .../network/client/NetworkHighlightHandler.kt | 5 + .../cbbees/content/domain/task/BeeTask.kt | 28 +- .../cbbees/content/domain/task/TaskBatch.kt | 14 +- .../content/domain/task/TransportTask.kt | 5 +- .../cbbees/content/drone/DroneViewManager.kt | 16 +- .../drone/client/DroneRangeRenderer.kt | 88 ++ .../drone/client/DroneViewClientEvents.kt | 47 +- .../transport/client/CargoPortLinkRenderer.kt | 5 + .../schematics/ConstructionPlannerItem.kt | 17 + .../schematics/DeconstructionPlannerItem.kt | 2 +- .../content/schematics/PickupPlannerItem.kt | 16 + .../schematics/SchematicCreateBridge.kt | 240 +++- .../client/ConstructionPlannerHUD.kt | 7 +- .../client/ConstructionPlannerHandler.kt | 13 +- .../client/ConstructionToolState.kt | 2 +- .../client/DeconstructionClientEvents.kt | 26 +- .../client/DeconstructionHandler.kt | 17 + .../client/DeconstructionRenderer.kt | 16 +- .../schematics/client/PickupHandler.kt | 198 +++ .../client/SchematicHoverPreview.kt | 9 +- .../cbbees/content/upgrades/BeeContext.kt | 6 +- .../cbbees/content/upgrades/BeeUpgradeItem.kt | 2 +- .../kotlin/de/devin/cbbees/items/AllItems.kt | 20 +- .../de/devin/cbbees/network/AllPackets.kt | 45 +- .../network/BeeCheckpointConfirmPacket.kt | 60 + .../cbbees/network/BeeFlightPlanPacket.kt | 86 ++ .../devin/cbbees/network/BeeRemovePacket.kt | 32 + .../devin/cbbees/network/CCRServerEvents.kt | 40 +- .../cbbees/network/DeployerSettingsPacket.kt | 72 ++ .../cbbees/network/HiveJobsSyncPacket.kt | 30 +- .../network/InstantConstructionPacket.kt | 50 +- .../devin/cbbees/network/JobProgressPacket.kt | 67 ++ .../cbbees/network/ProgramSchematicPacket.kt | 65 + .../cbbees/network/SelectSchematicPacket.kt | 6 +- .../cbbees/network/StartConstructionPacket.kt | 49 +- .../network/StartDeconstructionPacket.kt | 57 +- .../devin/cbbees/network/StartPickupPacket.kt | 83 ++ .../devin/cbbees/network/StopTasksPacket.kt | 2 +- .../cbbees/network/UnselectSchematicPacket.kt | 5 +- .../de/devin/cbbees/ponder/CBBPonderPlugin.kt | 7 + .../de/devin/cbbees/ponder/DeployerScenes.kt | 203 ++++ .../cbbees/registry/AllBlockEntityTypes.kt | 8 + .../cbbees/registry/AllDataComponents.kt | 7 + .../devin/cbbees/registry/AllEntityTypes.kt | 4 +- .../de/devin/cbbees/registry/AllKeys.kt | 27 +- .../devin/cbbees/util/ServerTickScheduler.kt | 48 + .../blockstates/schematic_deployer.json | 12 + .../assets/cbbees/geo/mechanical_bee.geo.json | 153 +-- .../cbbees/geo/mechanical_bumble_bee.geo.json | 173 +-- .../resources/assets/cbbees/lang/en_us.json | 56 +- .../models/block/schematic_deployer.json | 1062 +++++++++++++++++ .../block/schematic_deployer_powered.json | 7 + .../models/item/programmed_schematic.json | 6 + .../models/item/schematic_deployer.json | 3 + .../ponder/schematic_deployer/automation.nbt | Bin 0 -> 327 bytes .../ponder/schematic_deployer/intro.nbt | Bin 0 -> 583 bytes .../textures/block/schematic_deployer.png | Bin 0 -> 4901 bytes .../block/schematic_deployer_powered.png | Bin 0 -> 4870 bytes .../cbbees/textures/entity/mechanical_bee.png | Bin 1710 -> 1660 bytes .../textures/entity/mechanical_bumble_bee.png | Bin 1585 -> 1577 bytes .../textures/gui/sprites/toast/background.png | Bin 0 -> 548 bytes .../textures/item/upgrades/drone_range.png | Bin 0 -> 754 bytes .../textures/item/upgrades/drone_view.bbmodel | 1 + .../textures/item/upgrades/drone_view.png | Bin 0 -> 760 bytes .../cbbees/textures/item/upgrades/fuel.png | Bin 0 -> 770 bytes .../textures/item/upgrades/upgrade_base.png | Bin 725 -> 754 bytes src/main/resources/cbbees.mixins.json | 1 + 139 files changed, 10022 insertions(+), 1115 deletions(-) create mode 100644 src/main/java/de/devin/cbbees/mixin/SchematicToolBaseMixin.java create mode 100644 src/main/kotlin/de/devin/cbbees/content/bee/BeePathNavigation.kt create mode 100644 src/main/kotlin/de/devin/cbbees/content/bee/client/BeeClientTracker.kt create mode 100644 src/main/kotlin/de/devin/cbbees/content/bee/client/BeeWorldRenderer.kt create mode 100644 src/main/kotlin/de/devin/cbbees/content/bee/client/ClientBeeData.kt create mode 100644 src/main/kotlin/de/devin/cbbees/content/bee/flight/CheckpointActions.kt create mode 100644 src/main/kotlin/de/devin/cbbees/content/bee/flight/ClientBeeFlightData.kt create mode 100644 src/main/kotlin/de/devin/cbbees/content/bee/flight/FlightPlan.kt create mode 100644 src/main/kotlin/de/devin/cbbees/content/bee/flight/FlightPlanComputer.kt create mode 100644 src/main/kotlin/de/devin/cbbees/content/bee/server/BeeWorker.kt create mode 100644 src/main/kotlin/de/devin/cbbees/content/bee/server/ServerBeeData.kt create mode 100644 src/main/kotlin/de/devin/cbbees/content/bee/server/ServerBeeManager.kt create mode 100644 src/main/kotlin/de/devin/cbbees/content/bee/state/BeeState.kt create mode 100644 src/main/kotlin/de/devin/cbbees/content/bee/state/ConstructionBeeStateMachine.kt create mode 100644 src/main/kotlin/de/devin/cbbees/content/bee/state/TransportBeeStateMachine.kt create mode 100644 src/main/kotlin/de/devin/cbbees/content/deployer/DeployMode.kt create mode 100644 src/main/kotlin/de/devin/cbbees/content/deployer/ProgrammedSchematicItem.kt create mode 100644 src/main/kotlin/de/devin/cbbees/content/deployer/SchematicDeployerBlock.kt create mode 100644 src/main/kotlin/de/devin/cbbees/content/deployer/SchematicDeployerBlockEntity.kt create mode 100644 src/main/kotlin/de/devin/cbbees/content/deployer/SchematicDeployerItemHandler.kt create mode 100644 src/main/kotlin/de/devin/cbbees/content/deployer/SchematicProgram.kt create mode 100644 src/main/kotlin/de/devin/cbbees/content/deployer/client/DeployerPreviewRenderer.kt create mode 100644 src/main/kotlin/de/devin/cbbees/content/deployer/client/ProgrammedSchematicRenderer.kt create mode 100644 src/main/kotlin/de/devin/cbbees/content/deployer/client/SchematicDeployerRenderer.kt create mode 100644 src/main/kotlin/de/devin/cbbees/content/deployer/client/SchematicDeployerScreen.kt create mode 100644 src/main/kotlin/de/devin/cbbees/content/domain/action/impl/PickupItemsAction.kt create mode 100644 src/main/kotlin/de/devin/cbbees/content/domain/job/JobCalculationProgress.kt create mode 100644 src/main/kotlin/de/devin/cbbees/content/domain/job/client/JobProgressClient.kt create mode 100644 src/main/kotlin/de/devin/cbbees/content/domain/job/client/JobProgressToast.kt create mode 100644 src/main/kotlin/de/devin/cbbees/content/drone/client/DroneRangeRenderer.kt create mode 100644 src/main/kotlin/de/devin/cbbees/content/schematics/PickupPlannerItem.kt create mode 100644 src/main/kotlin/de/devin/cbbees/content/schematics/client/PickupHandler.kt create mode 100644 src/main/kotlin/de/devin/cbbees/network/BeeCheckpointConfirmPacket.kt create mode 100644 src/main/kotlin/de/devin/cbbees/network/BeeFlightPlanPacket.kt create mode 100644 src/main/kotlin/de/devin/cbbees/network/BeeRemovePacket.kt create mode 100644 src/main/kotlin/de/devin/cbbees/network/DeployerSettingsPacket.kt create mode 100644 src/main/kotlin/de/devin/cbbees/network/JobProgressPacket.kt create mode 100644 src/main/kotlin/de/devin/cbbees/network/ProgramSchematicPacket.kt create mode 100644 src/main/kotlin/de/devin/cbbees/network/StartPickupPacket.kt create mode 100644 src/main/kotlin/de/devin/cbbees/ponder/DeployerScenes.kt create mode 100644 src/main/kotlin/de/devin/cbbees/util/ServerTickScheduler.kt create mode 100644 src/main/resources/assets/cbbees/blockstates/schematic_deployer.json create mode 100644 src/main/resources/assets/cbbees/models/block/schematic_deployer.json create mode 100644 src/main/resources/assets/cbbees/models/block/schematic_deployer_powered.json create mode 100644 src/main/resources/assets/cbbees/models/item/programmed_schematic.json create mode 100644 src/main/resources/assets/cbbees/models/item/schematic_deployer.json create mode 100644 src/main/resources/assets/cbbees/ponder/schematic_deployer/automation.nbt create mode 100644 src/main/resources/assets/cbbees/ponder/schematic_deployer/intro.nbt create mode 100644 src/main/resources/assets/cbbees/textures/block/schematic_deployer.png create mode 100644 src/main/resources/assets/cbbees/textures/block/schematic_deployer_powered.png create mode 100644 src/main/resources/assets/cbbees/textures/gui/sprites/toast/background.png create mode 100644 src/main/resources/assets/cbbees/textures/item/upgrades/drone_range.png create mode 100644 src/main/resources/assets/cbbees/textures/item/upgrades/drone_view.bbmodel create mode 100644 src/main/resources/assets/cbbees/textures/item/upgrades/drone_view.png create mode 100644 src/main/resources/assets/cbbees/textures/item/upgrades/fuel.png diff --git a/gradle.properties b/gradle.properties index c4449d5..a13cc0c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -39,7 +39,7 @@ mod_name=Create Buzzy Beez # The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default. mod_license=All Rights Reserved # The mod version. See https://semver.org/ -mod_version=1.2.0 +mod_version=1.3.0 # The group ID for the mod. It is only important when publishing as an artifact to a Maven repository. # This should match the base package used for the mod sources. # See https://maven.apache.org/guides/mini/guide-naming-conventions.html diff --git a/src/main/java/de/devin/cbbees/mixin/SchematicHandlerHudMixin.java b/src/main/java/de/devin/cbbees/mixin/SchematicHandlerHudMixin.java index 720eced..815b802 100644 --- a/src/main/java/de/devin/cbbees/mixin/SchematicHandlerHudMixin.java +++ b/src/main/java/de/devin/cbbees/mixin/SchematicHandlerHudMixin.java @@ -6,6 +6,8 @@ import de.devin.cbbees.content.schematics.ConstructionPlannerItem; import de.devin.cbbees.content.schematics.client.ConstructionToolState; import de.devin.cbbees.items.AllItems; +import de.devin.cbbees.content.deployer.SchematicProgram; +import de.devin.cbbees.network.ProgramSchematicPacket; import de.devin.cbbees.network.StartConstructionPacket; import de.devin.cbbees.network.StopTasksPacket; import de.devin.cbbees.network.UnselectSchematicPacket; @@ -105,6 +107,27 @@ public abstract class SchematicHandlerHudMixin { ); ConstructionToolState.setActiveTool(ConstructionToolState.CustomTool.NONE); cir.setReturnValue(true); + } else if (tool == ConstructionToolState.CustomTool.PROGRAM) { + // Read placement data and send a ProgramSchematicPacket + String schematicFile = mainHand.get(AllDataComponents.SCHEMATIC_FILE); + String owner = mainHand.get(AllDataComponents.SCHEMATIC_OWNER); + if (schematicFile == null || owner == null) return; + + BlockPos anchor = mainHand.getOrDefault(AllDataComponents.SCHEMATIC_ANCHOR, BlockPos.ZERO); + Rotation rotation = mainHand.getOrDefault(AllDataComponents.SCHEMATIC_ROTATION, Rotation.NONE); + Mirror mirror = mainHand.getOrDefault(AllDataComponents.SCHEMATIC_MIRROR, Mirror.NONE); + + SchematicProgram program = new SchematicProgram.Construction( + schematicFile, anchor, rotation, mirror, owner + ); + PacketDistributor.sendToServer(new ProgramSchematicPacket(program)); + mc.player.displayClientMessage( + Component.translatable("cbbees.schematic.programmed") + .withStyle(style -> style.withColor(0x88CCFF)), + true + ); + ConstructionToolState.setActiveTool(ConstructionToolState.CustomTool.NONE); + cir.setReturnValue(true); } } diff --git a/src/main/java/de/devin/cbbees/mixin/SchematicToolBaseMixin.java b/src/main/java/de/devin/cbbees/mixin/SchematicToolBaseMixin.java new file mode 100644 index 0000000..5831b66 --- /dev/null +++ b/src/main/java/de/devin/cbbees/mixin/SchematicToolBaseMixin.java @@ -0,0 +1,103 @@ +package de.devin.cbbees.mixin; + +import com.simibubi.create.CreateClient; +import com.simibubi.create.content.schematics.client.SchematicHandler; +import com.simibubi.create.content.schematics.client.SchematicTransformation; +import com.simibubi.create.content.schematics.client.tools.SchematicToolBase; +import com.simibubi.create.foundation.utility.RaycastHelper; +import de.devin.cbbees.content.drone.client.DroneViewClientState; +import net.createmod.catnip.math.VecHelper; +import net.minecraft.client.Minecraft; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.HitResult; +import net.minecraft.world.phys.Vec3; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Mixin into {@link SchematicToolBase} to redirect raycasting during drone view. + * + *

Create's tool system uses the player's eye position and look direction for all + * schematic interaction (deploying, moving, selecting faces). During drone view, + * the camera is on the drone looking straight down, but the player entity is + * stationary on the ground with a frozen view direction. This mixin replaces + * the raycast origin/direction with the drone's position looking downward.

+ */ +@Mixin(value = SchematicToolBase.class, remap = false) +public abstract class SchematicToolBaseMixin { + + @Shadow protected BlockPos selectedPos; + @Shadow protected Vec3 chasingSelectedPos; + @Shadow protected Vec3 lastChasingSelectedPos; + @Shadow protected boolean selectIgnoreBlocks; + @Shadow protected int selectionRange; + @Shadow protected boolean schematicSelected; + @Shadow protected net.minecraft.core.Direction selectedFace; + + @Inject(method = "updateTargetPos", at = @At("HEAD"), cancellable = true) + private void ccr$droneViewTargetPos(CallbackInfo ci) { + if (!DroneViewClientState.INSTANCE.getActive()) return; + + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + if (player == null) return; + + var drone = mc.level != null ? mc.level.getEntity(DroneViewClientState.INSTANCE.getDroneEntityId()) : null; + if (drone == null) return; + + ci.cancel(); + + Vec3 dronePos = drone.position(); + // Look straight down from the drone + Vec3 droneEnd = dronePos.add(0, -75, 0); + + SchematicHandler handler = CreateClient.SCHEMATIC_HANDLER; + + // Select Blueprint (schematic face selection for move/rotate tools) + if (handler.isDeployed()) { + SchematicTransformation transformation = handler.getTransformation(); + AABB localBounds = handler.getBounds(); + + Vec3 start = transformation.toLocalSpace(dronePos); + Vec3 end = transformation.toLocalSpace(droneEnd); + RaycastHelper.PredicateTraceResult result = + RaycastHelper.rayTraceUntil(start, end, pos -> localBounds.contains(VecHelper.getCenterOf(pos))); + + schematicSelected = !result.missed(); + selectedFace = schematicSelected ? result.getFacing() : null; + } + + boolean snap = this.selectedPos == null; + + // Select location at distance (for tools like FlipTool that ignore blocks) + if (selectIgnoreBlocks) { + // Place at drone's XZ, on the ground below + selectedPos = BlockPos.containing(dronePos.add(0, -selectionRange, 0)); + if (snap) + lastChasingSelectedPos = chasingSelectedPos = Vec3.atLowerCornerOf(selectedPos); + return; + } + + // Select targeted Block — raycast straight down from drone + selectedPos = null; + ClipContext ctx = new ClipContext(dronePos, droneEnd, ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, player); + BlockHitResult trace = player.level().clip(ctx); + if (trace == null || trace.getType() != HitResult.Type.BLOCK) + return; + + BlockPos hit = BlockPos.containing(trace.getLocation()); + boolean replaceable = player.level().getBlockState(hit).canBeReplaced(); + if (trace.getDirection().getAxis().isVertical() && !replaceable) + hit = hit.relative(trace.getDirection()); + selectedPos = hit; + if (snap) + lastChasingSelectedPos = chasingSelectedPos = Vec3.atLowerCornerOf(selectedPos); + } +} diff --git a/src/main/java/de/devin/cbbees/mixin/ToolSelectionScreenMixin.java b/src/main/java/de/devin/cbbees/mixin/ToolSelectionScreenMixin.java index c9d4332..da603ba 100644 --- a/src/main/java/de/devin/cbbees/mixin/ToolSelectionScreenMixin.java +++ b/src/main/java/de/devin/cbbees/mixin/ToolSelectionScreenMixin.java @@ -47,16 +47,17 @@ public abstract class ToolSelectionScreenMixin { @Shadow protected Consumer callback; @Shadow private float yOffset; - /** Number of extra tool slots we add (Construct + Unselect). */ - @Unique private static final int CCR$EXTRA_TOOLS = 2; + /** Number of extra tool slots we add (Construct + Unselect + Program). */ + @Unique private static final int CCR$EXTRA_TOOLS = 3; - @Unique private static final String[] CCR$NAMES = { "gui.cbbees.tool.construct", "gui.cbbees.tool.unselect" }; - @Unique private static final String[] CCR$DESCS = { "gui.cbbees.tool.construct.desc", "gui.cbbees.tool.unselect.desc" }; - @Unique private static final AllIcons[] CCR$ICONS = { AllIcons.I_PLAY, AllIcons.I_TRASH }; - @Unique private static final int[] CCR$NAME_COLORS = { 0x88FF88, 0xFFAA88 }; + @Unique private static final String[] CCR$NAMES = { "gui.cbbees.tool.construct", "gui.cbbees.tool.unselect", "gui.cbbees.tool.program" }; + @Unique private static final String[] CCR$DESCS = { "gui.cbbees.tool.construct.desc", "gui.cbbees.tool.unselect.desc", "gui.cbbees.tool.program.desc" }; + @Unique private static final AllIcons[] CCR$ICONS = { AllIcons.I_PLAY, AllIcons.I_TRASH, AllIcons.I_CONFIRM }; + @Unique private static final int[] CCR$NAME_COLORS = { 0x88FF88, 0xFFAA88, 0x88CCFF }; @Unique private static final ConstructionToolState.CustomTool[] CCR$TOOL_ENUM = { ConstructionToolState.CustomTool.CONSTRUCT, - ConstructionToolState.CustomTool.UNSELECT + ConstructionToolState.CustomTool.UNSELECT, + ConstructionToolState.CustomTool.PROGRAM }; /* ------------------------------------------------------------------ */ diff --git a/src/main/kotlin/de/devin/cbbees/CreateBuzzyBeez.kt b/src/main/kotlin/de/devin/cbbees/CreateBuzzyBeez.kt index 0911150..337c438 100644 --- a/src/main/kotlin/de/devin/cbbees/CreateBuzzyBeez.kt +++ b/src/main/kotlin/de/devin/cbbees/CreateBuzzyBeez.kt @@ -19,6 +19,7 @@ import de.devin.cbbees.content.schematics.client.ConstructionPlannerClientEvents import de.devin.cbbees.content.schematics.client.ConstructionPlannerHUD import de.devin.cbbees.content.schematics.client.ConstructionRenderer import de.devin.cbbees.content.domain.events.PlayerTickEvent +import de.devin.cbbees.content.drone.client.DroneRangeRenderer import de.devin.cbbees.content.drone.client.DroneViewClientEvents import de.devin.cbbees.content.drone.client.DroneViewHUD import de.devin.cbbees.content.schematics.client.DeconstructionClientEvents @@ -110,11 +111,15 @@ object CreateBuzzyBeez { NeoForge.EVENT_BUS.register(DeconstructionClientEvents::class.java) NeoForge.EVENT_BUS.register(ConstructionPlannerClientEvents::class.java) NeoForge.EVENT_BUS.register(BeeTargetLineHandler::class.java) + NeoForge.EVENT_BUS.register(de.devin.cbbees.content.bee.client.BeeWorldRenderer::class.java) NeoForge.EVENT_BUS.register(BeehiveRangeHandler::class.java) NeoForge.EVENT_BUS.register(NetworkHighlightHandler::class.java) NeoForge.EVENT_BUS.register(ConstructionRenderer::class.java) NeoForge.EVENT_BUS.register(CargoPortLinkRenderer::class.java) NeoForge.EVENT_BUS.register(DroneViewClientEvents::class.java) + NeoForge.EVENT_BUS.register(DroneRangeRenderer::class.java) + NeoForge.EVENT_BUS.register(de.devin.cbbees.content.deployer.client.ProgrammedSchematicRenderer::class.java) + NeoForge.EVENT_BUS.register(de.devin.cbbees.content.deployer.client.DeployerPreviewRenderer::class.java) MOD_BUS.addListener { onClientSetup(it) } MOD_BUS.addListener { AllKeys.register(it) } MOD_BUS.addListener { event -> @@ -153,6 +158,11 @@ object CreateBuzzyBeez { AllBlockEntityTypes.LOGISTICS_PORT.get(), { be, side -> be.getItemHandler(be.world) } ) + event.registerBlockEntity( + Capabilities.ItemHandler.BLOCK, + AllBlockEntityTypes.SCHEMATIC_DEPLOYER.get(), + { be, _ -> de.devin.cbbees.content.deployer.SchematicDeployerItemHandler(be) } + ) event.registerItem( Capabilities.FluidHandler.ITEM, { stack, _ -> de.devin.cbbees.content.backpack.BeehiveFluidHandler(stack) }, diff --git a/src/main/kotlin/de/devin/cbbees/blocks/AllBlocks.kt b/src/main/kotlin/de/devin/cbbees/blocks/AllBlocks.kt index cf1465a..b12f8d6 100644 --- a/src/main/kotlin/de/devin/cbbees/blocks/AllBlocks.kt +++ b/src/main/kotlin/de/devin/cbbees/blocks/AllBlocks.kt @@ -8,6 +8,7 @@ import com.tterrag.registrate.providers.RegistrateRecipeProvider import com.tterrag.registrate.util.entry.BlockEntry import de.devin.cbbees.CreateBuzzyBeez import de.devin.cbbees.content.beehive.MechanicalBeehiveBlock +import de.devin.cbbees.content.deployer.SchematicDeployerBlock import de.devin.cbbees.content.logistics.ports.LogisticPortBlock import de.devin.cbbees.content.logistics.transport.TransportPortBlock import net.minecraft.data.recipes.RecipeCategory @@ -54,6 +55,28 @@ object AllBlocks { .build() .register() + val SCHEMATIC_DEPLOYER: BlockEntry = CreateBuzzyBeez.REGISTRATE + .block("schematic_deployer") { SchematicDeployerBlock(it) } + .initialProperties { Blocks.IRON_BLOCK } + .properties { p -> p.noOcclusion() } + .blockstate { _, _ -> } // Hand-written blockstate in resources + .item() + .recipe { c, p -> + ShapedRecipeBuilder.shaped(RecipeCategory.REDSTONE, c.get()) + .define('B', AllItems.BRASS_INGOT.get()) + .define('E', AllItems.ELECTRON_TUBE.get()) + .define('C', CreateAllBlocks.BRASS_CASING.get()) + .define('R', net.minecraft.world.item.Items.REDSTONE) + .pattern("BEB") + .pattern("RCR") + .pattern("BBB") + .unlockedBy("has_brass_casing", RegistrateRecipeProvider.has(CreateAllBlocks.BRASS_CASING.get())) + .save(p, CreateBuzzyBeez.asResource("crafting/" + c.name)) + } + .model { _, _ -> } // Hand-written item model in resources + .build() + .register() + val CARGO_PORT = CreateBuzzyBeez.REGISTRATE.block("cargo_port", ::TransportPortBlock) .initialProperties(SharedProperties::softMetal) .properties { it.noOcclusion() } diff --git a/src/main/kotlin/de/devin/cbbees/config/CBBeesClientConfig.kt b/src/main/kotlin/de/devin/cbbees/config/CBBeesClientConfig.kt index 6e290af..2e70e1e 100644 --- a/src/main/kotlin/de/devin/cbbees/config/CBBeesClientConfig.kt +++ b/src/main/kotlin/de/devin/cbbees/config/CBBeesClientConfig.kt @@ -12,6 +12,8 @@ object CBBeesClientConfig { val ghostBlockOpacity: ModConfigSpec.DoubleValue val renderGhostBlockEntities: ModConfigSpec.BooleanValue val maxGhostBlockEntities: ModConfigSpec.IntValue + val beeShadowDistance: ModConfigSpec.IntValue + val beeItemRenderDistance: ModConfigSpec.IntValue init { val builder = ModConfigSpec.Builder() @@ -47,6 +49,14 @@ object CBBeesClientConfig { .comment("Maximum number of block entities to render per ghost preview. Lower values improve performance.") .defineInRange("maxGhostBlockEntities", 64, 1, 1024) + beeShadowDistance = builder + .comment("Maximum distance (blocks) at which bee shadows are rendered. Lower for better FPS with many bees.") + .defineInRange("beeShadowDistance", 16, 4, 32) + + beeItemRenderDistance = builder + .comment("Maximum distance (blocks) at which carried items on bumble bees are rendered.") + .defineInRange("beeItemRenderDistance", 24, 8, 64) + builder.pop() SPEC = builder.build() diff --git a/src/main/kotlin/de/devin/cbbees/config/CBBeesConfig.kt b/src/main/kotlin/de/devin/cbbees/config/CBBeesConfig.kt index 637fdff..8332f6d 100644 --- a/src/main/kotlin/de/devin/cbbees/config/CBBeesConfig.kt +++ b/src/main/kotlin/de/devin/cbbees/config/CBBeesConfig.kt @@ -9,17 +9,17 @@ object CBBeesConfig { val enableExternalBrowser: ModConfigSpec.BooleanValue val maxBeesPerHive: ModConfigSpec.IntValue - val minActiveRobotsAtRpm: ModConfigSpec.IntValue + val minActiveBeesAtRpm: ModConfigSpec.IntValue val beePickupItems: ModConfigSpec.BooleanValue // Beehive RPM scaling val hiveBaseRange: ModConfigSpec.IntValue val hiveRangePerRpm: ModConfigSpec.DoubleValue val hiveRpmSpeedDivisor: ModConfigSpec.DoubleValue - val hiveRpmRobotDivisor: ModConfigSpec.DoubleValue + val hiveRpmBeeDivisor: ModConfigSpec.DoubleValue // Bee defaults - val defaultMaxActiveRobots: ModConfigSpec.IntValue + val defaultMaxActiveBees: ModConfigSpec.IntValue val defaultWorkRange: ModConfigSpec.DoubleValue // Upgrade values @@ -44,6 +44,11 @@ object CBBeesConfig { val springDrainDeposit: ModConfigSpec.DoubleValue val springRechargeTicks: ModConfigSpec.IntValue + // Performance settings + val maxBlockOperationsPerTick: ModConfigSpec.IntValue + val taskGenerationBlocksPerTick: ModConfigSpec.IntValue + val maxCheckpointsPerTick: ModConfigSpec.IntValue + // Honey fuel settings val portableHoneyPerRewind: ModConfigSpec.IntValue val portableMaxHoney: ModConfigSpec.IntValue @@ -70,9 +75,9 @@ object CBBeesConfig { .comment("Maximum number of active bees per hive") .defineInRange("maxBeesPerHive", 16, 1, 64) - minActiveRobotsAtRpm = builder + minActiveBeesAtRpm = builder .comment("Minimum active bees when the hive has any RPM, so even slow shafts deploy at least this many bees") - .defineInRange("minActiveRobotsAtRpm", 1, 0, 64) + .defineInRange("minActiveBeesAtRpm", 1, 0, 64) hiveBaseRange = builder .comment("Base work range of a beehive before RPM scaling (at minimum RPM)") @@ -86,9 +91,9 @@ object CBBeesConfig { .comment("RPM divisor for bee speed and spring efficiency: multiplier = 1 + RPM / this value") .defineInRange("hiveRpmSpeedDivisor", 256.0, 1.0, 1024.0) - hiveRpmRobotDivisor = builder + hiveRpmBeeDivisor = builder .comment("RPM divisor for workforce scaling: extra bees = RPM / this value") - .defineInRange("hiveRpmRobotDivisor", 8.0, 1.0, 256.0) + .defineInRange("hiveRpmBeeDivisor", 8.0, 1.0, 256.0) builder.pop() @@ -99,9 +104,9 @@ object CBBeesConfig { .comment("Whether bees pick up item drops when breaking blocks") .define("beePickupItems", true) - defaultMaxActiveRobots = builder + defaultMaxActiveBees = builder .comment("Default max active robots before RPM/upgrade scaling") - .defineInRange("defaultMaxActiveRobots", 4, 1, 64) + .defineInRange("defaultMaxActiveBees", 4, 1, 64) defaultWorkRange = builder .comment("Default work range for BeeContext before hive overrides") @@ -213,6 +218,23 @@ object CBBeesConfig { builder.pop() + builder.comment("Performance Tuning") + .push("performance") + + maxBlockOperationsPerTick = builder + .comment("Maximum number of block place/break operations all bees can perform per server tick. Lower values reduce TPS impact with many bees.") + .defineInRange("maxBlockOperationsPerTick", 20, 1, 200) + + taskGenerationBlocksPerTick = builder + .comment("Maximum schematic blocks scanned per server tick when generating build/removal tasks. Lower values reduce server hitches on large schematics; higher values finish calculation faster.") + .defineInRange("taskGenerationBlocksPerTick", 500, 50, 10000) + + maxCheckpointsPerTick = builder + .comment("Maximum bee checkpoint actions (block place/break arrivals) processed per server tick. Higher values let more bees work simultaneously but increase per-tick server load.") + .defineInRange("maxCheckpointsPerTick", 30, 1, 500) + + builder.pop() + SPEC = builder.build() } } diff --git a/src/main/kotlin/de/devin/cbbees/content/backpack/PortableBeehiveItem.kt b/src/main/kotlin/de/devin/cbbees/content/backpack/PortableBeehiveItem.kt index 912c981..4306951 100644 --- a/src/main/kotlin/de/devin/cbbees/content/backpack/PortableBeehiveItem.kt +++ b/src/main/kotlin/de/devin/cbbees/content/backpack/PortableBeehiveItem.kt @@ -49,7 +49,7 @@ import kotlin.math.roundToInt data class BeehiveTooltipData(val stack: ItemStack) : TooltipComponent /** - * Constructor Backpack - A wearable equipment item that manages constructor robots and their upgrades. + * Constructor Backpack - A wearable equipment item that manages construction bees and their upgrades. * * This item can be worn in the Curios "back" slot. When opened, it provides a GUI with: * - 4 Slots for bee items (MechanicalBeeItem or MechanicalBumbleBeeItem). @@ -267,10 +267,7 @@ class PortableBeehiveItem(properties: Properties) : ArmorItem(ArmorMaterials.IRO ) { super.appendHoverText(stack, context, tooltipComponents, tooltipFlag) - // Count robots and upgrades in the backpack val robotCount = getRobotCount(stack) - val upgrades = getUpgrades(stack) - tooltipComponents.add( Component.translatable("tooltip.cbbees.beehive.bees", robotCount, ROBOT_SLOTS) .withStyle(ChatFormatting.GRAY) @@ -282,26 +279,6 @@ class PortableBeehiveItem(properties: Properties) : ArmorItem(ArmorMaterials.IRO Component.translatable("tooltip.cbbees.beehive.honey", honey, maxHoney) .withStyle(ChatFormatting.GOLD) ) - - if (upgrades.isNotEmpty()) { - tooltipComponents.add( - Component.translatable("tooltip.cbbees.beehive.naturified_header") - .withStyle(ChatFormatting.GOLD) - ) - for ((type, count) in upgrades) { - tooltipComponents.add( - Component.literal(" - ") - .append(Component.translatable(type.descriptionKey)) - .append(Component.literal(" x$count")) - .withStyle(ChatFormatting.BLUE) - ) - } - } else { - tooltipComponents.add( - Component.translatable("tooltip.cbbees.beehive.no_naturified") - .withStyle(ChatFormatting.DARK_GRAY) - ) - } } private fun isBeeItem(item: net.minecraft.world.item.Item): Boolean { diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/BeePathNavigation.kt b/src/main/kotlin/de/devin/cbbees/content/bee/BeePathNavigation.kt new file mode 100644 index 0000000..ceaedb7 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/bee/BeePathNavigation.kt @@ -0,0 +1,131 @@ +package de.devin.cbbees.content.bee + +import net.minecraft.core.BlockPos +import net.minecraft.world.entity.Entity +import net.minecraft.world.entity.PathfinderMob +import net.minecraft.world.entity.ai.navigation.FlyingPathNavigation +import net.minecraft.world.level.Level +import net.minecraft.world.level.pathfinder.Node +import net.minecraft.world.level.pathfinder.Path + +/** + * Lightweight flight navigation for mechanical bees that bypasses A* pathfinding. + * + * Instead of running a 3D A* search, creates straight-line paths and follows them + * with direct MoveControl targeting. Overrides [tick] completely to avoid vanilla + * path-following logic that causes oscillation with simple paths. + * + * Obstacle avoidance: every [CHECK_INTERVAL] ticks, checks a few blocks ahead + * and routes vertically over obstructions. + */ +class BeePathNavigation(mob: PathfinderMob, level: Level) : FlyingPathNavigation(mob, level) { + + companion object { + private const val CHECK_INTERVAL = 5 + private const val ARRIVAL_DISTANCE_SQ = 2.25 // 1.5 blocks + } + + private var obstructionCheckTick = 0 + + /** + * Creates a straight-line path bypassing A*. + */ + override fun createPath(target: BlockPos, distance: Int): Path? { + val startPos = mob.blockPosition() + val startNode = Node(startPos.x, startPos.y, startPos.z).apply { walkedDistance = 0f; costMalus = 0f } + val endNode = Node(target.x, target.y, target.z).apply { + walkedDistance = startPos.distManhattan(target).toFloat(); costMalus = 0f + } + return Path(listOf(startNode, endNode), target, true) + } + + override fun createPath(entity: Entity, distance: Int): Path? { + return createPath(entity.blockPosition(), distance) + } + + /** + * Completely replaces vanilla path-following. Sets MoveControl target directly + * and handles node advancement + obstruction avoidance. + */ + override fun tick() { + val currentPath = this.path + if (currentPath == null || isDone) return + + // Advance past nodes the bee has already reached + val nodeIndex = currentPath.nextNodeIndex + if (nodeIndex >= currentPath.nodeCount) { + stop() + return + } + + val targetNode = currentPath.getNode(nodeIndex) + val tx = targetNode.x + 0.5 + val ty = targetNode.y.toDouble() + val tz = targetNode.z + 0.5 + + val dx = tx - mob.x + val dy = ty - mob.y + val dz = tz - mob.z + val distSq = dx * dx + dy * dy + dz * dz + + if (distSq < ARRIVAL_DISTANCE_SQ) { + // Arrived at this node — advance + currentPath.advance() + if (currentPath.nextNodeIndex >= currentPath.nodeCount) { + stop() + return + } + // Target next node + val next = currentPath.getNode(currentPath.nextNodeIndex) + mob.moveControl.setWantedPosition(next.x + 0.5, next.y.toDouble(), next.z + 0.5, 1.0) + } else { + // Still moving toward current node + mob.moveControl.setWantedPosition(tx, ty, tz, 1.0) + } + + // Lightweight obstruction check + obstructionCheckTick++ + if (obstructionCheckTick >= CHECK_INTERVAL) { + obstructionCheckTick = 0 + checkAndAvoidObstruction() + } + } + + /** + * Checks a few blocks in the bee's flight direction for solid obstructions. + * If found, creates a new path that routes above the obstruction. + */ + private fun checkAndAvoidObstruction() { + val currentPath = this.path ?: return + if (currentPath.nextNodeIndex >= currentPath.nodeCount) return + + val targetNode = currentPath.getNode(currentPath.nextNodeIndex) + val targetPos = BlockPos(targetNode.x, targetNode.y, targetNode.z) + val beePos = mob.blockPosition() + + // Check if the block at the target is solid + if (!level.getBlockState(targetPos).getCollisionShape(level, targetPos).isEmpty) { + // Find clear altitude above + var clearY = beePos.y + 2 + for (dy in 2..8) { + val checkPos = beePos.above(dy) + if (level.getBlockState(checkPos).getCollisionShape(level, checkPos).isEmpty) { + clearY = checkPos.y + break + } + } + + // Get the final target + val endNode = currentPath.endNode ?: return + val finalTarget = BlockPos(endNode.x, endNode.y, endNode.z) + + // Create a 3-node path: current → above → final target + val n0 = Node(beePos.x, beePos.y, beePos.z).apply { walkedDistance = 0f } + val n1 = Node(beePos.x, clearY, beePos.z).apply { walkedDistance = (clearY - beePos.y).toFloat() } + val n2 = Node(finalTarget.x, finalTarget.y, finalTarget.z).apply { + walkedDistance = n1.walkedDistance + beePos.distManhattan(finalTarget).toFloat() + } + this.path = Path(listOf(n0, n1, n2), finalTarget, true) + } + } +} diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/BeeSeparation.kt b/src/main/kotlin/de/devin/cbbees/content/bee/BeeSeparation.kt index 9efb2d8..5b82aa1 100644 --- a/src/main/kotlin/de/devin/cbbees/content/bee/BeeSeparation.kt +++ b/src/main/kotlin/de/devin/cbbees/content/bee/BeeSeparation.kt @@ -28,12 +28,7 @@ object BeeSeparation { val offsetX = Math.cos(angle) * 0.006 val offsetZ = Math.sin(angle) * 0.006 - val current = bee.deltaMovement - bee.deltaMovement = Vec3( - current.x + offsetX, - current.y, - current.z + offsetZ - ) + bee.deltaMovement = bee.deltaMovement.add(offsetX, 0.0, offsetZ) } /** diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/BumbleBeeCarriedItemLayer.kt b/src/main/kotlin/de/devin/cbbees/content/bee/BumbleBeeCarriedItemLayer.kt index 829bc07..82c2cf3 100644 --- a/src/main/kotlin/de/devin/cbbees/content/bee/BumbleBeeCarriedItemLayer.kt +++ b/src/main/kotlin/de/devin/cbbees/content/bee/BumbleBeeCarriedItemLayer.kt @@ -28,6 +28,11 @@ class BumbleBeeCarriedItemLayer(renderer: GeoRenderer packedLight: Int, packedOverlay: Int ) { + // Skip item rendering for distant bees + val cam = Minecraft.getInstance().gameRenderer.mainCamera.position + val itemDist = de.devin.cbbees.config.CBBeesClientConfig.beeItemRenderDistance.get() + if (animatable.distanceToSqr(cam) > itemDist.toLong() * itemDist) return + val contents = animatable.getInventoryContents() if (contents.isEmpty()) return diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/MechanicalBeeEntity.kt b/src/main/kotlin/de/devin/cbbees/content/bee/MechanicalBeeEntity.kt index 4f33fb6..0466700 100644 --- a/src/main/kotlin/de/devin/cbbees/content/bee/MechanicalBeeEntity.kt +++ b/src/main/kotlin/de/devin/cbbees/content/bee/MechanicalBeeEntity.kt @@ -2,11 +2,16 @@ package de.devin.cbbees.content.bee import com.mojang.serialization.Dynamic import com.simibubi.create.AllItems -import de.devin.cbbees.content.bee.brain.BeeBrainProvider -import de.devin.cbbees.content.bee.brain.BeeMemoryModules +import de.devin.cbbees.content.bee.client.BeeClientTracker +import de.devin.cbbees.content.bee.server.BeeWorker +import de.devin.cbbees.content.bee.state.ConstructionBeeState +import de.devin.cbbees.content.bee.state.ConstructionBeeStateMachine +import de.devin.cbbees.content.bee.state.StuckCheckData +import de.devin.cbbees.content.domain.beehive.BeeHive import de.devin.cbbees.content.domain.network.ServerBeeNetworkManager import de.devin.cbbees.content.domain.network.ClientBeeNetworkManager import de.devin.cbbees.content.domain.network.BeeNetwork +import de.devin.cbbees.content.domain.task.TaskBatch import de.devin.cbbees.content.domain.task.TaskStatus import de.devin.cbbees.content.upgrades.BeeContext import de.devin.cbbees.items.AllItems as CBeesItems @@ -58,7 +63,7 @@ import kotlin.jvm.optionals.getOrNull * 6. Returning to the hive when all tasks are done. */ class MechanicalBeeEntity(entityType: EntityType, level: Level) : PathfinderMob(entityType, level), - GeoEntity, MechanicalBeelike { + GeoEntity, MechanicalBeelike, BeeWorker { companion object { private val OWNER_UUID: EntityDataAccessor> = @@ -87,13 +92,21 @@ class MechanicalBeeEntity(entityType: EntityType, level: Leve private val geoCache = GeckoLibUtil.createInstanceCache(this) - /** Calculated stats for this robot based on backpack upgrades */ + /** Calculated stats for this bee based on backpack upgrades */ private var beeContext: BeeContext? = null /** Inventory for carrying items needed by tasks. 4 slots covers composite blocks (e.g. bracket = girder + shaft). */ override var inventory = SimpleContainer(4) private set + /** BeeWorker identity — delegates to Entity.getUUID(). */ + override val uuid: UUID get() = getUUID() + + // BeeWorker positional accessors — delegate to Entity getters + override fun getWorkerX(): Double = getX() + override fun getWorkerY(): Double = getY() + override fun getWorkerZ(): Double = getZ() + override var networkId: UUID = UUID.randomUUID() /** Tick when spring recharge completes at hive. -1 = not recharging. */ @@ -122,9 +135,18 @@ class MechanicalBeeEntity(entityType: EntityType, level: Leve override val homeId: UUID? get() = entityData.get(BEEHIVE_ID).getOrNull() override fun setHomeId(uuid: UUID) { entityData.set(BEEHIVE_ID, Optional.of(uuid)) } override fun beeItemStack(): ItemStack = ItemStack(CBeesItems.MECHANICAL_BEE.get()) - override fun taskMemory(): MemoryModuleType<*> = BeeMemoryModules.CURRENT_TASK.get() override fun getBeeContextForRecharge(): BeeContext = getBeeContext() + // ── State machine fields (replaces Brain memories) ── + var beeState = ConstructionBeeState.GATHERING + var currentTask: TaskBatch? = null + override var walkTargetPos: BlockPos? = null + override var hiveInstance: BeeHive? = null + override var hivePos: BlockPos? = null + override var returningToOwner: Player? = null + override var orphanedTicks: Int = 0 + override val stuckData = StuckCheckData() + /** * Consumes spring tension for an action. Applies efficiency modifiers from [beeContext]. * Returns false if spring is already empty. Drains to 0 if insufficient for a full action. @@ -186,10 +208,10 @@ class MechanicalBeeEntity(entityType: EntityType, level: Leve if (isDrone) return this.level().profiler.push("beeBrain") - this.getBrain().tick(this.level() as ServerLevel, this) + ConstructionBeeStateMachine.tick( + this, this.level() as ServerLevel, (this.level() as ServerLevel).gameTime + ) this.level().profiler.pop() - - super.customServerAiStep() } override fun getAnimatableInstanceCache(): AnimatableInstanceCache? { @@ -197,18 +219,18 @@ class MechanicalBeeEntity(entityType: EntityType, level: Leve } override fun brainProvider(): Brain.Provider<*> { - return BeeBrainProvider.brain() + // Empty brain — all AI logic handled by ConstructionBeeStateMachine + @Suppress("UNCHECKED_CAST") + return Brain.provider( + listOf>(), + listOf>>() + ) as Brain.Provider } @Suppress("UNCHECKED_CAST") override fun makeBrain(dynamic: Dynamic<*>): Brain { - val brain = this.brainProvider().makeBrain(dynamic) - return BeeBrainProvider.makeBrain(brain as Brain) - } - - @Suppress("UNCHECKED_CAST") - override fun getBrain(): Brain { - return super.getBrain() as Brain + // Empty brain — state machine handles all AI + return this.brainProvider().makeBrain(dynamic) as Brain } override fun defineSynchedData(builder: SynchedEntityData.Builder) { @@ -230,7 +252,7 @@ class MechanicalBeeEntity(entityType: EntityType, level: Leve if (!level().isClientSide && !isDrone) { network()?.releaseReservations(this.uuid) // Release current batch so it can be retried by another bee - val batch = getBrain().getMemory(BeeMemoryModules.CURRENT_TASK.get()).orElse(null) + val batch = currentTask if (batch != null && batch.status != TaskStatus.COMPLETED) { val tick = (level() as? ServerLevel)?.gameTime ?: 0L batch.release(gameTick = tick) @@ -240,6 +262,20 @@ class MechanicalBeeEntity(entityType: EntityType, level: Leve super.remove(reason) } + override fun onAddedToLevel() { + super.onAddedToLevel() + if (level().isClientSide) { + BeeClientTracker.onBeeAdded(this) + } + } + + override fun onRemovedFromLevel() { + super.onRemovedFromLevel() + if (level().isClientSide) { + BeeClientTracker.onBeeRemoved(this) + } + } + fun setOwner(uuid: UUID) { this.entityData.set(OWNER_UUID, Optional.of(uuid)) } @@ -250,15 +286,22 @@ class MechanicalBeeEntity(entityType: EntityType, level: Leve super.tick() if (level().isClientSide) return + // Legacy entity bees from old saves — drop as item and discard. + // All new bees use ServerBeeManager (non-entity system). + if (!isDrone) { + val beeItem = beeItemStack() + level().addFreshEntity(ItemEntity(level(), x, y, z, beeItem)) + dropInventory() + discard() + return + } + if (isDrone) { tickDrone() return } syncTargetPos() - if (rechargeFinishTick < 0) { - BeeSeparation.applyFlightOffset(this) - } if (beeContext == null || tickCount % 100 == 0) { beeContext = beehive()?.getBeeContext() @@ -306,25 +349,21 @@ class MechanicalBeeEntity(entityType: EntityType, level: Leve } } + private var lastSyncedTargetPos: BlockPos? = null + private fun syncTargetPos() { - val brain = getBrain() - val walkTarget = brain.getMemory(MemoryModuleType.WALK_TARGET).orElse(null) - if (walkTarget != null) { - entityData.set(TARGET_POS, Optional.of(walkTarget.target.currentBlockPosition())) - return - } - val batch = brain.getMemory(BeeMemoryModules.CURRENT_TASK.get()).orElse(null) - val task = batch?.getCurrentTask() - if (task != null) { - entityData.set(TARGET_POS, Optional.of(task.targetPos)) - } else { - entityData.set(TARGET_POS, Optional.empty()) + val newPos: BlockPos? = walkTargetPos ?: currentTask?.getCurrentTask()?.targetPos + + if (newPos != lastSyncedTargetPos) { + lastSyncedTargetPos = newPos + entityData.set(TARGET_POS, if (newPos != null) Optional.of(newPos) else Optional.empty()) } } override fun getTargetPos(): BlockPos? = entityData.get(TARGET_POS).orElse(null) - // Mechanical bees fly through water — no swimming, no water drag + // Mechanical bees fly — no gravity, no swimming, no water drag + override fun isNoGravity(): Boolean = true override fun isInWater(): Boolean = false @Deprecated("Overrides deprecated MC method", level = DeprecationLevel.WARNING) @@ -338,6 +377,7 @@ class MechanicalBeeEntity(entityType: EntityType, level: Leve @Deprecated("Overrides deprecated MC method", level = DeprecationLevel.WARNING) override fun isPushable(): Boolean = false + override fun pushEntities() { /* no-op — skip expensive nearby entity scan */ } override fun addAdditionalSaveData(compound: CompoundTag) { super.addAdditionalSaveData(compound) @@ -408,16 +448,16 @@ class MechanicalBeeEntity(entityType: EntityType, level: Leve /** * Gets the owner player entity. */ - internal fun getOwnerPlayer(): ServerPlayer? { + override fun getOwnerPlayer(): ServerPlayer? { return getOwnerUUID()?.let { level().getPlayerByUUID(it) } as? ServerPlayer } - fun getBeeContext(): BeeContext = beeContext ?: BeeContext() + override fun getBeeContext(): BeeContext = beeContext ?: BeeContext() /** * Inserts a stack into the bee's inventory. Returns the remainder that didn't fit. */ - fun addToInventory(stack: ItemStack): ItemStack { + override fun addToInventory(stack: ItemStack): ItemStack { var remaining = stack.copy() // First pass: merge into existing matching slots for (i in 0 until inventory.containerSize) { @@ -442,7 +482,7 @@ class MechanicalBeeEntity(entityType: EntityType, level: Leve return remaining } - fun getInventoryContents(): List { + override fun getInventoryContents(): List { val list = mutableListOf() for (i in 0 until inventory.containerSize) { val stack = inventory.getItem(i) @@ -451,7 +491,7 @@ class MechanicalBeeEntity(entityType: EntityType, level: Leve return list } - fun isInventoryFull(): Boolean { + override fun isInventoryFull(): Boolean { for (i in 0 until inventory.containerSize) { val stack = inventory.getItem(i) if (stack.isEmpty || stack.count < stack.maxStackSize) return false @@ -459,6 +499,13 @@ class MechanicalBeeEntity(entityType: EntityType, level: Leve return true } + override fun isInventoryEmpty(): Boolean { + for (i in 0 until inventory.containerSize) { + if (!inventory.getItem(i).isEmpty) return false + } + return true + } + fun countInInventory(stack: ItemStack): Int { var count = 0 for (i in 0 until inventory.containerSize) { @@ -470,7 +517,7 @@ class MechanicalBeeEntity(entityType: EntityType, level: Leve return count } - fun removeFromInventory(stack: ItemStack, count: Int): Boolean { + override fun removeFromInventory(stack: ItemStack, count: Int) { var toRemove = count for (i in 0 until inventory.containerSize) { if (toRemove <= 0) break @@ -482,13 +529,12 @@ class MechanicalBeeEntity(entityType: EntityType, level: Leve if (slotStack.isEmpty) inventory.setItem(i, ItemStack.EMPTY) } } - return toRemove <= 0 } /** * Drops all items in the bee's inventory on the ground at its current position. */ - fun dropInventory() { + override fun dropInventory() { for (i in 0 until inventory.containerSize) { val stack = inventory.getItem(i) if (!stack.isEmpty) { diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/MechanicalBeeItem.kt b/src/main/kotlin/de/devin/cbbees/content/bee/MechanicalBeeItem.kt index 4d109e9..8c873f9 100644 --- a/src/main/kotlin/de/devin/cbbees/content/bee/MechanicalBeeItem.kt +++ b/src/main/kotlin/de/devin/cbbees/content/bee/MechanicalBeeItem.kt @@ -7,7 +7,7 @@ import net.minecraft.world.item.ItemStack import net.minecraft.world.item.TooltipFlag /** - * Constructor Robot Item - Represents a robot that can be stored in the Constructor Backpack. + * Constructor Bee Item - Represents a bee that can be stored in the Constructor Backpack. * * Features: * - Stackable up to 64 @@ -18,7 +18,7 @@ import net.minecraft.world.item.TooltipFlag class MechanicalBeeItem(properties: Properties) : Item(properties) { companion object { - /** Maximum stack size for robots */ + /** Maximum stack size for bees */ const val MAX_STACK_SIZE = 64 } diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/MechanicalBeeRenderer.kt b/src/main/kotlin/de/devin/cbbees/content/bee/MechanicalBeeRenderer.kt index b1a8fc7..a77f1b3 100644 --- a/src/main/kotlin/de/devin/cbbees/content/bee/MechanicalBeeRenderer.kt +++ b/src/main/kotlin/de/devin/cbbees/content/bee/MechanicalBeeRenderer.kt @@ -15,7 +15,7 @@ class MechanicalBeeRenderer(context: EntityRendererProvider.Context) : GeoEntityRenderer(context, MechanicalBeeModel()) { init { - // Shadow radius for the robot + // Shadow radius for the bee this.shadowRadius = 0.3f } @@ -29,6 +29,13 @@ class MechanicalBeeRenderer(context: EntityRendererProvider.Context) : ) { // Drones are invisible to all players — they exist only as a camera anchor if (entity.isDrone) return + + // Distance-based shadow culling + val cam = Minecraft.getInstance().gameRenderer.mainCamera.position + val distSq = entity.distanceToSqr(cam) + val shadowDist = de.devin.cbbees.config.CBBeesClientConfig.beeShadowDistance.get() + this.shadowRadius = if (distSq < shadowDist.toLong() * shadowDist) 0.3f else 0.0f + super.render(entity, entityYaw, partialTick, poseStack, bufferSource, packedLight) } } diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/MechanicalBeelike.kt b/src/main/kotlin/de/devin/cbbees/content/bee/MechanicalBeelike.kt index 1bc9e8f..6bc0fb6 100644 --- a/src/main/kotlin/de/devin/cbbees/content/bee/MechanicalBeelike.kt +++ b/src/main/kotlin/de/devin/cbbees/content/bee/MechanicalBeelike.kt @@ -1,22 +1,18 @@ package de.devin.cbbees.content.bee -import de.devin.cbbees.content.bee.brain.BeeMemoryModules import de.devin.cbbees.content.bee.debug.BeeDebug import de.devin.cbbees.content.domain.beehive.BeeHive import de.devin.cbbees.content.domain.network.ServerBeeNetworkManager import de.devin.cbbees.content.upgrades.BeeContext +import net.minecraft.core.BlockPos import net.minecraft.world.SimpleContainer -import net.minecraft.world.entity.MoverType import net.minecraft.world.entity.PathfinderMob -import net.minecraft.world.entity.ai.memory.MemoryModuleType -import net.minecraft.world.entity.ai.navigation.FlyingPathNavigation import net.minecraft.world.entity.ai.navigation.PathNavigation import net.minecraft.world.entity.item.ItemEntity import net.minecraft.world.item.ItemStack import net.minecraft.world.level.Level import net.minecraft.world.phys.Vec3 import java.util.* -import kotlin.jvm.optionals.getOrNull /** * Shared contract for mechanical bee entities (construction bees and bumble bees). @@ -45,24 +41,41 @@ interface MechanicalBeelike : NetworkedBee { /** The item stack representing this bee type (for drops / hive entry) */ fun beeItemStack(): ItemStack - /** The memory module that represents "has active work" for this bee type */ - fun taskMemory(): MemoryModuleType<*> + // ── State machine fields (replaces Brain memories) ── + + /** Current walk target position, or null if not moving. Replaces WALK_TARGET memory. */ + var walkTargetPos: BlockPos? + + /** Cached hive instance. Replaces HIVE_INSTANCE memory. */ + var hiveInstance: de.devin.cbbees.content.domain.beehive.BeeHive? + + /** Cached hive position. Replaces HIVE_POS memory. */ + var hivePos: BlockPos? + + /** Player to return to (portable beehive removed). Replaces RETURNING_TO_OWNER memory. */ + var returningToOwner: net.minecraft.world.entity.player.Player? + + /** Orphaned tick counter for adoption timeout. */ + var orphanedTicks: Int + + /** Stuck detection data. */ + val stuckData: de.devin.cbbees.content.bee.state.StuckCheckData /** Consumes spring tension for an action. Returns false if empty. */ fun consumeSpring(baseDrain: Double): Boolean // ── Default implementations ────────────────────────────────────────── - /** Looks up the bee's hive, caching in brain memory */ + /** Looks up the bee's hive, caching in the state field. */ fun beehive(): BeeHive? { + val cached = hiveInstance + if (cached != null) return cached val self = this as PathfinderMob - val fromMemory = self.brain.getMemory(BeeMemoryModules.HIVE_INSTANCE.get()).getOrNull() - if (fromMemory != null) return fromMemory if (self.level().isClientSide) return null val hiveId = homeId ?: return null val hive = ServerBeeNetworkManager.findHive(hiveId) if (hive != null) { - self.brain.setMemory(BeeMemoryModules.HIVE_INSTANCE.get(), Optional.of(hive)) + hiveInstance = hive } return hive } @@ -73,11 +86,10 @@ interface MechanicalBeelike : NetworkedBee { val net = network() ?: return null val hive = net.hives .filter { it != exclude } - .sortedBy { it.pos.distSqr(self.blockPosition()) } - .firstOrNull() ?: return null + .minByOrNull { it.pos.distSqr(self.blockPosition()) } ?: return null setHomeId(hive.id) - self.brain.setMemory(BeeMemoryModules.HIVE_INSTANCE.get(), Optional.of(hive)) - self.brain.setMemory(BeeMemoryModules.HIVE_POS.get(), hive.pos) + hiveInstance = hive + hivePos = hive.pos return hive } @@ -104,29 +116,37 @@ interface MechanicalBeelike : NetworkedBee { } companion object { - /** Shared flying travel logic — call from PathfinderMob.travel() override */ + /** + * Lightweight flying travel — replaces Entity.move() with a single-block collision check. + * + * Entity.move() performs full block collision detection (640K+ chunk lookups at scale). + * This version does ONE block check at the destination position: if solid, stop; + * otherwise, setPos directly. Saves ~20% of tick time at 200+ bees. + */ fun travelFlying(mob: PathfinderMob, travelVector: Vec3) { if (mob.isControlledByLocalInstance()) { - if (mob.isInWater()) { - mob.moveRelative(0.02f, travelVector) - mob.move(MoverType.SELF, mob.deltaMovement) - mob.deltaMovement = mob.deltaMovement.scale(0.8) - } else if (mob.isInLava()) { - mob.moveRelative(0.02f, travelVector) - mob.move(MoverType.SELF, mob.deltaMovement) - mob.deltaMovement = mob.deltaMovement.scale(0.5) + mob.moveRelative(0.04f, travelVector) + val delta = mob.deltaMovement + val newX = mob.x + delta.x + val newY = mob.y + delta.y + val newZ = mob.z + delta.z + + // Lightweight collision: check if destination block is solid + val destPos = net.minecraft.core.BlockPos.containing(newX, newY, newZ) + if (mob.level().getBlockState(destPos).getCollisionShape(mob.level(), destPos).isEmpty) { + mob.setPos(newX, newY, newZ) } else { - mob.moveRelative(if (mob.onGround()) 0.1f else 0.04f, travelVector) - mob.move(MoverType.SELF, mob.deltaMovement) - mob.deltaMovement = mob.deltaMovement.scale(0.91) + // Hit a solid block — stop movement, navigation will reroute + mob.deltaMovement = Vec3.ZERO } + mob.deltaMovement = mob.deltaMovement.scale(0.91) } mob.calculateEntityAnimation(false) } /** Shared flying navigation setup — call from PathfinderMob.createNavigation() override */ fun createFlyingNavigation(mob: PathfinderMob, level: Level): PathNavigation { - val navigation = FlyingPathNavigation(mob, level) + val navigation = BeePathNavigation(mob, level) navigation.setCanOpenDoors(false) navigation.setCanPassDoors(true) return navigation diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/MechanicalBumbleBeeEntity.kt b/src/main/kotlin/de/devin/cbbees/content/bee/MechanicalBumbleBeeEntity.kt index 14ad588..63714cb 100644 --- a/src/main/kotlin/de/devin/cbbees/content/bee/MechanicalBumbleBeeEntity.kt +++ b/src/main/kotlin/de/devin/cbbees/content/bee/MechanicalBumbleBeeEntity.kt @@ -1,11 +1,15 @@ package de.devin.cbbees.content.bee import com.mojang.serialization.Dynamic -import de.devin.cbbees.content.bee.brain.MechanicalBumbleBrainProvider -import de.devin.cbbees.content.bee.brain.BeeMemoryModules +import de.devin.cbbees.content.bee.client.BeeClientTracker +import de.devin.cbbees.content.bee.state.StuckCheckData +import de.devin.cbbees.content.bee.state.TransportBeeState +import de.devin.cbbees.content.bee.state.TransportBeeStateMachine +import de.devin.cbbees.content.domain.beehive.BeeHive import de.devin.cbbees.content.domain.network.ServerBeeNetworkManager import de.devin.cbbees.content.domain.network.ClientBeeNetworkManager import de.devin.cbbees.content.domain.network.BeeNetwork +import de.devin.cbbees.content.domain.task.TransportTask import de.devin.cbbees.items.AllItems import net.minecraft.core.BlockPos import net.minecraft.nbt.CompoundTag @@ -26,9 +30,11 @@ import net.minecraft.world.entity.ai.attributes.Attributes import net.minecraft.world.entity.ai.control.FlyingMoveControl import net.minecraft.world.entity.ai.memory.MemoryModuleType import net.minecraft.world.entity.ai.navigation.PathNavigation +import net.minecraft.world.entity.item.ItemEntity import net.minecraft.world.entity.player.Player import net.minecraft.world.item.ItemStack import net.minecraft.world.level.Level +import net.minecraft.world.phys.Vec3 import software.bernie.geckolib.animatable.GeoEntity import software.bernie.geckolib.animatable.instance.AnimatableInstanceCache import software.bernie.geckolib.animation.AnimatableManager @@ -87,7 +93,16 @@ class MechanicalBumbleBeeEntity(entityType: EntityType, level override val homeId: UUID? get() = entityData.get(BEEHIVE_ID).getOrNull() override fun setHomeId(uuid: UUID) { entityData.set(BEEHIVE_ID, Optional.of(uuid)) } override fun beeItemStack(): ItemStack = ItemStack(AllItems.MECHANICAL_BUMBLE_BEE.get()) - override fun taskMemory(): MemoryModuleType<*> = BeeMemoryModules.TRANSPORT_TASK.get() + + // ── State machine fields ── + var beeState = TransportBeeState.FLYING_TO_SOURCE + var transportTask: TransportTask? = null + override var walkTargetPos: BlockPos? = null + override var hiveInstance: BeeHive? = null + override var hivePos: BlockPos? = null + override var returningToOwner: Player? = null + override var orphanedTicks: Int = 0 + override val stuckData = StuckCheckData() /** * Consumes spring tension for an action. BumbleBee uses flat rates (no BeeContext). @@ -129,9 +144,10 @@ class MechanicalBumbleBeeEntity(entityType: EntityType, level override fun customServerAiStep() { this.level().profiler.push("mechanicalBumbleBrain") - this.getBrain().tick(this.level() as ServerLevel, this) + TransportBeeStateMachine.tick( + this, this.level() as ServerLevel, (this.level() as ServerLevel).gameTime + ) this.level().profiler.pop() - super.customServerAiStep() } override fun getAnimatableInstanceCache(): AnimatableInstanceCache? { @@ -139,18 +155,16 @@ class MechanicalBumbleBeeEntity(entityType: EntityType, level } override fun brainProvider(): Brain.Provider<*> { - return MechanicalBumbleBrainProvider.brain() + @Suppress("UNCHECKED_CAST") + return Brain.provider( + listOf>(), + listOf>>() + ) as Brain.Provider } @Suppress("UNCHECKED_CAST") override fun makeBrain(dynamic: Dynamic<*>): Brain { - val brain = this.brainProvider().makeBrain(dynamic) - return MechanicalBumbleBrainProvider.makeBrain(brain as Brain) - } - - @Suppress("UNCHECKED_CAST") - override fun getBrain(): Brain { - return super.getBrain() as Brain + return this.brainProvider().makeBrain(dynamic) as Brain } override fun defineSynchedData(builder: SynchedEntityData.Builder) { @@ -163,7 +177,7 @@ class MechanicalBumbleBeeEntity(entityType: EntityType, level override fun createNavigation(level: Level): PathNavigation = MechanicalBeelike.createFlyingNavigation(this, level) - override fun travel(travelVector: net.minecraft.world.phys.Vec3) = + override fun travel(travelVector: Vec3) = MechanicalBeelike.travelFlying(this, travelVector) override fun remove(reason: RemovalReason) { @@ -174,28 +188,52 @@ class MechanicalBumbleBeeEntity(entityType: EntityType, level super.remove(reason) } + override fun onAddedToLevel() { + super.onAddedToLevel() + if (level().isClientSide) { + BeeClientTracker.onBeeAdded(this) + } + } + + override fun onRemovedFromLevel() { + super.onRemovedFromLevel() + if (level().isClientSide) { + BeeClientTracker.onBeeRemoved(this) + } + } + override fun tick() { super.tick() if (level().isClientSide) return - syncTargetPos() - if (rechargeFinishTick < 0) { - BeeSeparation.applyFlightOffset(this) + // Legacy entity bee from old save — drop as item and discard + val beeItem = beeItemStack() + level().addFreshEntity(ItemEntity(level(), x, y, z, beeItem)) + // Drop inventory items + for (i in 0 until inventory.containerSize) { + val stack = inventory.getItem(i) + if (!stack.isEmpty) { + level().addFreshEntity(ItemEntity(level(), x, y, z, stack.copy())) + inventory.setItem(i, ItemStack.EMPTY) + } } + discard() } + private var lastSyncedTargetPos: BlockPos? = null + private fun syncTargetPos() { - val brain = getBrain() - val walkTarget = brain.getMemory(MemoryModuleType.WALK_TARGET).orElse(null) - if (walkTarget != null) { - entityData.set(TARGET_POS, Optional.of(walkTarget.target.currentBlockPosition())) - } else { - entityData.set(TARGET_POS, Optional.empty()) + val newPos: BlockPos? = walkTargetPos + + if (newPos != lastSyncedTargetPos) { + lastSyncedTargetPos = newPos + entityData.set(TARGET_POS, if (newPos != null) Optional.of(newPos) else Optional.empty()) } } override fun getTargetPos(): BlockPos? = entityData.get(TARGET_POS).orElse(null) // Fly through water — no swimming, no water drag + override fun isNoGravity(): Boolean = true override fun isInWater(): Boolean = false @Deprecated("Overrides deprecated MC method", level = DeprecationLevel.WARNING) @@ -203,9 +241,9 @@ class MechanicalBumbleBeeEntity(entityType: EntityType, level override fun push(entity: Entity) { /* no-op */ } override fun doPush(entity: Entity) { /* no-op */ } - @Deprecated("Overrides deprecated MC method", level = DeprecationLevel.WARNING) override fun isPushable(): Boolean = false + override fun pushEntities() { /* no-op — skip expensive nearby entity scan */ } override fun addAdditionalSaveData(compound: CompoundTag) { super.addAdditionalSaveData(compound) diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/MechanicalBumbleBeeRenderer.kt b/src/main/kotlin/de/devin/cbbees/content/bee/MechanicalBumbleBeeRenderer.kt index db88259..f3ff5f3 100644 --- a/src/main/kotlin/de/devin/cbbees/content/bee/MechanicalBumbleBeeRenderer.kt +++ b/src/main/kotlin/de/devin/cbbees/content/bee/MechanicalBumbleBeeRenderer.kt @@ -1,5 +1,8 @@ package de.devin.cbbees.content.bee +import com.mojang.blaze3d.vertex.PoseStack +import net.minecraft.client.Minecraft +import net.minecraft.client.renderer.MultiBufferSource import net.minecraft.client.renderer.entity.EntityRendererProvider import software.bernie.geckolib.renderer.GeoEntityRenderer @@ -13,4 +16,21 @@ class MechanicalBumbleBeeRenderer(context: EntityRendererProvider.Context) : this.shadowRadius = 0.3f addRenderLayer(BumbleBeeCarriedItemLayer(this)) } + + override fun render( + entity: MechanicalBumbleBeeEntity, + entityYaw: Float, + partialTick: Float, + poseStack: PoseStack, + bufferSource: MultiBufferSource, + packedLight: Int + ) { + // Distance-based shadow culling + val cam = Minecraft.getInstance().gameRenderer.mainCamera.position + val distSq = entity.distanceToSqr(cam) + val shadowDist = de.devin.cbbees.config.CBBeesClientConfig.beeShadowDistance.get() + this.shadowRadius = if (distSq < shadowDist.toLong() * shadowDist) 0.3f else 0.0f + + super.render(entity, entityYaw, partialTick, poseStack, bufferSource, packedLight) + } } diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/brain/behavior/DepositAtTargetBehavior.kt b/src/main/kotlin/de/devin/cbbees/content/bee/brain/behavior/DepositAtTargetBehavior.kt index 7b33c15..fa45c0e 100644 --- a/src/main/kotlin/de/devin/cbbees/content/bee/brain/behavior/DepositAtTargetBehavior.kt +++ b/src/main/kotlin/de/devin/cbbees/content/bee/brain/behavior/DepositAtTargetBehavior.kt @@ -4,11 +4,13 @@ import de.devin.cbbees.config.CBBeesConfig import de.devin.cbbees.content.bee.MechanicalBumbleBeeEntity import de.devin.cbbees.content.bee.brain.BeeMemoryModules import de.devin.cbbees.content.bee.debug.BeeDebug +import de.devin.cbbees.content.domain.task.TransportTask import net.minecraft.server.level.ServerLevel import net.minecraft.world.entity.ai.behavior.Behavior import net.minecraft.world.entity.ai.memory.MemoryModuleType import net.minecraft.world.entity.ai.memory.MemoryStatus import net.minecraft.world.entity.item.ItemEntity +import net.minecraft.world.item.ItemStack /** * BumbleBee behavior: deposit carried items at the target (INSERT) port. @@ -35,42 +37,71 @@ class DepositAtTargetBehavior : Behavior( override fun start(level: ServerLevel, entity: MechanicalBumbleBeeEntity, gameTime: Long) { val task = entity.brain.getMemory(BeeMemoryModules.TRANSPORT_TASK.get()).get() val targetPos = task.targetPos + val isReturning = task.returningOverflow val network = entity.network() - val port = network?.transportPorts?.find { it.pos == targetPos && it.isValidRequester() } + // When returning overflow, targetPos is the original provider + val port = if (isReturning) { + network?.transportPortsByPos?.get(targetPos)?.takeIf { it.isValidProvider() } + } else { + network?.transportPortsByPos?.get(targetPos)?.takeIf { it.isValidRequester() } + } val items = entity.getInventoryContents().map { it.copy() } + val overflow = mutableListOf() if (port != null) { for (item in items) { val remainder = port.addItemStack(item) - if (!remainder.isEmpty) { - // Drop overflow on the ground near the port - val itemEntity = ItemEntity( - level, - targetPos.x + 0.5, - targetPos.y + 0.5, - targetPos.z + 0.5, - remainder - ) - level.addFreshEntity(itemEntity) - } entity.removeFromInventory(item, item.count) entity.consumeSpring(CBBeesConfig.springDrainDeposit.get()) - BeeDebug.logForEntity(entity, "Bumble", "Deposited ${item.count}x ${item.item} at target $targetPos") + + val deposited = item.count - (if (remainder.isEmpty) 0 else remainder.count) + if (deposited > 0) { + BeeDebug.logForEntity(entity, "Bumble", "Deposited ${deposited}x ${item.item} at $targetPos") + } + if (!remainder.isEmpty) { + overflow.add(remainder) + } } } else { - // No port available, drop on the ground - BeeDebug.logForEntity(entity, "Bumble", "Target port gone at $targetPos — dropping items") + BeeDebug.logForEntity(entity, "Bumble", "Port gone at $targetPos") for (item in items) { + overflow.add(item) entity.removeFromInventory(item, item.count) - val itemEntity = ItemEntity(level, entity.x, entity.y, entity.z, item) - level.addFreshEntity(itemEntity) } } - // Task complete — clear it so bee returns to hive - entity.brain.eraseMemory(BeeMemoryModules.TRANSPORT_TASK.get()) - entity.brain.eraseMemory(MemoryModuleType.WALK_TARGET) + if (overflow.isNotEmpty() && !isReturning) { + // Return overflow to the original source + BeeDebug.logForEntity(entity, "Bumble", "Returning ${overflow.sumOf { it.count }} overflow item(s) to source at ${task.sourcePos}") + for (item in overflow) { + val rem = entity.addToInventory(item) + if (!rem.isEmpty) { + // Bee inventory can't hold it all — drop excess + level.addFreshEntity(ItemEntity(level, entity.x, entity.y, entity.z, rem)) + } + } + entity.brain.setMemory( + BeeMemoryModules.TRANSPORT_TASK.get(), + TransportTask( + sourcePos = task.targetPos, + targetPos = task.sourcePos, + items = overflow, + returningOverflow = true + ) + ) + entity.brain.eraseMemory(MemoryModuleType.WALK_TARGET) + } else { + // Drop any remaining overflow as last resort (already tried returning, or was a return trip) + if (overflow.isNotEmpty()) { + BeeDebug.logForEntity(entity, "Bumble", "Cannot return overflow — dropping ${overflow.sumOf { it.count }} item(s)") + for (item in overflow) { + level.addFreshEntity(ItemEntity(level, entity.x, entity.y, entity.z, item)) + } + } + entity.brain.eraseMemory(BeeMemoryModules.TRANSPORT_TASK.get()) + entity.brain.eraseMemory(MemoryModuleType.WALK_TARGET) + } } } diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/brain/behavior/ExecuteTaskBehavior.kt b/src/main/kotlin/de/devin/cbbees/content/bee/brain/behavior/ExecuteTaskBehavior.kt index a15ba94..cb12368 100644 --- a/src/main/kotlin/de/devin/cbbees/content/bee/brain/behavior/ExecuteTaskBehavior.kt +++ b/src/main/kotlin/de/devin/cbbees/content/bee/brain/behavior/ExecuteTaskBehavior.kt @@ -21,7 +21,28 @@ class ExecuteTaskBehavior : Behavior( 1 ) { + companion object { + /** Global throttle: limits block operations across ALL bees per tick to prevent TPS drops. */ + private var operationsThisTick = 0 + private var lastThrottleTick = -1L + + fun canExecuteAction(gameTime: Long): Boolean { + if (gameTime != lastThrottleTick) { + lastThrottleTick = gameTime + operationsThisTick = 0 + } + return operationsThisTick < de.devin.cbbees.config.CBBeesConfig.maxBlockOperationsPerTick.get() + } + + fun recordAction() { + operationsThisTick++ + } + } + override fun checkExtraStartConditions(level: ServerLevel, owner: MechanicalBeeEntity): Boolean { + // Global throttle: too many block ops this tick → wait + if (!canExecuteAction(level.gameTime)) return false + val isDropOff = owner.brain.getMemory(BeeMemoryModules.CURRENT_TASK.get()).orElse(null) ?.getCurrentTask()?.action is DropOffItemsAction @@ -83,6 +104,7 @@ class ExecuteTaskBehavior : Behavior( BeeDebug.log(owner, "Executing: ${task.action.getDescription()}") + recordAction() val done = task.action.execute(level, owner, owner.getBeeContext()) if (done) { @@ -95,7 +117,7 @@ class ExecuteTaskBehavior : Behavior( task.complete() if (!batch.advance()) { - val nextBatch = hive.notifyTaskCompleted(task, owner) + val nextBatch = hive.notifyTaskCompleted(task, owner.uuid) if (nextBatch != null) { BeeDebug.log(owner, "Batch done — received next batch") @@ -112,7 +134,7 @@ class ExecuteTaskBehavior : Behavior( BeeDebug.log(owner, "Skipping drop-off (inventory empty)") nextTask.complete() if (!batch.advance()) { - val nextBatch = hive.notifyTaskCompleted(nextTask, owner) + val nextBatch = hive.notifyTaskCompleted(nextTask, owner.uuid) if (nextBatch != null) { owner.brain.setMemory(BeeMemoryModules.CURRENT_TASK.get(), nextBatch) } else { diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/brain/behavior/GatherItemsBehavior.kt b/src/main/kotlin/de/devin/cbbees/content/bee/brain/behavior/GatherItemsBehavior.kt index afca0af..ab0d69c 100644 --- a/src/main/kotlin/de/devin/cbbees/content/bee/brain/behavior/GatherItemsBehavior.kt +++ b/src/main/kotlin/de/devin/cbbees/content/bee/brain/behavior/GatherItemsBehavior.kt @@ -37,6 +37,20 @@ class GatherItemsBehavior : Behavior( 1 // Re-evaluate every tick to prevent MoveToTaskBehavior from hijacking the walk target ) { + // Tick-scoped cache to avoid recomputing missing items in both checkExtraStartConditions and start + private var cachedMissing: List? = null + private var cachedMissingTick: Long = -1 + private var cachedMissingBeeId: UUID? = null + + private fun getMissingItemsCached(bee: MechanicalBeeEntity, batch: TaskBatch, gameTime: Long): List { + if (gameTime == cachedMissingTick && bee.uuid == cachedMissingBeeId) return cachedMissing!! + val result = computeMissingItems(bee, batch) + cachedMissing = result + cachedMissingTick = gameTime + cachedMissingBeeId = bee.uuid + return result + } + override fun checkExtraStartConditions(level: ServerLevel, owner: MechanicalBeeEntity): Boolean { if (owner.springTension <= 0f) return false @@ -62,7 +76,7 @@ class GatherItemsBehavior : Behavior( } } - val missing = computeMissingItems(owner, batch) + val missing = getMissingItemsCached(owner, batch, level.gameTime) if (missing.isNotEmpty()) { BeeDebug.log(owner, "Gather: need ${missing.size} item type(s)") } @@ -71,7 +85,7 @@ class GatherItemsBehavior : Behavior( override fun start(level: ServerLevel, entity: MechanicalBeeEntity, gameTime: Long) { val batch = entity.brain.getMemory(BeeMemoryModules.CURRENT_TASK.get()).get() - val missing = computeMissingItems(entity, batch) + val missing = getMissingItemsCached(entity, batch, gameTime) if (missing.isEmpty()) return val network = entity.network() @@ -153,11 +167,14 @@ class GatherItemsBehavior : Behavior( } } - // 3. No provider found anywhere + // 3. No provider found anywhere — release batch so the bee enters REST and goes home. + // DropOffItemsBehavior will handle returning any carried items to a port or the player. BeeDebug.log(entity, "No providers for ${missing.size} missing item type(s) — releasing batch") network.releaseReservations(entity.uuid) batch.release(gameTick = gameTime) entity.brain.eraseMemory(BeeMemoryModules.CURRENT_TASK.get()) + // Clear walk target in case it was set to a destroyed port position + entity.brain.eraseMemory(MemoryModuleType.WALK_TARGET) } /** diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/brain/behavior/PickUpFromSourceBehavior.kt b/src/main/kotlin/de/devin/cbbees/content/bee/brain/behavior/PickUpFromSourceBehavior.kt index d35bc34..c069ea8 100644 --- a/src/main/kotlin/de/devin/cbbees/content/bee/brain/behavior/PickUpFromSourceBehavior.kt +++ b/src/main/kotlin/de/devin/cbbees/content/bee/brain/behavior/PickUpFromSourceBehavior.kt @@ -9,6 +9,7 @@ import net.minecraft.world.entity.ai.behavior.Behavior import net.minecraft.world.entity.ai.memory.MemoryModuleType import net.minecraft.world.entity.ai.memory.MemoryStatus import net.minecraft.world.entity.ai.memory.WalkTarget +import net.neoforged.neoforge.items.ItemHandlerHelper /** * BumbleBee behavior: fly to the source (EXTRACT) port and pick up items. @@ -42,7 +43,7 @@ class PickUpFromSourceBehavior : Behavior( return } - val port = network.transportPorts.find { it.pos == sourcePos && it.isValidProvider() } + val port = network.transportPortsByPos[sourcePos]?.takeIf { it.isValidProvider() } if (port == null) { BeeDebug.logForEntity(entity, "Bumble", "Source port gone at $sourcePos — clearing task") entity.brain.eraseMemory(BeeMemoryModules.TRANSPORT_TASK.get()) @@ -52,11 +53,31 @@ class PickUpFromSourceBehavior : Behavior( // Release reservation now that we're at the port port.releaseReservation(entity.uuid) + // Pre-check: how much the target can accept (best-effort, handles most overflow) + val targetPort = network.transportPortsByPos[task.targetPos]?.takeIf { it.isValidRequester() } + val targetHandler = targetPort?.getItemHandler(targetPort.world) + var pickedUp = false for (item in task.items) { if (entity.isInventoryFull()) break - if (port.hasItemStack(item) && port.removeItemStack(item)) { - val remainder = entity.addToInventory(item.copy()) + + // Determine how many items the target can accept + var toExtract = item + if (targetHandler != null) { + val simulated = ItemHandlerHelper.insertItemStacked(targetHandler, item.copy(), true) + val canAccept = item.count - simulated.count + if (canAccept <= 0) { + BeeDebug.logForEntity(entity, "Bumble", "Target full for ${item.item}, skipping") + continue + } + if (canAccept < item.count) { + toExtract = item.copyWithCount(canAccept) + BeeDebug.logForEntity(entity, "Bumble", "Target can accept ${canAccept}/${item.count} ${item.item}") + } + } + + if (port.hasItemStack(toExtract) && port.removeItemStack(toExtract)) { + val remainder = entity.addToInventory(toExtract.copy()) if (!remainder.isEmpty) { port.addItemStack(remainder) } @@ -65,7 +86,7 @@ class PickUpFromSourceBehavior : Behavior( BeeDebug.logForEntity( entity, "Bumble", - "Picked up ${item.count}x ${item.item} from source at $sourcePos" + "Picked up ${toExtract.count}x ${toExtract.item} from source at $sourcePos" ) } } diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/brain/behavior/StuckSafetyBehavior.kt b/src/main/kotlin/de/devin/cbbees/content/bee/brain/behavior/StuckSafetyBehavior.kt index 322bd63..2513662 100644 --- a/src/main/kotlin/de/devin/cbbees/content/bee/brain/behavior/StuckSafetyBehavior.kt +++ b/src/main/kotlin/de/devin/cbbees/content/bee/brain/behavior/StuckSafetyBehavior.kt @@ -1,5 +1,6 @@ package de.devin.cbbees.content.bee.brain.behavior +import de.devin.cbbees.content.bee.BeePathNavigation import de.devin.cbbees.content.bee.MechanicalBeelike import de.devin.cbbees.content.bee.debug.BeeDebug import net.minecraft.core.BlockPos @@ -58,6 +59,10 @@ class StuckSafetyBehavior : Behavior( if (progress < MIN_PROGRESS) { failedChecks++ + // Try rerouting before escalating to teleport + if (failedChecks < MAX_FAILS) { + owner.navigation.recomputePath() + } } else { failedChecks = 0 } diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/client/BeeClientTracker.kt b/src/main/kotlin/de/devin/cbbees/content/bee/client/BeeClientTracker.kt new file mode 100644 index 0000000..9c1ca0c --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/bee/client/BeeClientTracker.kt @@ -0,0 +1,84 @@ +package de.devin.cbbees.content.bee.client + +import de.devin.cbbees.content.bee.NetworkedBee +import de.devin.cbbees.content.bee.flight.ClientBeeFlightData +import de.devin.cbbees.util.ClientSide +import java.util.UUID + +/** + * Client-side registry of all bees for rendering. + * + * Tracks: + * - Legacy entity bees via [onBeeAdded]/[onBeeRemoved] (migration) + * - Checkpoint-based bees via [applyFlightPlan]/[removeFlightData] + * + * @see ClientBeeFlightData + * @see de.devin.cbbees.network.BeeFlightPlanPacket + */ +@ClientSide +object BeeClientTracker { + + private val entityBees = mutableSetOf() + private val flightBees = mutableMapOf() + + // ── Legacy entity tracking ── + + fun onBeeAdded(bee: NetworkedBee) { entityBees.add(bee) } + fun onBeeRemoved(bee: NetworkedBee) { entityBees.remove(bee) } + fun getBees(): Set = entityBees + + // ── Checkpoint flight data ── + + /** Apply a new or updated flight plan from the server. */ + fun applyFlightPlan(data: ClientBeeFlightData) { + flightBees[data.id] = data + } + + /** Remove a bee immediately (entered hive, discarded). */ + fun removeFlightData(id: UUID) { + flightBees.remove(id) + } + + /** Server confirmed a checkpoint action completed — unblock the bee's flight. */ + fun confirmCheckpoint(beeId: UUID, checkpointIndex: Int) { + flightBees[beeId]?.confirmCheckpoint(checkpointIndex) + } + + /** All active checkpoint-based bees. */ + fun getFlightBees(): Collection = flightBees.values + + /** Notify all bees of pause state change for wall-clock freeze. */ + fun setPaused(paused: Boolean) { + flightBees.values.forEach { it.setPaused(paused) } + } + + /** Called every client tick for rotation smoothing. */ + fun tickClient() { + flightBees.values.forEach { it.tickClient() } + flightBees.values.removeAll { it.isComplete && it.id !in previewIds } + } + + // ── Legacy position-based data (kept for backward compat during migration) ── + + fun applySync(bees: List) { /* no-op — replaced by flight plans */ } + fun getDataBees(): Collection = emptyList() + + // ── Preview bees (spawned via /cbbees preview) ── + + private val previewIds = mutableSetOf() + + /** Mark a bee as a preview so it isn't auto-removed when its flight completes. */ + fun addPreviewId(id: UUID) { previewIds.add(id) } + + /** Remove all preview bees. */ + fun clearPreviews() { + previewIds.forEach { flightBees.remove(it) } + previewIds.clear() + } + + fun clear() { + entityBees.clear() + flightBees.clear() + previewIds.clear() + } +} diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/client/BeeTargetLineHandler.kt b/src/main/kotlin/de/devin/cbbees/content/bee/client/BeeTargetLineHandler.kt index a8e69ab..7a5088f 100644 --- a/src/main/kotlin/de/devin/cbbees/content/bee/client/BeeTargetLineHandler.kt +++ b/src/main/kotlin/de/devin/cbbees/content/bee/client/BeeTargetLineHandler.kt @@ -2,8 +2,6 @@ package de.devin.cbbees.content.bee.client import com.simibubi.create.content.equipment.goggles.GogglesItem import de.devin.cbbees.config.CBBeesClientConfig -import de.devin.cbbees.content.bee.MechanicalBeeEntity -import de.devin.cbbees.content.bee.MechanicalBumbleBeeEntity import de.devin.cbbees.content.bee.NetworkedBee import de.devin.cbbees.util.ClientSide import net.createmod.catnip.outliner.Outliner @@ -23,6 +21,7 @@ import net.neoforged.neoforge.client.event.ClientTickEvent object BeeTargetLineHandler { private const val LINE_COLOR = 0xFFD700 // Gold + private const val MAX_DIST_SQ = 64.0 * 64.0 // 64-block radius /** Set by [de.devin.cbbees.network.BeeDebugSyncPacket] from the server. */ @JvmStatic @@ -31,42 +30,44 @@ object BeeTargetLineHandler { @SubscribeEvent @JvmStatic fun onClientTick(event: ClientTickEvent.Post) { + // Update pause state for wall-clock freeze, tick rotation smoothing + val paused = Minecraft.getInstance().isPaused + BeeClientTracker.setPaused(paused) + if (!paused) BeeClientTracker.tickClient() + if (!CBBeesClientConfig.showBeeTargetLines.get()) return val mc = Minecraft.getInstance() val player = mc.player ?: return - val level = mc.level ?: return + mc.level ?: return if (mc.screen != null) return if (!GogglesItem.isWearingGoggles(player)) return - val searchBox = player.boundingBox.inflate(64.0) val lookedAtEntity = mc.crosshairPickEntity + val playerPos = player.position() - // Scan both bee types - processEntities(level.getEntitiesOfClass(MechanicalBeeEntity::class.java, searchBox), lookedAtEntity) - processEntities(level.getEntitiesOfClass(MechanicalBumbleBeeEntity::class.java, searchBox), lookedAtEntity) - } + // Use the tracked bee set instead of scanning all entities + for (bee in BeeClientTracker.getBees()) { + val entity = bee as? Entity ?: continue + if (entity.distanceToSqr(playerPos) > MAX_DIST_SQ) continue - private fun processEntities(bees: List, lookedAtEntity: Entity?) where T : Entity, T : NetworkedBee { - for (bee in bees) { val target = bee.getTargetPos() ?: continue + if (!debugEnabled && entity != lookedAtEntity) continue - if (!debugEnabled && bee != lookedAtEntity) continue - - val start = bee.position().add(0.0, (bee.bbHeight / 2).toDouble(), 0.0) + val start = entity.position().add(0.0, (entity.bbHeight / 2).toDouble(), 0.0) val end = Vec3.atCenterOf(target) val network = bee.network() val color = network?.color ?: LINE_COLOR Outliner.getInstance() - .showLine("bee_target_${bee.id}", start, end) + .showLine("bee_target_${entity.id}", start, end) .colored(color) .lineWidth(1 / 16f) Outliner.getInstance() - .chaseAABB("bee_target_block_${bee.id}", AABB(target)) + .chaseAABB("bee_target_block_${entity.id}", AABB(target)) .colored(color) .lineWidth(1 / 16f) } diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/client/BeeWorldRenderer.kt b/src/main/kotlin/de/devin/cbbees/content/bee/client/BeeWorldRenderer.kt new file mode 100644 index 0000000..bc3b7f3 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/bee/client/BeeWorldRenderer.kt @@ -0,0 +1,177 @@ +package de.devin.cbbees.content.bee.client + +import com.mojang.blaze3d.vertex.PoseStack +import com.mojang.blaze3d.vertex.VertexConsumer +import com.mojang.math.Axis +import de.devin.cbbees.CreateBuzzyBeez +import de.devin.cbbees.content.bee.server.BeeType +import de.devin.cbbees.util.ClientSide +import net.minecraft.client.Minecraft +import net.minecraft.client.renderer.MultiBufferSource +import net.minecraft.client.renderer.RenderType +import net.minecraft.client.renderer.texture.OverlayTexture +import net.minecraft.resources.ResourceLocation +import net.neoforged.bus.api.SubscribeEvent +import net.neoforged.neoforge.client.event.RenderLevelStageEvent +import software.bernie.geckolib.cache.GeckoLibCache +import software.bernie.geckolib.cache.`object`.BakedGeoModel +import software.bernie.geckolib.cache.`object`.GeoBone +import kotlin.math.sin + +/** + * Renders non-entity bees during [RenderLevelStageEvent]. + * + * Uses GeckoLib's baked model cache for geometry and applies wing animation + * via PoseStack transforms (doesn't mutate the shared baked model). + */ +@ClientSide +object BeeWorldRenderer { + + private val BEE_MODEL = CreateBuzzyBeez.asResource("geo/mechanical_bee.geo.json") + private val BEE_TEXTURE = CreateBuzzyBeez.asResource("textures/entity/mechanical_bee.png") + private val BUMBLE_MODEL = CreateBuzzyBeez.asResource("geo/mechanical_bumble_bee.geo.json") + private val BUMBLE_TEXTURE = CreateBuzzyBeez.asResource("textures/entity/mechanical_bumble_bee.png") + + private val WING_BONES = setOf("lwing", "rwing") + private const val LIGHT_BONE = "light" + private const val LIGHT_ALPHA = 0.45f + + private const val MAX_RENDER_DIST_SQ = 64.0 * 64.0 + private const val FULL_BRIGHT_LIGHT = 0xF000F0 + private const val WING_FLAP_SPEED = 25f + private const val WING_FLAP_AMPLITUDE = 0.6f + + @SubscribeEvent + @JvmStatic + fun onRenderLevel(event: RenderLevelStageEvent) { + if (event.stage != RenderLevelStageEvent.Stage.AFTER_ENTITIES) return + + val mc = Minecraft.getInstance() + mc.level ?: return + val camPos = mc.gameRenderer.mainCamera.position + val partialTick = event.partialTick.realtimeDeltaTicks + + val poseStack = event.poseStack + val bufferSource = mc.renderBuffers().bufferSource() + + val flightBees = BeeClientTracker.getFlightBees() + if (flightBees.isEmpty()) return + + val maxDistSq = MAX_RENDER_DIST_SQ + val time = System.nanoTime() / 1_000_000_000.0f + + flightBees.forEach { bee -> + val pos = bee.lerpPos(partialTick) + val dx = pos.x - camPos.x + val dy = pos.y - camPos.y + val dz = pos.z - camPos.z + if (dx * dx + dy * dy + dz * dz > maxDistSq) return@forEach + + val (modelRes, textureRes) = when (bee.type) { + BeeType.CONSTRUCTION -> BEE_MODEL to BEE_TEXTURE + BeeType.TRANSPORT -> BUMBLE_MODEL to BUMBLE_TEXTURE + } + + poseStack.pushPose() + poseStack.translate(dx, dy, dz) + poseStack.mulPose(Axis.YP.rotationDegrees(180f - bee.yRot())) + + renderModel(poseStack, bufferSource, modelRes, textureRes, time) + + poseStack.popPose() + } + + bufferSource.endBatch() + } + + private fun renderModel( + poseStack: PoseStack, + bufferSource: MultiBufferSource, + modelRes: ResourceLocation, + textureRes: ResourceLocation, + time: Float, + ) { + val bakedModel: BakedGeoModel = GeckoLibCache.getBakedModels()[modelRes] ?: return + val opaqueBuffer = bufferSource.getBuffer(RenderType.entityCutoutNoCull(textureRes)) + val packedLight = FULL_BRIGHT_LIGHT + + // Wing flap oscillation — same for both bee and bumble bee models + val wingAngle = sin(time * WING_FLAP_SPEED) * WING_FLAP_AMPLITUDE + + // Opaque pass — everything except the light bone + bakedModel.topLevelBones.forEach { bone -> + if (bone.name != LIGHT_BONE) { + renderBone(poseStack, opaqueBuffer, bone, packedLight, wingAngle, 1f) + } + } + + // Translucent pass — light bone only (if present) + bakedModel.topLevelBones.find { it.name == LIGHT_BONE }?.let { lightBone -> + val translucentBuffer = bufferSource.getBuffer(RenderType.entityTranslucent(textureRes)) + renderBone(poseStack, translucentBuffer, lightBone, packedLight, wingAngle, LIGHT_ALPHA) + } + } + + private fun renderBone( + poseStack: PoseStack, + buffer: VertexConsumer, + bone: GeoBone, + packedLight: Int, + wingAngle: Float, + alpha: Float, + ) { + if (bone.isHidden) return + + poseStack.pushPose() + + // Mirror the pivot X for left wing — both wings share pivot [-3, 7, -2] + // in the model, but lwing's cube is on the positive X side + val rawPx = bone.pivotX / 16f + val px = if (bone.name == "lwing") -rawPx else rawPx + val py = bone.pivotY / 16f + val pz = bone.pivotZ / 16f + + poseStack.translate(px.toDouble(), py.toDouble(), pz.toDouble()) + + // Apply stored rotation from the model + if (bone.rotX != 0f || bone.rotY != 0f || bone.rotZ != 0f) { + poseStack.mulPose(Axis.ZP.rotation(bone.rotZ)) + poseStack.mulPose(Axis.YP.rotation(bone.rotY)) + poseStack.mulPose(Axis.XP.rotation(bone.rotX)) + } + + // Wing flap — lwing and rwing mirror each other on Z axis + if (bone.name in WING_BONES) { + val sign = if (bone.name == "lwing") 1f else -1f + poseStack.mulPose(Axis.ZP.rotation(wingAngle * sign)) + } + + poseStack.translate(-px.toDouble(), -py.toDouble(), -pz.toDouble()) + + // Render cubes + val isWing = bone.name in WING_BONES + val matrix = poseStack.last() + bone.cubes.forEach { cube -> + cube.quads().forEach { quad -> + val normal = quad.normal() + // Wings are zero-height planes — skip the bottom face to prevent z-fighting + if (isWing && normal.y() < 0) return@forEach + quad.vertices().forEach { vertex -> + buffer.addVertex(matrix.pose(), vertex.position().x(), vertex.position().y(), vertex.position().z()) + .setColor(1f, 1f, 1f, alpha) + .setUv(vertex.texU(), vertex.texV()) + .setOverlay(OverlayTexture.NO_OVERLAY) + .setLight(packedLight) + .setNormal(matrix, normal.x(), normal.y(), normal.z()) + } + } + } + + // Recurse children + bone.childBones.forEach { child -> + renderBone(poseStack, buffer, child, packedLight, wingAngle, alpha) + } + + poseStack.popPose() + } +} diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/client/ClientBeeData.kt b/src/main/kotlin/de/devin/cbbees/content/bee/client/ClientBeeData.kt new file mode 100644 index 0000000..141637f --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/bee/client/ClientBeeData.kt @@ -0,0 +1,77 @@ +package de.devin.cbbees.content.bee.client + +import de.devin.cbbees.content.bee.server.BeeType +import net.minecraft.world.phys.Vec3 +import java.util.UUID + +/** + * Client-side bee representation for rendering. Updated by [de.devin.cbbees.network.BeeSyncPacket]. + * + * Uses velocity-based prediction between sync updates for smooth visual movement. + * On each sync, calculates velocity from position delta and extrapolates forward. + */ +data class ClientBeeData( + val id: UUID, + val type: BeeType, + var x: Double, + var y: Double, + var z: Double, + var yRot: Float, + var hasItem: Boolean, +) { + /** Previous synced position (for velocity calculation). */ + private var prevSyncX: Double = x + private var prevSyncY: Double = y + private var prevSyncZ: Double = z + + /** Estimated velocity (blocks per tick). */ + var velX: Double = 0.0; private set + var velY: Double = 0.0; private set + var velZ: Double = 0.0; private set + + /** Render position — updated every frame with velocity prediction. */ + var renderX: Double = x; private set + var renderY: Double = y; private set + var renderZ: Double = z; private set + private var prevRenderX: Double = x + private var prevRenderY: Double = y + private var prevRenderZ: Double = z + + var lastUpdateTick: Long = 0 + + /** Interpolated position for rendering with sub-tick smoothness. */ + fun lerpPos(partialTick: Float) = Vec3( + prevRenderX + (renderX - prevRenderX) * partialTick, + prevRenderY + (renderY - prevRenderY) * partialTick, + prevRenderZ + (renderZ - prevRenderZ) * partialTick, + ) + + /** Called when a sync packet arrives with new authoritative position. */ + fun applyUpdate(newX: Double, newY: Double, newZ: Double, newYRot: Float, newHasItem: Boolean, tick: Long) { + // Calculate velocity from position delta (sync arrives every 2 ticks) + velX = (newX - prevSyncX) * 0.5 + velY = (newY - prevSyncY) * 0.5 + velZ = (newZ - prevSyncZ) * 0.5 + + prevSyncX = x; prevSyncY = y; prevSyncZ = z + x = newX; y = newY; z = newZ + yRot = newYRot + hasItem = newHasItem + lastUpdateTick = tick + + // Snap render position to the authoritative position + renderX = newX; renderY = newY; renderZ = newZ + } + + /** Called every client tick to predict position between sync updates. */ + fun tickClient() { + prevRenderX = renderX + prevRenderY = renderY + prevRenderZ = renderZ + + // Extrapolate using velocity + renderX += velX + renderY += velY + renderZ += velZ + } +} diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/debug/BeeDebug.kt b/src/main/kotlin/de/devin/cbbees/content/bee/debug/BeeDebug.kt index 73024ed..8931e83 100644 --- a/src/main/kotlin/de/devin/cbbees/content/bee/debug/BeeDebug.kt +++ b/src/main/kotlin/de/devin/cbbees/content/bee/debug/BeeDebug.kt @@ -61,4 +61,25 @@ object BeeDebug { } } } + + /** + * Sends a debug message about a block at a given position to all nearby debug-enabled players. + */ + fun logAtPos(level: net.minecraft.world.level.Level, pos: net.minecraft.core.BlockPos, label: String, message: String) { + if (enabledPlayers.isEmpty()) return + if (level.isClientSide) return + + val server = level.server ?: return + + val text = Component.literal("[$label ${pos.x},${pos.y},${pos.z}] ") + .withStyle(ChatFormatting.AQUA) + .append(Component.literal(message).withStyle(ChatFormatting.GRAY)) + + for (uuid in enabledPlayers) { + val player = server.playerList.getPlayer(uuid) ?: continue + if (player.level() == level && player.blockPosition().closerThan(pos, 128.0)) { + player.sendSystemMessage(text) + } + } + } } diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/debug/BeeDebugCommand.kt b/src/main/kotlin/de/devin/cbbees/content/bee/debug/BeeDebugCommand.kt index 25b793c..8b464df 100644 --- a/src/main/kotlin/de/devin/cbbees/content/bee/debug/BeeDebugCommand.kt +++ b/src/main/kotlin/de/devin/cbbees/content/bee/debug/BeeDebugCommand.kt @@ -1,11 +1,22 @@ package de.devin.cbbees.content.bee.debug import com.mojang.brigadier.CommandDispatcher +import com.mojang.brigadier.arguments.StringArgumentType +import de.devin.cbbees.content.bee.client.BeeClientTracker +import de.devin.cbbees.content.bee.flight.ClientBeeFlightData +import de.devin.cbbees.content.bee.flight.ClientCheckpoint +import de.devin.cbbees.content.bee.server.BeeType +import de.devin.cbbees.content.domain.job.JobCalculationProgress +import de.devin.cbbees.network.JobProgressPacket +import de.devin.cbbees.util.ServerTickScheduler import net.minecraft.ChatFormatting import net.minecraft.commands.CommandSourceStack import net.minecraft.commands.Commands +import net.minecraft.core.BlockPos import net.minecraft.network.chat.Component import net.minecraft.server.level.ServerPlayer +import net.neoforged.neoforge.network.PacketDistributor +import java.util.UUID object BeeDebugCommand { @@ -25,6 +36,132 @@ object BeeDebugCommand { 1 } ) + .then( + Commands.literal("preview") + .then( + Commands.argument("type", StringArgumentType.word()) + .suggests { _, builder -> + BeeType.entries.forEach { builder.suggest(it.name.lowercase()) } + builder.buildFuture() + } + .executes { ctx -> + val typeName = StringArgumentType.getString(ctx, "type") + val beeType = BeeType.entries.find { it.name.equals(typeName, ignoreCase = true) } + if (beeType == null) { + ctx.source.sendFailure(Component.literal("Unknown bee type: $typeName")) + return@executes 0 + } + spawnPreviewBee(ctx.source, beeType) + ctx.source.sendSuccess({ + Component.literal("Spawned ${beeType.name.lowercase()} preview") + .withStyle(ChatFormatting.GREEN) + }, false) + 1 + } + ) + .executes { ctx -> + spawnPreviewBee(ctx.source, BeeType.CONSTRUCTION) + ctx.source.sendSuccess({ + Component.literal("Spawned construction bee preview") + .withStyle(ChatFormatting.GREEN) + }, false) + 1 + } + ) + .then( + Commands.literal("clearpreview") + .executes { ctx -> + BeeClientTracker.clearPreviews() + ctx.source.sendSuccess({ + Component.literal("Cleared all preview bees").withStyle(ChatFormatting.YELLOW) + }, false) + 1 + } + ) + .then( + Commands.literal("toast") + .executes { ctx -> + simulateToast(ctx.source) + ctx.source.sendSuccess({ + Component.literal("Playing toast animation").withStyle(ChatFormatting.GREEN) + }, false) + 1 + } + ) + ) + } + + private fun spawnPreviewBee(source: CommandSourceStack, type: BeeType) { + val player = source.playerOrException + val look = player.lookAngle + val center = player.eyePosition.add(look.scale(4.0)) + val pos = BlockPos.containing(center) + + // Small orbit: 4 waypoints in a 2-block square with long pauses so the bee circles slowly + val checkpoints = listOf( + ClientCheckpoint(pos.offset(1, 0, 1), pauseTicks = 20), + ClientCheckpoint(pos.offset(-1, 0, 1), pauseTicks = 20), + ClientCheckpoint(pos.offset(-1, 0, -1), pauseTicks = 20), + ClientCheckpoint(pos.offset(1, 0, -1), pauseTicks = 20), + ClientCheckpoint(pos.offset(1, 0, 1), pauseTicks = 20), + ClientCheckpoint(pos.offset(-1, 0, 1), pauseTicks = 20), + ClientCheckpoint(pos.offset(-1, 0, -1), pauseTicks = 20), + ClientCheckpoint(pos.offset(1, 0, -1), pauseTicks = 20), + ClientCheckpoint(pos.offset(1, 0, 1), pauseTicks = 20), + ClientCheckpoint(pos.offset(-1, 0, 1), pauseTicks = 20), + ) + + val id = UUID.randomUUID() + val flightData = ClientBeeFlightData( + id = id, + type = type, + speed = 0.15f, + checkpoints = checkpoints, ) + + BeeClientTracker.applyFlightPlan(flightData) + BeeClientTracker.addPreviewId(id) + } + + private fun simulateToast(source: CommandSourceStack) { + val player = source.playerOrException as ServerPlayer + val jobId = UUID.randomUUID() + val expectedBlocks = 10_000 + val totalSteps = 20 + val blocksPerStep = expectedBlocks / totalSteps + + // STARTED + PacketDistributor.sendToPlayer(player, JobProgressPacket( + jobId, JobCalculationProgress.Phase.STARTED, + "cbbees.progress.processing_schematic", 0, expectedBlocks, + )) + + // Schedule progress updates over ~2 seconds (1 per tick) + for (step in 1..totalSteps) { + val processed = (blocksPerStep * step).coerceAtMost(expectedBlocks) + val isLast = step == totalSteps + + // Delay each step by N ticks + var remaining = step + fun schedule(action: () -> Unit) { + if (remaining <= 0) { action(); return } + remaining-- + ServerTickScheduler.nextTick { schedule(action) } + } + schedule { + if (isLast) { + PacketDistributor.sendToPlayer(player, JobProgressPacket( + jobId, JobCalculationProgress.Phase.COMPLETED, + "cbbees.progress.processing_schematic", expectedBlocks, expectedBlocks, + "cbbees.construction.started", 234, + )) + } else { + PacketDistributor.sendToPlayer(player, JobProgressPacket( + jobId, JobCalculationProgress.Phase.IN_PROGRESS, + "cbbees.progress.processing_schematic", processed, expectedBlocks, + )) + } + } + } } } diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/flight/CheckpointActions.kt b/src/main/kotlin/de/devin/cbbees/content/bee/flight/CheckpointActions.kt new file mode 100644 index 0000000..4006f71 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/bee/flight/CheckpointActions.kt @@ -0,0 +1,320 @@ +package de.devin.cbbees.content.bee.flight + +import de.devin.cbbees.config.CBBeesConfig +import de.devin.cbbees.content.bee.server.BeeType +import de.devin.cbbees.content.bee.server.ServerBeeData +import de.devin.cbbees.content.bee.server.ServerBeeManager +import de.devin.cbbees.content.beehive.MechanicalBeehiveBlockEntity +import de.devin.cbbees.content.domain.GlobalJobPool +import de.devin.cbbees.content.domain.action.BeeAction +import de.devin.cbbees.content.domain.action.ItemConsumingAction +import de.devin.cbbees.content.domain.action.impl.RemoveBlockAction +import de.devin.cbbees.content.domain.beehive.PortableBeeHive +import de.devin.cbbees.content.domain.logistics.LogisticsPort +import de.devin.cbbees.content.domain.task.BeeTask +import de.devin.cbbees.content.domain.task.TransportTask +import de.devin.cbbees.items.AllItems as CBeesItems +import net.minecraft.core.BlockPos +import net.minecraft.server.level.ServerLevel +import net.minecraft.world.entity.item.ItemEntity +import net.minecraft.world.item.ItemStack + +// ════════════════════════════════════════════════════════════════════════════════ +// Composable CheckpointAction implementations +// Each is a standalone class — add new ones freely without modifying any enum. +// ════════════════════════════════════════════════════════════════════════════════ + +/** + * Fly-through waypoint — always advances immediately. + * Used for spawn points or intermediate routing waypoints. + * + * @see CheckpointAction + */ +object FlyThrough : CheckpointAction { + override fun onArrival(bee: ServerBeeData, level: ServerLevel, gameTime: Long) = true +} + +/** + * Pick up items from a logistics port. Validates the port still exists and has stock. + * Returns `false` if the port was destroyed (triggers flight plan recomputation). + * + * @param items the items this bee needs to pick up + * @see CheckpointAction + */ +class GatherFromPort(private val items: List) : CheckpointAction { + + override fun onArrival(bee: ServerBeeData, level: ServerLevel, gameTime: Long): Boolean { + val network = bee.network() ?: return false + + items.forEach { item -> + if (bee.isInventoryFull()) return@forEach + val searchStack = item.copyWithCount(1) + val provider = network.findAvailableProvider(searchStack, bee.id) ?: return@forEach + if (provider is PortableBeeHive) return@forEach + + if (provider.hasItemStack(item) && provider.removeItemStack(item)) { + val remainder = bee.addToInventory(item.copy()) + if (!remainder.isEmpty) provider.addItemStack(remainder) + bee.consumeSpring(CBBeesConfig.springDrainPickup.get()) + } + } + return true + } +} + +/** + * Execute a [BeeAction] (place block, break block, etc.) and advance the [task] in the batch. + * Respects the global block-operations-per-tick throttle. + * + * After the action completes, marks the task as completed and advances the batch. + * This drives job progress tracking — without it, jobs never finish. + * + * @param beeAction the action to execute + * @param task the task in the batch (for completion tracking) + * @see CheckpointAction + * @see de.devin.cbbees.content.domain.task.TaskBatch + */ +class ExecuteBeeAction( + private val beeAction: BeeAction, + private val task: BeeTask, +) : CheckpointAction { + private var activated = false + + override fun onArrival(bee: ServerBeeData, level: ServerLevel, gameTime: Long): Boolean { + // Call onActivate once when first arriving (resolves dynamic targets like DropOff ports) + if (!activated) { + beeAction.onActivate(bee) + activated = true + } + + if (!ActionThrottle.canExecute(gameTime)) return false + + ActionThrottle.record() + val done = beeAction.execute(level, bee, bee.getBeeContext()) + + if (done) { + val drain = if (beeAction is RemoveBlockAction) CBBeesConfig.springDrainBreak.get() + else CBBeesConfig.springDrainPlace.get() + bee.consumeSpring(drain) + + // Mark the task as completed and advance the batch + task.complete() + bee.currentTask?.advance() + } + return done + } +} + +/** Global per-tick throttle for block operations across all bees. */ +object ActionThrottle { + private var opsThisTick = 0 + private var lastTick = -1L + + fun canExecute(gameTime: Long): Boolean { + if (gameTime != lastTick) { lastTick = gameTime; opsThisTick = 0 } + return opsThisTick < CBBeesConfig.maxBlockOperationsPerTick.get() + } + + fun record() { opsThisTick++ } +} + +/** + * At the hive: check for more work before entering. + * + * If a new [TaskBatch] is available from [GlobalJobPool.workBacklog], assigns it and + * computes a new flight plan — the bee continues working without entering the hive. + * If no work is available, the bee proceeds to enter the hive. + * + * This is what keeps bees busy: they only return home when there's truly nothing left to do. + * + * @see EnterHive + * @see CheckpointAction + */ +class CheckForNextWork : CheckpointAction { + + override fun onArrival(bee: ServerBeeData, level: ServerLevel, gameTime: Long): Boolean { + val hive = bee.hiveInstance ?: return true // no hive → proceed to EnterHive + + // Ask the job pool for more work + val nextBatch = GlobalJobPool.workBacklog(hive) + if (nextBatch != null) { + // Assign new batch and recompute flight plan + nextBatch.assignToBee(bee.id, gameTime) + bee.currentTask = nextBatch + + val network = bee.network() + if (network != null) { + // Compute a new plan starting from the hive + FlightPlanComputer.computeAsync( + bee, nextBatch, network, level + ) { plan -> + bee.flightPlan = plan + bee.planStartTick = level.gameTime + bee.currentCheckpointIndex = 0 + // Skip FlyThrough at index 0, start flying to index 1 + if (plan.checkpoints.size > 1) { + val travel = FlightPlan.travelTicks( + plan.checkpoints[0].pos, plan.checkpoints[1].pos, plan.speed + ) + bee.currentCheckpointIndex = 1 + bee.nextCheckpointArrivalTick = level.gameTime + travel + } + // Client starts at 0 so it renders the full flight from current pos + ServerBeeManager.broadcastFlightPlan(bee, plan, clientStartIndex = 0) + } + return true // advance past this checkpoint (plan will be replaced async) + } + } + + return true // no work found → proceed to EnterHive (next checkpoint) + } +} + +/** + * Return the bee to its hive. Charges return fuel, adds the bee item back + * to the hive inventory, and removes the bee from [ServerBeeManager]. + * + * Placed after [CheckForNextWork] in the flight plan — only reached when + * there's no more work available. + * + * @see CheckForNextWork + * @see CheckpointAction + */ +class EnterHive : CheckpointAction { + + override fun onArrival(bee: ServerBeeData, level: ServerLevel, gameTime: Long): Boolean { + val hive = bee.hiveInstance ?: return false + + val deficit = 1.0f - bee.springTension + hive.chargeReturnFuel(deficit, bee.getBeeContext()) + + val beeItem = ItemStack( + if (bee.type == BeeType.CONSTRUCTION) + CBeesItems.MECHANICAL_BEE.get() + else + CBeesItems.MECHANICAL_BUMBLE_BEE.get() + ) + + if (hive.returnBee(beeItem)) { + (hive as? MechanicalBeehiveBlockEntity)?.onBeeRemovedById(bee.id) + ServerBeeManager.removeBee(bee.id) + return true + } + + // Hive full — drop as item + bee.dropInventory() + level.addFreshEntity(ItemEntity(level, bee.pos.x, bee.pos.y, bee.pos.z, beeItem)) + (hive as? MechanicalBeehiveBlockEntity)?.onBeeRemovedById(bee.id) + ServerBeeManager.removeBee(bee.id) + return true + } +} + +/** + * Recharge the bee's spring tension at the hive. + * Pauses for a calculated duration based on the hive's RPM and bee context. + * + * @see CheckpointAction + */ +class RechargeSpring : CheckpointAction { + private var finishTick: Long = -1 + + override fun onArrival(bee: ServerBeeData, level: ServerLevel, gameTime: Long): Boolean { + if (finishTick < 0) { + val hive = bee.hiveInstance ?: return true // skip if no hive + val ctx = bee.getBeeContext() + finishTick = gameTime + hive.rechargeSpring(ctx) + } + if (gameTime >= finishTick) { + bee.springTension = 1.0f + finishTick = -1 + return true + } + return false + } +} + +/** + * Bumble bee: pick up items from the source transport port. + * + * @param task the transport task defining source position and items + * @see CheckpointAction + */ +class PickupTransport(private val task: TransportTask) : CheckpointAction { + + override fun onArrival(bee: ServerBeeData, level: ServerLevel, gameTime: Long): Boolean { + val network = bee.network() ?: return false + val port = network.transportPortsByPos[task.sourcePos]?.takeIf { it.isValidProvider() } ?: return false + + port.releaseReservation(bee.id) + + var pickedUp = false + task.items.forEach { item -> + if (bee.isInventoryFull()) return@forEach + if (port.hasItemStack(item) && port.removeItemStack(item)) { + val rem = bee.addToInventory(item.copy()) + if (!rem.isEmpty) port.addItemStack(rem) + pickedUp = true + bee.consumeSpring(CBBeesConfig.springDrainPickup.get()) + } + } + return pickedUp + } +} + +/** + * Bumble bee: deposit items at the target transport port. + * + * @param task the transport task defining target position + * @see CheckpointAction + */ +class DepositTransport(private val task: TransportTask) : CheckpointAction { + + override fun onArrival(bee: ServerBeeData, level: ServerLevel, gameTime: Long): Boolean { + val network = bee.network() + val port = network?.transportPortsByPos?.get(task.targetPos)?.takeIf { it.isValidRequester() } + + bee.getInventoryContents().forEach { item -> + if (port != null) { + val remainder = port.addItemStack(item.copy()) + bee.removeFromInventory(item, item.count) + bee.consumeSpring(CBBeesConfig.springDrainDeposit.get()) + if (!remainder.isEmpty) { + level.addFreshEntity(ItemEntity(level, bee.pos.x, bee.pos.y, bee.pos.z, remainder)) + } + } else { + bee.removeFromInventory(item, item.count) + level.addFreshEntity(ItemEntity(level, bee.pos.x, bee.pos.y, bee.pos.z, item.copy())) + } + } + return true + } +} + +/** + * Drop off excess items at a logistics port or on the ground. + * + * @see CheckpointAction + */ +class DropOffItems : CheckpointAction { + + override fun onArrival(bee: ServerBeeData, level: ServerLevel, gameTime: Long): Boolean { + val contents = bee.getInventoryContents() + if (contents.isEmpty()) return true + + val port = bee.network()?.findDropOff(contents.first()) + contents.forEach { item -> + if (port != null) { + val remainder = port.addItemStack(item.copy()) + bee.removeFromInventory(item, item.count) + if (!remainder.isEmpty) { + level.addFreshEntity(ItemEntity(level, bee.pos.x, bee.pos.y, bee.pos.z, remainder)) + } + } else { + bee.removeFromInventory(item, item.count) + level.addFreshEntity(ItemEntity(level, bee.pos.x, bee.pos.y, bee.pos.z, item.copy())) + } + } + return true + } +} diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/flight/ClientBeeFlightData.kt b/src/main/kotlin/de/devin/cbbees/content/bee/flight/ClientBeeFlightData.kt new file mode 100644 index 0000000..26d98a7 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/bee/flight/ClientBeeFlightData.kt @@ -0,0 +1,232 @@ +package de.devin.cbbees.content.bee.flight + +import de.devin.cbbees.content.bee.server.BeeType +import net.minecraft.core.BlockPos +import net.minecraft.world.phys.Vec3 +import java.util.UUID +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin + +/** + * Client-side autonomous bee flight data. + * + * Position is computed directly from [System.nanoTime] at render time for + * perfectly smooth movement at any frame rate and TPS. The clock pauses + * when the game is paused (tracked via [paused] flag set by [setPaused]). + * + * @see FlightPlan + */ +class ClientBeeFlightData( + val id: UUID, + val type: BeeType, + val speed: Float, + val checkpoints: List, + startIndex: Int = 0, + /** Unused — kept for packet compat. Sync uses startIndex instead. */ + elapsedNanoOffset: Long = 0, +) { + private val positions: List = checkpoints.map { Vec3.atCenterOf(it.pos) } + + private val arrivalNanos: LongArray = LongArray(checkpoints.size).also { arr -> + var cumulative = 0L + arr[0] = 0 + for (i in 1 until checkpoints.size) { + val travelTicks = FlightPlan.travelTicks(checkpoints[i - 1].pos, checkpoints[i].pos, speed) + cumulative += (travelTicks + checkpoints[i - 1].pauseTicks) * NANOS_PER_TICK + arr[i] = cumulative + } + } + + /** + * Wall-clock start time, shifted so the client begins at [startIndex]. + * The client starts its timer as if it had been flying since checkpoint 0, + * but with the clock already advanced to the arrival time of [startIndex]. + * This means the bee visually appears at checkpoint [startIndex] and flies forward. + */ + private var startNano: Long = System.nanoTime() - arrivalNanos.getOrElse(startIndex) { 0L } + + /** Accumulated pause duration to subtract from elapsed time. */ + private var pauseOffsetNano: Long = 0 + + /** nanoTime when the game was last paused. */ + private var pauseStartNano: Long = 0 + + /** Whether the game is currently paused. */ + private var isPaused = false + + // ── Checkpoint confirmation wait ── + /** Sorted indices of checkpoints that require server confirmation before advancing. */ + private val actionCheckpointIndices: IntArray = checkpoints.indices + .filter { checkpoints[it].awaitConfirm } + .toIntArray() + /** Pointer into [actionCheckpointIndices] — the next action checkpoint to check. */ + private var nextActionPtr: Int = 0 + /** Checkpoint index we're currently blocked at, or -1 if flying freely. */ + private var waitingAtIdx: Int = -1 + /** nanoTime when we started waiting at the current checkpoint. */ + private var waitStartNano: Long = 0L + /** Total accumulated wait time across all confirmed checkpoints. */ + private var totalWaitNano: Long = 0L + /** Confirmations received before the client reached the checkpoint (race condition buffer). */ + private val earlyConfirms = mutableSetOf() + + private var currentYRot: Float = 0f + + private val separationAngle = (id.hashCode() * 2654435761L and 0xFFFF).toDouble() / 0xFFFF * Math.PI * 2 + private val wobblePhase = (id.leastSignificantBits * 2246822519L and 0xFFFF).toDouble() / 0xFFFF * Math.PI * 2 + private val ySeparationPhase = (id.mostSignificantBits * 1597334677L and 0xFF).toDouble() / 0xFF + + val isComplete: Boolean get() = + checkpoints.size < 2 || elapsedNano() >= arrivalNanos.last() + + /** Call when game pauses/unpauses. */ + fun setPaused(paused: Boolean) { + if (paused && !isPaused) { + pauseStartNano = System.nanoTime() + } else if (!paused && isPaused) { + pauseOffsetNano += System.nanoTime() - pauseStartNano + } + isPaused = paused + } + + /** + * Server confirmed a checkpoint action completed — unblock the bee's flight. + * If the client hasn't reached this checkpoint yet, the confirmation is buffered + * in [earlyConfirms] and the checkpoint is skipped (no wait) when reached. + */ + fun confirmCheckpoint(index: Int) { + if (waitingAtIdx == index) { + // Currently blocked here — unblock immediately + val now = if (isPaused) pauseStartNano else System.nanoTime() + totalWaitNano += now - waitStartNano + waitingAtIdx = -1 + } else { + // Not there yet — buffer so we don't block when we arrive + earlyConfirms.add(index) + } + } + + /** Smooth rotation update — call every client tick. */ + fun tickClient() { + currentYRot = lerpAngle(currentYRot, computeSegmentYRot(), 0.25f) + } + + /** + * Elapsed flight time in nanos, excluding paused and wait periods. + * If the bee has reached an unconfirmed action checkpoint, the returned + * value is clamped at that checkpoint's arrival time until [confirmCheckpoint] + * is called. + */ + private fun elapsedNano(): Long { + val now = if (isPaused) pauseStartNano else System.nanoTime() + + // Currently blocked at a checkpoint waiting for server confirmation + if (waitingAtIdx >= 0) { + return arrivalNanos[waitingAtIdx] + } + + val elapsed = now - startNano - pauseOffsetNano - totalWaitNano + + // Check if we've reached the next action checkpoint that needs confirmation + while (nextActionPtr < actionCheckpointIndices.size) { + val cpIdx = actionCheckpointIndices[nextActionPtr] + if (elapsed < arrivalNanos[cpIdx]) break // haven't reached it yet + nextActionPtr++ + // If the server already confirmed this checkpoint (arrived before us), skip it + if (earlyConfirms.remove(cpIdx)) continue + // Block here until the server confirms + waitingAtIdx = cpIdx + waitStartNano = now + return arrivalNanos[cpIdx] + } + + return elapsed + } + + /** + * Exact position at this instant. Called every render frame. + * Reads [System.nanoTime] directly — smooth at any FPS/TPS. + */ + fun lerpPos(@Suppress("UNUSED_PARAMETER") partialTick: Float): Vec3 { + if (checkpoints.size < 2) return positions.firstOrNull() ?: Vec3.ZERO + return computePositionAtNano(elapsedNano()) + } + + fun yRot(): Float = currentYRot + + // ── Position computation ── + + private fun computePositionAtNano(nano: Long): Vec3 { + val (segIndex, segStartNano, segEndNano) = findSegment(nano) + if (segIndex >= positions.size - 1) return positions.last().add(flightOffset(nano)).add(separationOffset()) + + val pauseNano = checkpoints[segIndex].pauseTicks.toLong() * NANOS_PER_TICK + val travelStartNano = segStartNano + pauseNano + val travelDuration = (segEndNano - travelStartNano).coerceAtLeast(1) + + val rawT = if (nano <= travelStartNano) 0f + else ((nano - travelStartNano).toFloat() / travelDuration).coerceIn(0f, 1f) + + val t = easeIn(rawT).toDouble() + + return positions[segIndex].lerp(positions[segIndex + 1], t) + .add(flightOffset(nano)) + .add(separationOffset()) + } + + private fun findSegment(nano: Long): Triple { + for (i in 1 until arrivalNanos.size) { + if (nano < arrivalNanos[i]) return Triple(i - 1, arrivalNanos[i - 1], arrivalNanos[i]) + } + val last = arrivalNanos.size - 1 + return Triple((last - 1).coerceAtLeast(0), arrivalNanos[(last - 1).coerceAtLeast(0)], arrivalNanos[last]) + } + + private fun computeSegmentYRot(): Float { + val (segIndex, _, _) = findSegment(elapsedNano()) + val from = positions.getOrNull(segIndex) ?: return currentYRot + val to = positions.getOrNull(segIndex + 1) ?: return currentYRot + val dx = to.x - from.x + val dz = to.z - from.z + return if (dx * dx + dz * dz < 0.01) currentYRot + else (atan2(-dx, dz) * (180.0 / Math.PI)).toFloat() + } + + private fun flightOffset(nano: Long): Vec3 { + val t = nano / 1_000_000_000.0 + val bobY = sin(t * 2.8 + separationAngle) * 0.07 + val wobbleX = sin(t * 1.3 + wobblePhase) * 0.12 + sin(t * 3.7 + separationAngle) * 0.04 + val wobbleZ = cos(t * 1.7 + wobblePhase) * 0.10 + cos(t * 4.1 + separationAngle) * 0.03 + return Vec3(wobbleX, bobY, wobbleZ) + } + + private fun separationOffset() = Vec3( + cos(separationAngle) * 0.4, + ySeparationPhase * 0.6 - 0.3, + sin(separationAngle) * 0.4, + ) + + private fun easeIn(t: Float): Float { + val c = t.coerceIn(0f, 1f) + return c * 0.7f + (c * c) * 0.3f + } + + companion object { + private const val NANOS_PER_TICK = 50_000_000L + + private fun lerpAngle(from: Float, to: Float, factor: Float): Float { + var delta = (to - from) % 360f + if (delta > 180f) delta -= 360f + if (delta < -180f) delta += 360f + return from + delta * factor + } + } +} + +data class ClientCheckpoint( + val pos: BlockPos, + val pauseTicks: Int = 0, + /** If true, the client holds the bee at this checkpoint until the server confirms completion. */ + val awaitConfirm: Boolean = false, +) diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/flight/FlightPlan.kt b/src/main/kotlin/de/devin/cbbees/content/bee/flight/FlightPlan.kt new file mode 100644 index 0000000..9700449 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/bee/flight/FlightPlan.kt @@ -0,0 +1,119 @@ +package de.devin.cbbees.content.bee.flight + +import de.devin.cbbees.content.bee.server.BeeType +import de.devin.cbbees.content.bee.server.ServerBeeData +import net.minecraft.core.BlockPos +import net.minecraft.server.level.ServerLevel +import net.minecraft.world.phys.Vec3 +import java.util.UUID + +/** + * A bee's full mission represented as an ordered list of [Checkpoint] waypoints. + * + * The flight plan is computed once when a bee is assigned a task (or recomputed when + * a checkpoint fails validation). Both server and client advance through the same + * checkpoints deterministically — the server executes [CheckpointAction]s on arrival, + * while the client just interpolates position between checkpoint positions. + * + * ## Lifecycle + * 1. Bee spawns → [FlightPlanComputer] builds a plan from the task batch + * 2. Plan sent to client via `BeeFlightPlanPacket` + * 3. Server advances checkpoints by timing (distance / speed) + * 4. On arrival, the [Checkpoint.action] validates and executes + * 5. If action fails (e.g., port destroyed), [FlightPlanComputer] replans from current position + * 6. Plan complete → bee enters hive → `BeeRemovePacket` sent to client + * + * @see FlightPlanComputer + * @see CheckpointAction + * @see Checkpoint + */ +data class FlightPlan( + val beeId: UUID, + val type: BeeType, + val speed: Float, + val checkpoints: List, +) { + /** Estimated total flight ticks (sum of distances / speed + pause ticks). */ + val estimatedDurationTicks: Long + get() = checkpoints.zipWithNext().sumOf { (a, b) -> + travelTicks(a.pos, b.pos, speed) + a.clientPauseTicks + } + + /** + * Pre-computes cumulative arrival ticks for each checkpoint. + * Used by both server (for checkpoint advancement) and client (for position interpolation). + * Guarantees identical timing on both sides. + */ + fun computeArrivalTicks(): LongArray = LongArray(checkpoints.size).also { arr -> + var cumulative = 0L + arr[0] = 0 + for (i in 1 until checkpoints.size) { + val travel = travelTicks(checkpoints[i - 1].pos, checkpoints[i].pos, speed) + cumulative += travel + checkpoints[i - 1].clientPauseTicks + arr[i] = cumulative + } + } + + companion object { + /** + * Euclidean travel time between two block positions at the given speed. + * Single source of truth — used by server, client, and duration estimates. + */ + fun travelTicks(from: BlockPos, to: BlockPos, speed: Float): Long { + val dist = Vec3.atCenterOf(from).distanceTo(Vec3.atCenterOf(to)) + return (dist / speed).toLong().coerceAtLeast(1) + } + } +} + +/** + * A single waypoint in a [FlightPlan]. + * + * The bee flies toward [pos] at the plan's speed. On arrival, [action] is invoked + * server-side to perform the checkpoint's work (pick up items, place a block, enter + * the hive, etc.). The client only sees [pos] and [clientPauseTicks] — it doesn't + * need the action logic. + * + * @see CheckpointAction + * @see FlightPlan + */ +data class Checkpoint( + val pos: BlockPos, + val action: CheckpointAction, + /** How long the client should visually pause at this checkpoint (ticks). */ + val clientPauseTicks: Int = 0, +) + +/** + * Composable action executed when a bee arrives at a [Checkpoint]. + * + * Each checkpoint type is a standalone class implementing this interface — no enum, + * no central switch statement. Adding new checkpoint behaviors is just creating a new + * class. The server calls [onArrival] each tick while the bee is at the checkpoint. + * + * ## Built-in implementations + * - [FlyThrough] — pass-through waypoint, always succeeds + * - [GatherFromPort] — pick up items from a logistics port + * - [ExecuteBeeAction] — run a [de.devin.cbbees.content.domain.action.BeeAction] with global throttle + * - [EnterHive] — return the bee to its hive + * - [RechargeSpring] — wait at hive until spring is full + * - [PickupTransport] / [DepositTransport] — bumble bee cargo operations + * + * ## Return value contract + * - `true` → checkpoint complete, bee advances to next waypoint + * - `false` → not done yet, will be called again next tick (throttle, recharge timer, etc.) + * + * @see Checkpoint + * @see FlightPlanComputer + */ +fun interface CheckpointAction { + /** + * Called each server tick while the bee is at this checkpoint. + * + * @param bee the bee's server-side data + * @param level the server level + * @param gameTime current game tick + * @return `true` to advance to the next checkpoint, `false` to retry next tick + */ + fun onArrival(bee: ServerBeeData, level: ServerLevel, gameTime: Long): Boolean +} diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/flight/FlightPlanComputer.kt b/src/main/kotlin/de/devin/cbbees/content/bee/flight/FlightPlanComputer.kt new file mode 100644 index 0000000..bc7e2a3 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/bee/flight/FlightPlanComputer.kt @@ -0,0 +1,385 @@ +package de.devin.cbbees.content.bee.flight + +import de.devin.cbbees.content.bee.flight.FlightPlanComputer.computeAsync +import de.devin.cbbees.content.bee.flight.FlightPlanComputer.computeTransportAsync +import de.devin.cbbees.content.bee.flight.FlightPlanComputer.forConstruction +import de.devin.cbbees.content.bee.server.BeeType +import de.devin.cbbees.content.bee.server.ServerBeeData +import de.devin.cbbees.content.domain.action.ItemConsumingAction +import de.devin.cbbees.content.domain.action.impl.DropOffItemsAction +import de.devin.cbbees.content.domain.action.impl.RemoveBlockAction +import de.devin.cbbees.content.domain.beehive.PortableBeeHive +import de.devin.cbbees.content.domain.network.BeeNetwork +import de.devin.cbbees.content.domain.task.TaskBatch +import de.devin.cbbees.content.domain.task.TransportTask +import de.devin.cbbees.util.ItemStackKey +import de.devin.cbbees.util.ServerTickScheduler +import net.minecraft.core.BlockPos +import net.minecraft.server.level.ServerLevel +import net.minecraft.world.item.ItemStack +import kotlin.math.abs +import kotlin.math.max + +/** + * Computes [FlightPlan]s for bees based on their assigned tasks. + * + * The plan is a sequence of [Checkpoint]s the bee must visit. Each checkpoint carries + * a composable [CheckpointAction] that executes server-side on arrival. The client + * receives only the positions and pause durations for smooth autonomous interpolation. + * + * Plans are computed synchronously on the server thread (fast — just building a list). + * If needed, could be moved to a background thread since it only reads immutable task data. + * + * @see FlightPlan + * @see CheckpointAction + */ +object FlightPlanComputer { + + private const val DEFAULT_SPEED = 0.35f + private const val GATHER_PAUSE_TICKS = 5 + private const val PICKUP_PAUSE_TICKS = 5 + private const val DEPOSIT_PAUSE_TICKS = 5 + private const val DROP_OFF_PAUSE_TICKS = 5 + + private val executor = java.util.concurrent.Executors.newSingleThreadExecutor { r -> + Thread(r, "BeeFlightPlanner").apply { isDaemon = true } + } + + /** + * Computes a construction flight plan on a background thread. + * + * Block state reads for obstacle avoidance are snapshotted on the server thread + * before dispatching to the worker. The [onComplete] callback is invoked on the + * server thread via [ServerLevel.getServer]. + * + * @see forConstruction + */ + fun computeAsync( + bee: ServerBeeData, + batch: TaskBatch, + network: BeeNetwork, + level: ServerLevel, + onComplete: (FlightPlan) -> Unit, + ) { + // Snapshot: build raw checkpoints on the server thread (reads network/task data) + val rawCheckpoints = buildRawConstructionCheckpoints(bee, batch, network) + + // Snapshot: collect all block positions that need collision checks + val collisionSnapshot = snapshotCollisions(rawCheckpoints.map { it.pos }, level) + + executor.submit { + // Off-thread: insert obstacle waypoints using the snapshot (no Level access needed) + val finalCheckpoints = insertObstacleWaypointsFromSnapshot(rawCheckpoints, collisionSnapshot, level) + + val plan = FlightPlan(bee.id, BeeType.CONSTRUCTION, DEFAULT_SPEED, finalCheckpoints) + + // Deliver result back on the server thread. server.execute drains + // during runAllTasks() at the start of the next tick — before our + // ServerTickScheduler and before ServerBeeManager.tickAll(), so the + // flight plan is set before the bee advances any checkpoints. + level.server.execute { onComplete(plan) } + } + } + + /** + * Computes a transport flight plan on a background thread. + */ + fun computeTransportAsync( + bee: ServerBeeData, + task: TransportTask, + level: ServerLevel, + onComplete: (FlightPlan) -> Unit, + ) { + val rawCheckpoints = buildRawTransportCheckpoints(bee, task) + val collisionSnapshot = snapshotCollisions(rawCheckpoints.map { it.pos }, level) + + executor.submit { + val finalCheckpoints = insertObstacleWaypointsFromSnapshot(rawCheckpoints, collisionSnapshot, level) + val plan = FlightPlan(bee.id, BeeType.TRANSPORT, DEFAULT_SPEED, finalCheckpoints) + level.server.execute { onComplete(plan) } + } + } + + /** + * Snapshots block collision state along all straight-line segments between positions. + * Called on the server thread. Returns an immutable map safe to read from any thread. + */ + private fun snapshotCollisions(positions: List, level: ServerLevel): Map { + val snapshot = mutableMapOf() + + positions.zipWithNext().forEach { (from, to) -> + val dx = to.x - from.x + val dy = to.y - from.y + val dz = to.z - from.z + val steps = max(max(abs(dx), abs(dy)), abs(dz)).coerceAtLeast(1) + + for (i in 0..steps) { + val t = i.toFloat() / steps + val pos = BlockPos( + from.x + (dx * t).toInt(), + from.y + (dy * t).toInt(), + from.z + (dz * t).toInt(), + ) + if (pos !in snapshot && level.isLoaded(pos)) { + snapshot[pos] = !level.getBlockState(pos).getCollisionShape(level, pos).isEmpty + } + } + + // Also snapshot blocks above potential obstructions for routing + snapshot.filter { it.value }.keys.toList().forEach { blocked -> + for (dy2 in 1..MAX_FLY_OVER_HEIGHT) { + val above = blocked.above(dy2) + if (above !in snapshot && level.isLoaded(above)) { + snapshot[above] = !level.getBlockState(above).getCollisionShape(level, above).isEmpty + } + } + } + } + + return snapshot + } + + /** + * Builds a construction flight plan synchronously (with optional obstacle avoidance). + * Prefer [computeAsync] for production use. + */ + fun forConstruction( + bee: ServerBeeData, + batch: TaskBatch, + network: BeeNetwork, + level: ServerLevel? = null + ): FlightPlan { + val raw = buildRawConstructionCheckpoints(bee, batch, network) + val checkpoints = if (level != null) insertObstacleWaypoints(raw, level) else raw + return FlightPlan(bee.id, BeeType.CONSTRUCTION, DEFAULT_SPEED, checkpoints) + } + + /** + * Builds a transport flight plan synchronously (with optional obstacle avoidance). + * Prefer [computeTransportAsync] for production use. + */ + fun forTransport(bee: ServerBeeData, task: TransportTask, level: ServerLevel? = null): FlightPlan { + val raw = buildRawTransportCheckpoints(bee, task) + val checkpoints = if (level != null) insertObstacleWaypoints(raw, level) else raw + return FlightPlan(bee.id, BeeType.TRANSPORT, DEFAULT_SPEED, checkpoints) + } + + private fun buildRawConstructionCheckpoints(bee: ServerBeeData, batch: TaskBatch, network: BeeNetwork) = buildList { + add(Checkpoint(bee.blockPosition(), FlyThrough)) + val missing = computeMissingItems(bee, batch) + if (missing.isNotEmpty()) { + findBestProvider(network, missing, bee.id)?.let { + add(Checkpoint(it.pos.above(), GatherFromPort(missing), clientPauseTicks = GATHER_PAUSE_TICKS)) + } + } + val remainingTasks = batch.getRemainingTasks() + remainingTasks.forEach { task -> + val action = task.action + when (action) { + is DropOffItemsAction -> { + val dropPort = network.findDropOff(ItemStack.EMPTY) + val dropPos = dropPort?.pos?.above() ?: task.targetPos + add(Checkpoint(dropPos, ExecuteBeeAction(action, task), clientPauseTicks = DROP_OFF_PAUSE_TICKS)) + } + + is RemoveBlockAction -> { + add( + Checkpoint( + task.targetPos, + ExecuteBeeAction(action, task), + clientPauseTicks = action.getWorkTicks(bee.getBeeContext()) + ) + ) + } + + else -> { + add(Checkpoint(task.targetPos, ExecuteBeeAction(action, task))) + } + } + } + // Check for next batch RIGHT AFTER the last task — not at the hive. + // If work exists, the bee replans from here (the construction site) without flying home. + val lastTaskPos = remainingTasks.lastOrNull()?.targetPos ?: bee.blockPosition() + add(Checkpoint(lastTaskPos, CheckForNextWork())) + // Only reached if no more work — fly home and enter + val hiveApproach = (bee.hivePos ?: bee.blockPosition()).above() + add(Checkpoint(hiveApproach, EnterHive())) + } + + private fun buildRawTransportCheckpoints(bee: ServerBeeData, task: TransportTask) = buildList { + add(Checkpoint(bee.blockPosition(), FlyThrough)) + add(Checkpoint(task.sourcePos.above(), PickupTransport(task), clientPauseTicks = PICKUP_PAUSE_TICKS)) + add(Checkpoint(task.targetPos.above(), DepositTransport(task), clientPauseTicks = DEPOSIT_PAUSE_TICKS)) + val hiveApproach = (bee.hivePos ?: bee.blockPosition()).above() + add(Checkpoint(hiveApproach, EnterHive())) + } + + /** + * Recomputes a flight plan from the bee's current position. + * Used when a checkpoint fails validation (e.g., port destroyed). + */ + fun replanFrom( + bee: ServerBeeData, + batch: TaskBatch?, + network: BeeNetwork?, + level: ServerLevel? = null + ): FlightPlan? { + if (network == null) return null + return when (bee.type) { + BeeType.CONSTRUCTION -> batch?.let { forConstruction(bee, it, network, level) } + BeeType.TRANSPORT -> bee.transportTask?.let { forTransport(bee, it, level) } + } + } + + // ── Obstacle Avoidance (snapshot-based, thread-safe) ── + + /** + * Inserts waypoints using a pre-snapshotted collision map. Safe to call from any thread. + */ + @Suppress("UNUSED_PARAMETER") + private fun insertObstacleWaypointsFromSnapshot( + checkpoints: List, + collisionSnapshot: Map, + level: ServerLevel, // unused but kept for signature compat + ): List = buildList { + checkpoints.forEachIndexed { index, checkpoint -> + add(checkpoint) + val next = checkpoints.getOrNull(index + 1) ?: return@forEachIndexed + findObstacleWaypointFromSnapshot(checkpoint.pos, next.pos, collisionSnapshot)?.let { + add(Checkpoint(it, FlyThrough)) + } + } + } + + private fun findObstacleWaypointFromSnapshot( + from: BlockPos, + to: BlockPos, + snapshot: Map + ): BlockPos? { + val dx = to.x - from.x; + val dy = to.y - from.y; + val dz = to.z - from.z + val steps = max(max(abs(dx), abs(dy)), abs(dz)).coerceAtLeast(1) + var highestObstruction: BlockPos? = null + + for (i in 1 until steps) { + val t = i.toFloat() / steps + val pos = BlockPos(from.x + (dx * t).toInt(), from.y + (dy * t).toInt(), from.z + (dz * t).toInt()) + if (snapshot[pos] == true) { + if (highestObstruction == null || pos.y > highestObstruction.y) highestObstruction = pos + } + } + + if (highestObstruction == null) return null + + for (dy2 in 1..MAX_FLY_OVER_HEIGHT) { + val above = highestObstruction.above(dy2) + if (snapshot[above] != true) { + return BlockPos((from.x + to.x) / 2, above.y, (from.z + to.z) / 2) + } + } + return BlockPos((from.x + to.x) / 2, highestObstruction.y + MAX_FLY_OVER_HEIGHT, (from.z + to.z) / 2) + } + + // ── Obstacle Avoidance (synchronous, uses Level directly) ── + + /** Max height to fly above obstructions. */ + private const val MAX_FLY_OVER_HEIGHT = 10 + + /** + * Inserts [FlyThrough] waypoints between checkpoints to route around solid blocks. + * + * For each pair of consecutive checkpoints, does a simple block-by-block raycast. + * If any block along the line is solid, inserts a waypoint above the obstruction. + * Cheap: at most ~50 block checks per segment (typical flight distance). + */ + private fun insertObstacleWaypoints(checkpoints: List, level: ServerLevel): List = + buildList { + checkpoints.forEachIndexed { index, checkpoint -> + add(checkpoint) + val next = checkpoints.getOrNull(index + 1) ?: return@forEachIndexed + + val waypoint = findObstacleWaypoint(checkpoint.pos, next.pos, level) + if (waypoint != null) { + add(Checkpoint(waypoint, FlyThrough)) + } + } + } + + /** + * Raycasts from [from] to [to] checking for solid blocks. + * If an obstruction is found, returns a waypoint above it. Otherwise null. + */ + private fun findObstacleWaypoint(from: BlockPos, to: BlockPos, level: ServerLevel): BlockPos? { + val dx = to.x - from.x + val dy = to.y - from.y + val dz = to.z - from.z + val steps = max(max(abs(dx), abs(dy)), abs(dz)).coerceAtLeast(1) + + var highestObstruction: BlockPos? = null + + for (i in 1 until steps) { + val t = i.toFloat() / steps + val checkPos = BlockPos( + from.x + (dx * t).toInt(), + from.y + (dy * t).toInt(), + from.z + (dz * t).toInt(), + ) + + if (!level.isLoaded(checkPos)) continue + if (!level.getBlockState(checkPos).getCollisionShape(level, checkPos).isEmpty) { + // Found an obstruction — track the highest one + if (highestObstruction == null || checkPos.y > highestObstruction.y) { + highestObstruction = checkPos + } + } + } + + if (highestObstruction == null) return null + + // Route above the obstruction + for (dy2 in 1..MAX_FLY_OVER_HEIGHT) { + val above = highestObstruction.above(dy2) + if (level.isLoaded(above) && level.getBlockState(above).getCollisionShape(level, above).isEmpty) { + // Waypoint at the midpoint X/Z but above the obstruction + val midX = (from.x + to.x) / 2 + val midZ = (from.z + to.z) / 2 + return BlockPos(midX, above.y, midZ) + } + } + + // Can't find clear space — fly very high + return BlockPos((from.x + to.x) / 2, highestObstruction.y + MAX_FLY_OVER_HEIGHT, (from.z + to.z) / 2) + } + + // ── Helpers ── + + private fun computeMissingItems(bee: ServerBeeData, batch: TaskBatch): List { + val totalRequired = mutableMapOf() + batch.getRemainingTasks().forEach { task -> + (task.action as? ItemConsumingAction)?.requiredItems?.forEach { req -> + val key = ItemStackKey(req) + totalRequired[key] = (totalRequired[key] ?: 0) + req.count + } + } + bee.getInventoryContents().forEach { carried -> + val key = ItemStackKey(carried) + totalRequired[key]?.let { needed -> + val remaining = needed - carried.count + if (remaining <= 0) totalRequired.remove(key) + else totalRequired[key] = remaining + } + } + return totalRequired.map { (key, count) -> key.stack.copy().also { it.count = count } } + } + + private fun findBestProvider( + network: BeeNetwork, + missing: List, + beeId: java.util.UUID, + ): de.devin.cbbees.content.domain.logistics.LogisticsPort? { + val first = missing.firstOrNull() ?: return null + val provider = network.findAvailableProvider(first.copyWithCount(1), beeId) ?: return null + return if (provider is PortableBeeHive) null else provider + } +} diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/server/BeeWorker.kt b/src/main/kotlin/de/devin/cbbees/content/bee/server/BeeWorker.kt new file mode 100644 index 0000000..b391bfe --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/bee/server/BeeWorker.kt @@ -0,0 +1,49 @@ +package de.devin.cbbees.content.bee.server + +import de.devin.cbbees.content.upgrades.BeeContext +import net.minecraft.core.BlockPos +import net.minecraft.world.entity.player.Player +import net.minecraft.world.item.ItemStack +import net.minecraft.world.level.Level +import java.util.UUID + +/** + * Minimal interface for bee-world interaction, used by [de.devin.cbbees.content.domain.action.BeeAction]. + * + * Implemented by both [ServerBeeData] (non-entity system) and [de.devin.cbbees.content.bee.MechanicalBeeEntity] + * (legacy migration). Actions only need inventory access, position, and spring management. + */ +interface BeeWorker { + val uuid: UUID + val networkId: UUID + fun blockPosition(): BlockPos + fun level(): Level + + fun addToInventory(stack: ItemStack): ItemStack + fun removeFromInventory(stack: ItemStack, count: Int) + fun getInventoryContents(): List + fun isInventoryFull(): Boolean + fun isInventoryEmpty(): Boolean + + fun consumeSpring(baseDrain: Double): Boolean + fun getBeeContext(): BeeContext + + /** Returns the owner player (for portable beehive bees), or null. */ + fun getOwnerPlayer(): Player? + + /** Look up this bee's network. */ + fun network(): de.devin.cbbees.content.domain.network.BeeNetwork? + + /** Drop all inventory contents as items on the ground. */ + fun dropInventory() { + val level = level() + getInventoryContents().forEach { item -> + removeFromInventory(item, item.count) + level.addFreshEntity(net.minecraft.world.entity.item.ItemEntity(level, getWorkerX(), getWorkerY(), getWorkerZ(), item.copy())) + } + } + + fun getWorkerX(): Double + fun getWorkerY(): Double + fun getWorkerZ(): Double +} diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/server/ServerBeeData.kt b/src/main/kotlin/de/devin/cbbees/content/bee/server/ServerBeeData.kt new file mode 100644 index 0000000..cd032ea --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/bee/server/ServerBeeData.kt @@ -0,0 +1,271 @@ +package de.devin.cbbees.content.bee.server + +import de.devin.cbbees.content.bee.flight.FlightPlan +import de.devin.cbbees.content.bee.state.ConstructionBeeState +import de.devin.cbbees.content.bee.state.StuckCheckData +import de.devin.cbbees.content.bee.state.TransportBeeState +import de.devin.cbbees.content.domain.beehive.BeeHive +import de.devin.cbbees.content.domain.network.BeeNetwork +import de.devin.cbbees.content.domain.network.ServerBeeNetworkManager +import de.devin.cbbees.content.domain.task.TaskBatch +import de.devin.cbbees.content.domain.task.TransportTask +import de.devin.cbbees.content.upgrades.BeeContext +import net.minecraft.core.BlockPos +import net.minecraft.core.HolderLookup +import net.minecraft.nbt.CompoundTag +import net.minecraft.nbt.ListTag +import net.minecraft.nbt.Tag +import net.minecraft.server.level.ServerLevel +import net.minecraft.world.SimpleContainer +import net.minecraft.world.entity.player.Player +import net.minecraft.world.item.ItemStack +import net.minecraft.world.level.Level +import net.minecraft.world.phys.Vec3 +import java.util.UUID + +/** + * Type of bee for determining which state machine to use. + */ +enum class BeeType { CONSTRUCTION, TRANSPORT } + +/** + * Lightweight bee data object — replaces the full [de.devin.cbbees.content.bee.MechanicalBeeEntity]. + * + * No entity overhead: no PathfinderMob, no Brain, no SynchedEntityData, no vanilla tick chain. + * Ticked directly by [ServerBeeManager] with pure Kotlin logic. + */ +class ServerBeeData( + val id: UUID, + val type: BeeType, + override var networkId: UUID, + var hiveId: UUID? = null, + /** Owner player UUID for portable beehive bees. */ + var ownerId: UUID? = null, +) : BeeWorker { + + // ── Position & movement ── + var pos: Vec3 = Vec3.ZERO + var velocity: Vec3 = Vec3.ZERO + var yRot: Float = 0f + + // ── Spring (fuel) ── + var springTension: Float = 1.0f + var rechargeFinishTick: Long = -1 + + // ── State machine ── + var constructionState: ConstructionBeeState = ConstructionBeeState.GATHERING + var transportState: TransportBeeState = TransportBeeState.FLYING_TO_SOURCE + var currentTask: TaskBatch? = null + var transportTask: TransportTask? = null + + // ── Flight plan (checkpoint-based navigation) ── + var flightPlan: FlightPlan? = null + var currentCheckpointIndex: Int = 0 + var nextCheckpointArrivalTick: Long = 0 + /** Game tick when the current flight plan was assigned. Used to sync client position. */ + var planStartTick: Long = 0 + + // ── Legacy navigation (kept for state machine compat during transition) ── + var walkTarget: BlockPos? = null + + // ── Hive ── + var hiveInstance: BeeHive? = null + var hivePos: BlockPos? = null + var hiveEntryRetries: Int = 0 + + // ── Safety ── + var orphanedTicks: Int = 0 + val stuckData: StuckCheckData = StuckCheckData() + var returningToOwner: Player? = null + + // ── Inventory ── + val inventory: SimpleContainer = SimpleContainer(if (type == BeeType.CONSTRUCTION) 4 else 3) + + // ── Context cache ── + var cachedBeeContext: BeeContext? = null + private var contextRefreshTick: Int = 0 + + // ── Level reference (set by ServerBeeManager before ticking) ── + lateinit var _level: Level + + // ════════════════════════════════════════════════════════════════════ + // BeeWorker implementation + // ════════════════════════════════════════════════════════════════════ + + override val uuid: UUID get() = id + override fun blockPosition(): BlockPos = BlockPos.containing(pos) + override fun level(): Level = _level + + override fun network(): BeeNetwork? = + ServerBeeNetworkManager.getNetwork(networkId, _level) + + override fun getWorkerX(): Double = pos.x + override fun getWorkerY(): Double = pos.y + override fun getWorkerZ(): Double = pos.z + + override fun addToInventory(stack: ItemStack): ItemStack { + var remaining = stack.copy() + for (i in 0 until inventory.containerSize) { + if (remaining.isEmpty) break + remaining = inventory.addItem(remaining) + } + return remaining + } + + override fun removeFromInventory(stack: ItemStack, count: Int) { + var toRemove = count + for (i in 0 until inventory.containerSize) { + if (toRemove <= 0) break + val slot = inventory.getItem(i) + if (!slot.isEmpty && ItemStack.isSameItemSameComponents(slot, stack)) { + val take = minOf(toRemove, slot.count) + slot.shrink(take) + if (slot.isEmpty) inventory.setItem(i, ItemStack.EMPTY) + toRemove -= take + } + } + } + + override fun getInventoryContents(): List { + val result = mutableListOf() + for (i in 0 until inventory.containerSize) { + val stack = inventory.getItem(i) + if (!stack.isEmpty) result.add(stack) + } + return result + } + + override fun isInventoryFull(): Boolean { + for (i in 0 until inventory.containerSize) { + val stack = inventory.getItem(i) + if (stack.isEmpty || stack.count < stack.maxStackSize) return false + } + return true + } + + override fun isInventoryEmpty(): Boolean { + for (i in 0 until inventory.containerSize) { + if (!inventory.getItem(i).isEmpty) return false + } + return true + } + + override fun consumeSpring(baseDrain: Double): Boolean { + if (springTension <= 0f) return false + val ctx = getBeeContext() + val effectiveDrain = if (type == BeeType.CONSTRUCTION) { + (baseDrain / ctx.springEfficiency * ctx.fuelConsumptionMultiplier).toFloat() + } else { + baseDrain.toFloat() + } + springTension = (springTension - effectiveDrain).coerceAtLeast(0f) + return true + } + + override fun getBeeContext(): BeeContext { + return cachedBeeContext ?: BeeContext() + } + + override fun getOwnerPlayer(): Player? { + val ownerId = this.ownerId ?: return null + return (_level as? ServerLevel)?.server?.playerList?.getPlayer(ownerId) + } + + fun refreshContext() { + cachedBeeContext = hiveInstance?.getBeeContext() + } + + // ════════════════════════════════════════════════════════════════════ + // NBT Serialization + // ════════════════════════════════════════════════════════════════════ + + fun save(registries: HolderLookup.Provider): CompoundTag { + val tag = CompoundTag() + tag.putUUID("Id", id) + tag.putString("Type", type.name) + tag.putUUID("NetworkId", networkId) + hiveId?.let { tag.putUUID("HiveId", it) } + ownerId?.let { tag.putUUID("OwnerId", it) } + + tag.putDouble("PosX", pos.x) + tag.putDouble("PosY", pos.y) + tag.putDouble("PosZ", pos.z) + tag.putFloat("YRot", yRot) + tag.putFloat("Spring", springTension) + tag.putLong("RechargeTick", rechargeFinishTick) + + tag.putString("State", if (type == BeeType.CONSTRUCTION) constructionState.name else transportState.name) + + walkTarget?.let { + tag.putInt("WalkX", it.x) + tag.putInt("WalkY", it.y) + tag.putInt("WalkZ", it.z) + } + + hivePos?.let { + tag.putInt("HivePosX", it.x) + tag.putInt("HivePosY", it.y) + tag.putInt("HivePosZ", it.z) + } + + // Save inventory + val itemList = ListTag() + for (i in 0 until inventory.containerSize) { + val stack = inventory.getItem(i) + if (!stack.isEmpty) { + val itemTag = CompoundTag() + itemTag.putByte("Slot", i.toByte()) + itemList.add(stack.save(registries, itemTag)) + } + } + tag.put("Inventory", itemList) + + return tag + } + + companion object { + fun load(tag: CompoundTag, registries: HolderLookup.Provider): ServerBeeData { + val id = tag.getUUID("Id") + val type = BeeType.valueOf(tag.getString("Type")) + val networkId = tag.getUUID("NetworkId") + + val bee = ServerBeeData(id, type, networkId) + if (tag.hasUUID("HiveId")) bee.hiveId = tag.getUUID("HiveId") + if (tag.hasUUID("OwnerId")) bee.ownerId = tag.getUUID("OwnerId") + + bee.pos = Vec3(tag.getDouble("PosX"), tag.getDouble("PosY"), tag.getDouble("PosZ")) + bee.yRot = tag.getFloat("YRot") + bee.springTension = tag.getFloat("Spring") + bee.rechargeFinishTick = tag.getLong("RechargeTick") + + if (type == BeeType.CONSTRUCTION) { + bee.constructionState = try { + ConstructionBeeState.valueOf(tag.getString("State")) + } catch (_: Exception) { ConstructionBeeState.FLYING_HOME } + } else { + bee.transportState = try { + TransportBeeState.valueOf(tag.getString("State")) + } catch (_: Exception) { TransportBeeState.FLYING_HOME } + } + + if (tag.contains("WalkX")) { + bee.walkTarget = BlockPos(tag.getInt("WalkX"), tag.getInt("WalkY"), tag.getInt("WalkZ")) + } + if (tag.contains("HivePosX")) { + bee.hivePos = BlockPos(tag.getInt("HivePosX"), tag.getInt("HivePosY"), tag.getInt("HivePosZ")) + } + + // Load inventory + val itemList = tag.getList("Inventory", Tag.TAG_COMPOUND.toInt()) + for (j in 0 until itemList.size) { + val itemTag = itemList.getCompound(j) + val slot = itemTag.getByte("Slot").toInt() + if (slot in 0 until bee.inventory.containerSize) { + bee.inventory.setItem(slot, ItemStack.parse(registries, itemTag).orElse(ItemStack.EMPTY)) + } + } + + return bee + } + } +} diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/server/ServerBeeManager.kt b/src/main/kotlin/de/devin/cbbees/content/bee/server/ServerBeeManager.kt new file mode 100644 index 0000000..db9d0c2 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/bee/server/ServerBeeManager.kt @@ -0,0 +1,317 @@ +package de.devin.cbbees.content.bee.server + +import de.devin.cbbees.CreateBuzzyBeez +import de.devin.cbbees.config.CBBeesConfig +import de.devin.cbbees.content.bee.flight.ClientCheckpoint +import de.devin.cbbees.content.bee.flight.ExecuteBeeAction +import de.devin.cbbees.content.bee.flight.FlightPlan +import de.devin.cbbees.content.bee.flight.FlightPlanComputer +import de.devin.cbbees.content.bee.state.* +import de.devin.cbbees.content.domain.beehive.BeeHive +import de.devin.cbbees.content.domain.network.ServerBeeNetworkManager +import de.devin.cbbees.content.domain.task.TaskBatch +import de.devin.cbbees.content.domain.task.TransportTask +import de.devin.cbbees.content.upgrades.BeeContext +import de.devin.cbbees.network.BeeCheckpointConfirmPacket +import de.devin.cbbees.network.BeeFlightPlanPacket +import de.devin.cbbees.network.BeeRemovePacket +import net.minecraft.core.BlockPos +import net.minecraft.core.HolderLookup +import net.minecraft.nbt.CompoundTag +import net.minecraft.nbt.ListTag +import net.minecraft.nbt.Tag +import net.minecraft.server.level.ServerLevel +import net.minecraft.world.level.saveddata.SavedData +import net.minecraft.world.phys.Vec3 +import net.neoforged.neoforge.network.PacketDistributor +import java.util.* + +/** + * Server-side singleton managing all mechanical bees as lightweight data objects. + * + * Replaces the vanilla Entity tick chain with direct iteration over [ServerBeeData]. + * No PathfinderMob, no Brain, no SynchedEntityData — just state machines and Vec3 math. + * + * Registered as [SavedData] for world persistence. + */ +object ServerBeeManager { + + private val bees = mutableMapOf() + private var level: ServerLevel? = null + + val activeBees: Collection get() = bees.values + + fun init(serverLevel: ServerLevel) { + level = serverLevel + } + + fun clear() { + // Drop carried items so nothing is lost on shutdown + for (bee in bees.values) { + try { bee.dropInventory() } catch (_: UninitializedPropertyAccessException) {} + } + // Release task batches so they return to PENDING on next load + for (bee in bees.values) { + bee.currentTask?.release() + } + bees.clear() + level = null + } + + // ════════════════════════════════════════════════════════════════════ + // Tick + // ════════════════════════════════════════════════════════════════════ + + /** Pending removals — collected during tick, applied after iteration. */ + private val pendingRemovals = mutableSetOf() + private var isTicking = false + + /** Max checkpoint actions per tick — prevents mass-arrival lag spikes. Read from config. */ + private val maxCheckpointsPerTick: Int get() = CBBeesConfig.maxCheckpointsPerTick.get() + + fun tickAll(serverLevel: ServerLevel, gameTime: Long) { + isTicking = true + pendingRemovals.clear() + val profiler = serverLevel.profiler + + val snapshot = bees.values.toTypedArray() + var checkpointsThisTick = 0 + val confirmBatch = mutableListOf() + + for (bee in snapshot) { + if (bee.id in pendingRemovals) continue + bee._level = serverLevel + + val plan = bee.flightPlan + if (plan == null || bee.currentCheckpointIndex >= plan.checkpoints.size) continue + + // Still flying → skip + if (gameTime < bee.nextCheckpointArrivalTick) continue + + // Throttle: spread checkpoint processing across ticks + if (checkpointsThisTick >= maxCheckpointsPerTick) continue + + profiler.push("beeCheckpoint") + checkpointsThisTick++ + val checkpoint = plan.checkpoints[bee.currentCheckpointIndex] + bee.pos = Vec3.atCenterOf(checkpoint.pos) + + val completed = checkpoint.action.onArrival(bee, serverLevel, gameTime) + if (completed) { + if (checkpoint.action is ExecuteBeeAction) { + confirmBatch.add(BeeCheckpointConfirmPacket.Entry(bee.id, bee.currentCheckpointIndex)) + } + advanceCheckpoint(bee, gameTime) + } + profiler.pop() + + if (bee.springTension < -999f) pendingRemovals.add(bee.id) + } + + // Send all action confirmations in one batched packet + if (confirmBatch.isNotEmpty()) { + broadcastCheckpointConfirmBatch(confirmBatch) + } + + isTicking = false + pendingRemovals.forEach { bees.remove(it) } + } + + private fun advanceCheckpoint(bee: ServerBeeData, gameTime: Long) { + val plan = bee.flightPlan ?: return + val prevIndex = bee.currentCheckpointIndex + bee.currentCheckpointIndex++ + + if (bee.currentCheckpointIndex >= plan.checkpoints.size) return + + val from = plan.checkpoints[prevIndex] + val to = plan.checkpoints[bee.currentCheckpointIndex] + + // Shared calculation — identical to client's ClientBeeFlightData timing + val travel = FlightPlan.travelTicks(from.pos, to.pos, plan.speed) + bee.nextCheckpointArrivalTick = gameTime + travel + from.clientPauseTicks + } + + private fun tickConstructionBee(bee: ServerBeeData, level: ServerLevel, gameTime: Long) { + ConstructionBeeStateMachine.tickData(bee, level, gameTime) + } + + private fun tickTransportBee(bee: ServerBeeData, level: ServerLevel, gameTime: Long) { + TransportBeeStateMachine.tickData(bee, level, gameTime) + } + + // ════════════════════════════════════════════════════════════════════ + // Spawn / Despawn + // ════════════════════════════════════════════════════════════════════ + + /** + * Spawns a construction bee from a hive with the given task batch. + */ + fun spawnConstructionBee( + hive: BeeHive, + batch: TaskBatch, + networkId: UUID, + spawnPos: Vec3, + context: BeeContext, + ownerId: UUID? = null, + beeType: BeeType = BeeType.CONSTRUCTION, + ): ServerBeeData { + val bee = ServerBeeData( + id = UUID.randomUUID(), + type = beeType, + networkId = networkId, + hiveId = hive.id, + ownerId = ownerId, + ).apply { + pos = spawnPos + springTension = 1.0f + hiveInstance = hive + hivePos = hive.pos + currentTask = batch + constructionState = ConstructionBeeState.GATHERING + cachedBeeContext = context + } + + batch.assignToBee(bee.id, (level?.gameTime ?: 0L)) + bees[bee.id] = bee + hive.onBeeSpawned(bee.id) + + // Compute flight plan asynchronously (raycasting done off-thread) + val network = ServerBeeNetworkManager.getNetwork(networkId, level!!) + if (network != null) { + FlightPlanComputer.computeAsync(bee, batch, network, level!!) { plan -> + bee.flightPlan = plan + bee.planStartTick = level!!.gameTime + bee.currentCheckpointIndex = 0 + // Start flying to checkpoint 1 — don't arrive at checkpoint 0 instantly + if (plan.checkpoints.size > 1) { + val travel = FlightPlan.travelTicks( + plan.checkpoints[0].pos, plan.checkpoints[1].pos, plan.speed + ) + bee.currentCheckpointIndex = 1 + bee.nextCheckpointArrivalTick = level!!.gameTime + travel + } + // Client starts at 0 (hive) so it renders the full departure flight + broadcastFlightPlan(bee, plan, clientStartIndex = 0) + } + } + + return bee + } + + /** + * Spawns a transport (bumble) bee with a transport task. + */ + fun spawnTransportBee( + hive: BeeHive, + task: TransportTask, + networkId: UUID, + spawnPos: Vec3, + ): ServerBeeData { + val bee = ServerBeeData( + id = UUID.randomUUID(), + type = BeeType.TRANSPORT, + networkId = networkId, + hiveId = hive.id, + ).apply { + pos = spawnPos + springTension = 1.0f + hiveInstance = hive + hivePos = hive.pos + transportTask = task + transportState = TransportBeeState.FLYING_TO_SOURCE + } + + // Compute flight plan asynchronously + if (level != null) { + FlightPlanComputer.computeTransportAsync(bee, task, level!!) { plan -> + bee.flightPlan = plan + bee.planStartTick = level!!.gameTime + bee.currentCheckpointIndex = 0 + if (plan.checkpoints.size > 1) { + val travel = FlightPlan.travelTicks( + plan.checkpoints[0].pos, plan.checkpoints[1].pos, plan.speed + ) + bee.currentCheckpointIndex = 1 + bee.nextCheckpointArrivalTick = level!!.gameTime + travel + } + // Client starts at 0 (hive) so it renders the full departure flight + broadcastFlightPlan(bee, plan, clientStartIndex = 0) + } + } + + bees[bee.id] = bee + return bee + } + + /** + * Sends a flight plan to all connected players. + * + * @param clientStartIndex override the checkpoint the client begins at. + * Defaults to `bee.currentCheckpointIndex` (correct for late-joiner resync). + * For initial spawn broadcasts, pass `0` so the client renders the full + * hive-to-target flight instead of appearing at the first work checkpoint. + */ + fun broadcastFlightPlan(bee: ServerBeeData, plan: FlightPlan, clientStartIndex: Int? = null) { + val gameTime = level?.gameTime ?: 0L + val planStartTick = bee.planStartTick + val elapsedTicks = if (planStartTick > 0) (gameTime - planStartTick) else 0L + + val packet = BeeFlightPlanPacket( + beeId = plan.beeId, + type = plan.type, + speed = plan.speed, + checkpoints = plan.checkpoints.map { + ClientCheckpoint(it.pos, it.clientPauseTicks, awaitConfirm = it.action is ExecuteBeeAction) + }, + startIndex = clientStartIndex ?: bee.currentCheckpointIndex, + elapsedTicks = elapsedTicks, + ) + val server = level?.server ?: return + server.playerList.players.forEach { player -> + PacketDistributor.sendToPlayer(player, packet) + } + } + + /** Send all action checkpoint confirmations for this tick in one packet. */ + private fun broadcastCheckpointConfirmBatch(entries: List) { + val server = level?.server ?: return + val packet = BeeCheckpointConfirmPacket(entries) + server.playerList.players.forEach { player -> + PacketDistributor.sendToPlayer(player, packet) + } + } + + /** Broadcast a bee removal to all clients. */ + fun broadcastRemoval(beeId: UUID) { + val server = level?.server ?: return + server.playerList.players.forEach { player -> + PacketDistributor.sendToPlayer(player, BeeRemovePacket(beeId)) + } + } + + /** + * Removes a bee from the manager (bee entered hive, dropped as item, etc.). + * If called during [tickAll], defers removal until iteration completes. + */ + fun removeBee(id: UUID) { + if (isTicking) { + pendingRemovals.add(id) + } else { + bees.remove(id) + } + broadcastRemoval(id) + } + + fun getBee(id: UUID): ServerBeeData? = bees[id] + + // Bees are ephemeral — not persisted to NBT. On shutdown, items are + // dropped and tasks released. Hives respawn bees when they find pending work. +} + +// Extension on BeeHive to accept the UUID-based onBeeSpawned +private fun BeeHive.onBeeSpawned(beeId: UUID) { + // The existing onBeeSpawned(Entity) won't work for non-entities. + // Hive tracking is done via activeBeesByJob in MechanicalBeehiveBlockEntity. + // For the non-entity system, the hive tracks bees by UUID directly. +} diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/state/BeeState.kt b/src/main/kotlin/de/devin/cbbees/content/bee/state/BeeState.kt new file mode 100644 index 0000000..069c29e --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/bee/state/BeeState.kt @@ -0,0 +1,66 @@ +package de.devin.cbbees.content.bee.state + +/** + * States for construction bees (MechanicalBeeEntity). + * Replaces the Brain/Behavior system with O(1) state dispatch. + */ +enum class ConstructionBeeState { + /** Flying to a logistics port to gather required items. */ + GATHERING, + /** Flying to the task's target block position. */ + FLYING_TO_TASK, + /** At the target position, executing the action (place/break). */ + EXECUTING, + /** Flying back to the hive (no more tasks or spring empty). */ + FLYING_HOME, + /** At the hive, entering to return the bee item. */ + ENTERING_HIVE, + /** At the hive, recharging spring tension. */ + RECHARGING, + /** Flying to a port to drop off excess items. */ + DROPPING_ITEMS, + /** No hive found, waiting for adoption or drop. */ + ORPHANED, + /** Portable beehive removed, returning to owner player. */ + RETURNING_TO_OWNER +} + +/** + * States for transport bees (MechanicalBumbleBeeEntity). + */ +enum class TransportBeeState { + /** Flying to the source port to pick up items. */ + FLYING_TO_SOURCE, + /** At source port, picking up items. */ + PICKING_UP, + /** Flying to the target port to deliver items. */ + FLYING_TO_TARGET, + /** At target port, depositing items. */ + DEPOSITING, + /** Flying back to the hive. */ + FLYING_HOME, + /** At the hive, entering. */ + ENTERING_HIVE, + /** Recharging spring. */ + RECHARGING, + /** No hive found. */ + ORPHANED +} + +/** + * Tracks stuck-detection state for a bee. + */ +class StuckCheckData { + var lastDistanceToTarget: Double = Double.MAX_VALUE + var ticksSinceCheck: Int = 0 + var failedChecks: Int = 0 + var lastTargetX: Double = 0.0 + var lastTargetY: Double = 0.0 + var lastTargetZ: Double = 0.0 + + fun reset() { + lastDistanceToTarget = Double.MAX_VALUE + ticksSinceCheck = 0 + failedChecks = 0 + } +} diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/state/ConstructionBeeStateMachine.kt b/src/main/kotlin/de/devin/cbbees/content/bee/state/ConstructionBeeStateMachine.kt new file mode 100644 index 0000000..f927a6b --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/bee/state/ConstructionBeeStateMachine.kt @@ -0,0 +1,922 @@ +package de.devin.cbbees.content.bee.state + +import de.devin.cbbees.config.CBBeesConfig +import de.devin.cbbees.content.bee.BeeSeparation +import de.devin.cbbees.content.bee.MechanicalBeeEntity +import de.devin.cbbees.content.bee.debug.BeeDebug +import de.devin.cbbees.content.bee.server.ServerBeeData +import de.devin.cbbees.content.bee.server.ServerBeeManager +import de.devin.cbbees.content.beehive.MechanicalBeehiveBlockEntity +import de.devin.cbbees.content.domain.action.ItemConsumingAction +import de.devin.cbbees.content.domain.action.impl.DropOffItemsAction +import de.devin.cbbees.content.domain.action.impl.RemoveBlockAction +import de.devin.cbbees.content.domain.beehive.BeeHive +import de.devin.cbbees.content.domain.beehive.PortableBeeHive +import de.devin.cbbees.content.domain.logistics.LogisticsPort +import de.devin.cbbees.content.domain.network.BeeNetwork +import de.devin.cbbees.content.domain.network.ServerBeeNetworkManager +import de.devin.cbbees.content.domain.task.TaskBatch +import de.devin.cbbees.items.AllItems as CBeesItems +import de.devin.cbbees.util.ItemStackKey +import net.minecraft.core.BlockPos +import net.minecraft.server.level.ServerLevel +import net.minecraft.world.entity.item.ItemEntity +import net.minecraft.world.item.ItemStack +import net.minecraft.world.phys.Vec3 +import java.util.UUID + +/** + * O(1) state machine for construction bees, replacing the Brain/Behavior system. + * + * Each tick: run cross-cutting checks (flight drain, orphan, stuck, spring), + * then dispatch to the current state handler. No behavior precondition evaluation. + */ +object ConstructionBeeStateMachine { + + /** Global throttle for block operations per tick (shared with bumble bees). */ + private var operationsThisTick = 0 + private var lastThrottleTick = -1L + + fun canExecuteAction(gameTime: Long): Boolean { + if (gameTime != lastThrottleTick) { + lastThrottleTick = gameTime + operationsThisTick = 0 + } + return operationsThisTick < CBBeesConfig.maxBlockOperationsPerTick.get() + } + + fun recordAction() { operationsThisTick++ } + + /** + * Tick for non-entity [ServerBeeData]. Movement and stuck detection are handled + * by [ServerBeeManager], so this only does state transitions and actions. + */ + fun tickData(bee: ServerBeeData, level: ServerLevel, gameTime: Long) { + // Flight drain + if (bee.velocity.lengthSqr() > 0.001) bee.consumeSpring(CBBeesConfig.springDrainFlight.get()) + + // Orphan check + if (bee.hiveInstance == null) { + val hive = ServerBeeNetworkManager.findHive(bee.hiveId ?: UUID.randomUUID()) + if (hive != null) bee.hiveInstance = hive + else { + bee.orphanedTicks++ + if (bee.orphanedTicks >= 200) { + // Drop as item + val itemStack = ItemStack(CBeesItems.MECHANICAL_BEE.get()) + level.addFreshEntity(ItemEntity(level, bee.pos.x, bee.pos.y, bee.pos.z, itemStack)) + bee.springTension = -9999f // signal removal + } + return + } + } + + // Portable beehive tracking + (bee.hiveInstance as? PortableBeeHive)?.let { portable -> + if (gameTime % 20 == 0L) { + bee.hivePos = portable.player.blockPosition().above(2) + if (!portable.isValid()) { + bee.currentTask?.release(gameTick = gameTime) + bee.currentTask = null + bee.hiveInstance = null + bee.constructionState = ConstructionBeeState.FLYING_HOME + return + } + } + } + + // Spring empty → recharge + if (bee.constructionState != ConstructionBeeState.RECHARGING + && bee.constructionState != ConstructionBeeState.ENTERING_HIVE + && bee.constructionState != ConstructionBeeState.ORPHANED + && bee.springTension <= 0f + ) { + bee.constructionState = ConstructionBeeState.RECHARGING + bee.walkTarget = bee.hivePos + } + + // State dispatch — uses the same logic as the entity version but with ServerBeeData fields + when (bee.constructionState) { + ConstructionBeeState.GATHERING -> tickGatheringData(bee, level, gameTime) + ConstructionBeeState.FLYING_TO_TASK -> tickFlyingToTaskData(bee) + ConstructionBeeState.EXECUTING -> tickExecutingData(bee, level, gameTime) + ConstructionBeeState.FLYING_HOME -> tickFlyingHomeData(bee) + ConstructionBeeState.ENTERING_HIVE -> tickEnteringHiveData(bee, level, gameTime) + ConstructionBeeState.RECHARGING -> tickRechargingData(bee, gameTime) + ConstructionBeeState.DROPPING_ITEMS -> tickDroppingItemsData(bee, level) + ConstructionBeeState.ORPHANED -> { /* handled above */ } + ConstructionBeeState.RETURNING_TO_OWNER -> { /* TODO */ } + } + } + + // ── ServerBeeData state handlers (reuse helper methods via BeeWorker) ── + + private fun tickGatheringData(bee: ServerBeeData, level: ServerLevel, gameTime: Long) { + val batch = bee.currentTask ?: run { bee.constructionState = ConstructionBeeState.FLYING_HOME; bee.walkTarget = bee.hivePos; return } + val currentTask = batch.getCurrentTask() + if (currentTask != null) { + val action = currentTask.action + if (action is ItemConsumingAction && action.hasItems(bee)) { + bee.constructionState = ConstructionBeeState.FLYING_TO_TASK + bee.walkTarget = currentTask.targetPos + return + } + if (action !is ItemConsumingAction) { + bee.constructionState = ConstructionBeeState.FLYING_TO_TASK + bee.walkTarget = currentTask.targetPos + return + } + } + + val missing = computeMissingItems(bee, batch) + if (missing.isEmpty()) { + bee.constructionState = ConstructionBeeState.FLYING_TO_TASK + bee.walkTarget = batch.getCurrentTask()?.targetPos + return + } + + val network = bee.network() ?: run { + batch.release(gameTick = gameTime); bee.currentTask = null + bee.constructionState = ConstructionBeeState.FLYING_HOME; bee.walkTarget = bee.hivePos; return + } + + val gatherPlan = buildGatherPlan(network, missing, bee.id) + if (gatherPlan.isNotEmpty()) { + val (targetPort, itemsAtPort) = gatherPlan.maxByOrNull { it.value.size }!! + network.releaseReservations(bee.id) + targetPort.reserve(bee.id, itemsAtPort, gameTime) + + if (bee.blockPosition().closerThan(targetPort.pos, 2.5)) { + itemsAtPort.forEach { item -> + if (bee.isInventoryFull()) return@forEach + if (targetPort.hasItemStack(item) && targetPort.removeItemStack(item)) { + val remainder = bee.addToInventory(item.copy()) + if (!remainder.isEmpty) targetPort.addItemStack(remainder) + bee.consumeSpring(CBBeesConfig.springDrainPickup.get()) + } + } + targetPort.releaseReservation(bee.id) + } else { + bee.walkTarget = targetPort.pos + } + return + } + + // No providers + network.releaseReservations(bee.id) + batch.release(gameTick = gameTime) + bee.currentTask = null + bee.walkTarget = null + bee.constructionState = ConstructionBeeState.FLYING_HOME + bee.walkTarget = bee.hivePos + } + + private fun tickFlyingToTaskData(bee: ServerBeeData) { + val batch = bee.currentTask ?: run { bee.constructionState = ConstructionBeeState.FLYING_HOME; bee.walkTarget = bee.hivePos; return } + val task = batch.getCurrentTask() ?: run { bee.constructionState = ConstructionBeeState.FLYING_HOME; bee.walkTarget = bee.hivePos; return } + val action = task.action + if (action is ItemConsumingAction && !action.hasItems(bee)) { + bee.constructionState = ConstructionBeeState.GATHERING; bee.walkTarget = null; return + } + if (bee.blockPosition().closerThan(task.targetPos, 2.5)) { + bee.constructionState = ConstructionBeeState.EXECUTING; bee.walkTarget = null + } else { + bee.walkTarget = task.targetPos + } + } + + private fun tickExecutingData(bee: ServerBeeData, level: ServerLevel, gameTime: Long) { + val batch = bee.currentTask ?: run { bee.constructionState = ConstructionBeeState.FLYING_HOME; bee.walkTarget = bee.hivePos; return } + val task = batch.getCurrentTask() ?: run { bee.constructionState = ConstructionBeeState.FLYING_HOME; bee.walkTarget = bee.hivePos; return } + val hive = bee.hiveInstance ?: run { bee.constructionState = ConstructionBeeState.FLYING_HOME; bee.walkTarget = bee.hivePos; return } + if (!bee.blockPosition().closerThan(task.targetPos, 2.5)) { + bee.constructionState = ConstructionBeeState.FLYING_TO_TASK; bee.walkTarget = task.targetPos; return + } + val action = task.action + if (action is ItemConsumingAction && !action.hasItems(bee)) { + bee.constructionState = ConstructionBeeState.GATHERING; bee.walkTarget = null; return + } + if (!canExecuteAction(gameTime)) return + if (action !is DropOffItemsAction) { + bee.network()?.let { if (!it.isInRange(task.targetPos)) return } + } + recordAction() + val done = action.execute(level, bee, bee.getBeeContext()) + if (done) { + val drain = if (action is RemoveBlockAction) CBBeesConfig.springDrainBreak.get() else CBBeesConfig.springDrainPlace.get() + bee.consumeSpring(drain) + task.complete() + if (!batch.advance()) { + val nextBatch = hive.notifyTaskCompleted(task, bee.id) + if (nextBatch != null) { + bee.currentTask = nextBatch + nextBatch.assignToBee(bee.id, gameTime) + bee.constructionState = ConstructionBeeState.GATHERING + } else { + bee.currentTask = null + bee.constructionState = ConstructionBeeState.FLYING_HOME; bee.walkTarget = bee.hivePos + } + } else { + val nextTask = batch.getCurrentTask() + if (nextTask?.action is DropOffItemsAction && bee.isInventoryEmpty()) { + nextTask.complete() + if (!batch.advance()) { + val nextBatch = hive.notifyTaskCompleted(nextTask, bee.id) + if (nextBatch != null) { + bee.currentTask = nextBatch + nextBatch.assignToBee(bee.id, gameTime) + bee.constructionState = ConstructionBeeState.GATHERING + } else { + bee.currentTask = null; bee.constructionState = ConstructionBeeState.FLYING_HOME; bee.walkTarget = bee.hivePos + } + } + } else { + nextTask?.action?.onActivate(bee) + bee.constructionState = ConstructionBeeState.GATHERING + } + } + bee.walkTarget = null + } else { + bee.network()?.releaseReservations(bee.id) + batch.release(gameTick = gameTime); bee.currentTask = null + bee.constructionState = ConstructionBeeState.FLYING_HOME; bee.walkTarget = bee.hivePos + } + } + + private fun tickFlyingHomeData(bee: ServerBeeData) { + if (bee.getInventoryContents().isNotEmpty() && bee.currentTask == null) { + bee.constructionState = ConstructionBeeState.DROPPING_ITEMS; bee.walkTarget = null; return + } + val hive = bee.hiveInstance ?: return + if (bee.blockPosition().closerThan(hive.pos, 4.0)) { + bee.constructionState = ConstructionBeeState.ENTERING_HIVE; bee.walkTarget = null + } else { + bee.walkTarget = hive.pos + } + } + + private fun tickEnteringHiveData(bee: ServerBeeData, level: ServerLevel, gameTime: Long) { + val hive = bee.hiveInstance ?: run { bee.orphanedTicks = 0; bee.constructionState = ConstructionBeeState.ORPHANED; return } + if (!bee.blockPosition().closerThan(hive.pos, 4.0)) { + bee.constructionState = ConstructionBeeState.FLYING_HOME; return + } + val ctx = bee.getBeeContext() + hive.chargeReturnFuel(1.0f - bee.springTension, ctx) + val beeItem = ItemStack(CBeesItems.MECHANICAL_BEE.get()) + if (hive.returnBee(beeItem)) { + // Update hive tracking before removing the bee + (hive as? MechanicalBeehiveBlockEntity)?.onBeeRemovedById(bee.id) + ServerBeeManager.removeBee(bee.id) + } else { + bee.hiveEntryRetries++ + if (bee.hiveEntryRetries >= 3) { + level.addFreshEntity(ItemEntity(level, bee.pos.x, bee.pos.y, bee.pos.z, beeItem)) + bee.dropInventory() + (hive as? MechanicalBeehiveBlockEntity)?.onBeeRemovedById(bee.id) + ServerBeeManager.removeBee(bee.id) + } + } + } + + private fun tickRechargingData(bee: ServerBeeData, gameTime: Long) { + val hive = bee.hiveInstance ?: run { bee.constructionState = ConstructionBeeState.ORPHANED; bee.orphanedTicks = 0; return } + if (bee.rechargeFinishTick >= 0) { + if (gameTime >= bee.rechargeFinishTick) { + bee.springTension = 1.0f; bee.rechargeFinishTick = -1 + bee.constructionState = if (bee.currentTask != null) ConstructionBeeState.GATHERING else ConstructionBeeState.FLYING_HOME + bee.walkTarget = if (bee.currentTask != null) null else bee.hivePos + } + return + } + if (bee.blockPosition().closerThan(hive.pos, 4.0)) { + val ctx = bee.getBeeContext() + bee.rechargeFinishTick = gameTime + hive.rechargeSpring(ctx) + } else { + bee.walkTarget = hive.pos + } + } + + private fun tickDroppingItemsData(bee: ServerBeeData, level: ServerLevel) { + val excess = getExcessItems(bee, bee.currentTask) + if (excess.isEmpty()) { bee.constructionState = ConstructionBeeState.FLYING_HOME; bee.walkTarget = bee.hivePos; return } + val port = bee.network()?.findDropOff(excess.first()) + if (port == null) { + excess.forEach { item -> + bee.removeFromInventory(item, item.count) + level.addFreshEntity(ItemEntity(level, bee.pos.x, bee.pos.y, bee.pos.z, item.copy())) + } + bee.constructionState = ConstructionBeeState.FLYING_HOME; bee.walkTarget = bee.hivePos + return + } + if (bee.blockPosition().closerThan(port.pos, 2.5)) { + excess.forEach { item -> + val remainder = port.addItemStack(item.copy()) + if (!remainder.isEmpty) level.addFreshEntity(ItemEntity(level, port.pos.x + 0.5, port.pos.y + 0.5, port.pos.z + 0.5, remainder)) + bee.removeFromInventory(item, item.count) + } + bee.constructionState = ConstructionBeeState.FLYING_HOME; bee.walkTarget = bee.hivePos + } else { + bee.walkTarget = port.pos + } + } + + // ── Entity-based tick (legacy, used while entities still exist) ── + + fun tick(bee: MechanicalBeeEntity, level: ServerLevel, gameTime: Long) { + // ── Cross-cutting checks (replaces CORE behaviors) ── + tickFlightDrain(bee) + if (bee.rechargeFinishTick < 0) BeeSeparation.applyFlightOffset(bee) + if (tickPortableBeehiveTracking(bee, level, gameTime)) return + if (tickReturnToOwner(bee, level)) return + if (tickOrphanedCheck(bee, gameTime)) return + tickStuckCheck(bee, level, gameTime) + tickNavigation(bee) + + // Spring empty → recharge (highest priority in WORK) + if (bee.beeState != ConstructionBeeState.RECHARGING + && bee.beeState != ConstructionBeeState.ENTERING_HIVE + && bee.beeState != ConstructionBeeState.ORPHANED + && bee.springTension <= 0f + ) { + bee.beeState = ConstructionBeeState.RECHARGING + bee.walkTargetPos = null + } + + // ── State dispatch ── + when (bee.beeState) { + ConstructionBeeState.GATHERING -> tickGathering(bee, level, gameTime) + ConstructionBeeState.FLYING_TO_TASK -> tickFlyingToTask(bee) + ConstructionBeeState.EXECUTING -> tickExecuting(bee, level, gameTime) + ConstructionBeeState.FLYING_HOME -> tickFlyingHome(bee) + ConstructionBeeState.ENTERING_HIVE -> tickEnteringHive(bee, level, gameTime) + ConstructionBeeState.RECHARGING -> tickRecharging(bee, gameTime) + ConstructionBeeState.DROPPING_ITEMS -> tickDroppingItems(bee, level) + ConstructionBeeState.ORPHANED -> { /* handled in tickOrphanedCheck */ } + ConstructionBeeState.RETURNING_TO_OWNER -> { /* handled in tickReturnToOwner */ } + } + } + + // ════════════════════════════════════════════════════════════════════ + // Cross-cutting checks + // ════════════════════════════════════════════════════════════════════ + + private fun tickFlightDrain(bee: MechanicalBeeEntity) { + if (bee.deltaMovement.lengthSqr() > 0.001) { + bee.consumeSpring(CBBeesConfig.springDrainFlight.get()) + } + } + + private fun tickNavigation(bee: MechanicalBeeEntity) { + val target = bee.walkTargetPos ?: return + if (bee.navigation.isDone) { + bee.navigation.moveTo(target.x + 0.5, target.y.toDouble(), target.z + 0.5, 1.0) + } + } + + private fun tickOrphanedCheck(bee: MechanicalBeeEntity, gameTime: Long): Boolean { + if (bee.beeState == ConstructionBeeState.ORPHANED) { + bee.orphanedTicks++ + if (bee.orphanedTicks % 40 == 1) { + val adopted = bee.tryAdoptHive() + if (adopted != null) { + bee.orphanedTicks = 0 + bee.beeState = ConstructionBeeState.FLYING_HOME + return false + } + } + if (bee.orphanedTicks >= 200) { + bee.dropBeeItemAndDiscard("orphaned for 200 ticks") + } + return true + } + + if (bee.hiveInstance == null) { + // Try to look up hive + val hive = bee.beehive() + if (hive == null) { + bee.beeState = ConstructionBeeState.ORPHANED + bee.orphanedTicks = 0 + bee.walkTargetPos = null + return true + } + bee.hiveInstance = hive + } + return false + } + + private fun tickStuckCheck(bee: MechanicalBeeEntity, level: ServerLevel, gameTime: Long) { + val target = bee.walkTargetPos ?: run { bee.stuckData.reset(); return } + val data = bee.stuckData + val targetVec = Vec3.atCenterOf(target) + + // Reset if target changed + val dx = targetVec.x - data.lastTargetX + val dz = targetVec.z - data.lastTargetZ + if (dx * dx + dz * dz > 1.0) { + data.lastTargetX = targetVec.x + data.lastTargetY = targetVec.y + data.lastTargetZ = targetVec.z + data.lastDistanceToTarget = bee.position().distanceTo(targetVec) + data.ticksSinceCheck = 0 + data.failedChecks = 0 + return + } + + data.ticksSinceCheck++ + if (data.ticksSinceCheck < 20) return + data.ticksSinceCheck = 0 + + val currentDist = bee.position().distanceTo(targetVec) + val progress = data.lastDistanceToTarget - currentDist + + if (progress < 1.5) { + data.failedChecks++ + if (data.failedChecks < 3) { + bee.navigation.recomputePath() + } + } else { + data.failedChecks = 0 + } + + data.lastDistanceToTarget = currentDist + + if (data.failedChecks >= 3) { + // Teleport to target + val safeY = findSafeY(level, target) + bee.teleportTo(target.x + 0.5, safeY, target.z + 0.5) + data.reset() + bee.walkTargetPos = null + BeeDebug.log(bee, "Stuck! Teleported to target") + } + } + + private fun tickReturnToOwner(bee: MechanicalBeeEntity, level: ServerLevel): Boolean { + val player = bee.returningToOwner ?: return false + if (!player.isAlive) { + bee.dropBeeItemAndDiscard("owner disconnected or dead") + return true + } + if (bee.blockPosition().closerThan(player.blockPosition(), 3.0)) { + bee.dropBeeItemAndDiscard("reached owner — portable beehive removed") + return true + } + bee.walkTargetPos = player.blockPosition().above(2) + return true + } + + private fun tickPortableBeehiveTracking(bee: MechanicalBeeEntity, level: ServerLevel, gameTime: Long): Boolean { + if (bee.tickCount % 20 != 0) return false + val hive = bee.hiveInstance as? PortableBeeHive ?: return false + + bee.hivePos = hive.player.blockPosition().above(2) + + if (!hive.isValid() && bee.returningToOwner == null) { + val task = bee.currentTask + if (task != null) { + task.release(gameTick = gameTime) + bee.currentTask = null + } + bee.hiveInstance = null + bee.hivePos = null + bee.walkTargetPos = null + bee.returningToOwner = hive.player + bee.springTension = 1.0f + bee.beeState = ConstructionBeeState.RETURNING_TO_OWNER + return true + } + return false + } + + // ════════════════════════════════════════════════════════════════════ + // State handlers + // ════════════════════════════════════════════════════════════════════ + + private fun tickGathering(bee: MechanicalBeeEntity, level: ServerLevel, gameTime: Long) { + val batch = bee.currentTask ?: run { transitionToHome(bee); return } + + val currentTask = batch.getCurrentTask() + if (currentTask != null) { + val action = currentTask.action + if (action is ItemConsumingAction && action.hasItems(bee)) { + // Current task already has items — go execute + bee.beeState = ConstructionBeeState.FLYING_TO_TASK + bee.walkTargetPos = currentTask.targetPos + return + } + if (action !is ItemConsumingAction) { + // Task doesn't need items + bee.beeState = ConstructionBeeState.FLYING_TO_TASK + bee.walkTargetPos = currentTask.targetPos + return + } + } + + val missing = computeMissingItems(bee, batch) + if (missing.isEmpty()) { + bee.beeState = ConstructionBeeState.FLYING_TO_TASK + bee.walkTargetPos = batch.getCurrentTask()?.targetPos + return + } + + val network = bee.network() ?: run { + releaseBatch(bee, batch, gameTime) + transitionToHome(bee) + return + } + + val hive = bee.hiveInstance + val isPortable = hive is PortableBeeHive + + // Try network logistics ports + val gatherPlan = buildGatherPlan(network, missing, bee.uuid) + if (gatherPlan.isNotEmpty()) { + val (targetPort, itemsAtPort) = gatherPlan.maxByOrNull { it.value.size }!! + network.releaseReservations(bee.uuid) + targetPort.reserve(bee.uuid, itemsAtPort, gameTime) + + if (bee.blockPosition().closerThan(targetPort.pos, bee.workRange)) { + for (item in itemsAtPort) { + if (bee.isInventoryFull()) break + if (targetPort.hasItemStack(item) && targetPort.removeItemStack(item)) { + val remainder = bee.addToInventory(item.copy()) + if (!remainder.isEmpty) targetPort.addItemStack(remainder) + bee.consumeSpring(CBBeesConfig.springDrainPickup.get()) + } + } + targetPort.releaseReservation(bee.uuid) + // Re-evaluate next tick (might need more items or be ready to fly) + } else { + bee.walkTargetPos = targetPort.pos + } + return + } + + // Portable beehive fallback: player inventory + if (isPortable) { + val player = (hive as PortableBeeHive).player + val playerItems = missing.filter { playerHasItem(player, it) } + if (playerItems.isNotEmpty()) { + if (bee.blockPosition().closerThan(player.blockPosition(), bee.workRange)) { + for (item in playerItems) { + if (bee.isInventoryFull()) break + val extracted = extractFromPlayer(player, item) + if (!extracted.isEmpty) { + val remainder = bee.addToInventory(extracted) + if (!remainder.isEmpty) player.inventory.add(remainder) + bee.consumeSpring(CBBeesConfig.springDrainPickup.get()) + } + } + return + } else { + bee.walkTargetPos = player.blockPosition() + return + } + } + } + + // No providers — release batch + BeeDebug.log(bee, "No providers for ${missing.size} missing items — releasing") + network.releaseReservations(bee.uuid) + releaseBatch(bee, batch, gameTime) + bee.walkTargetPos = null + transitionToHome(bee) + } + + private fun tickFlyingToTask(bee: MechanicalBeeEntity) { + val batch = bee.currentTask ?: run { transitionToHome(bee); return } + val task = batch.getCurrentTask() ?: run { transitionToHome(bee); return } + + // Check if items are needed but missing + val action = task.action + if (action is ItemConsumingAction && !action.hasItems(bee)) { + bee.beeState = ConstructionBeeState.GATHERING + bee.walkTargetPos = null + return + } + + if (bee.blockPosition().closerThan(task.targetPos, bee.workRange)) { + bee.beeState = ConstructionBeeState.EXECUTING + bee.walkTargetPos = null + } else { + bee.walkTargetPos = task.targetPos + } + } + + private fun tickExecuting(bee: MechanicalBeeEntity, level: ServerLevel, gameTime: Long) { + val batch = bee.currentTask ?: run { transitionToHome(bee); return } + val task = batch.getCurrentTask() ?: run { transitionToHome(bee); return } + val hive = bee.hiveInstance ?: run { transitionToHome(bee); return } + + // Check proximity + if (!bee.blockPosition().closerThan(task.targetPos, bee.workRange)) { + bee.beeState = ConstructionBeeState.FLYING_TO_TASK + bee.walkTargetPos = task.targetPos + return + } + + // Check items + val action = task.action + if (action is ItemConsumingAction && !action.hasItems(bee)) { + bee.beeState = ConstructionBeeState.GATHERING + bee.walkTargetPos = null + return + } + + // Global throttle + if (!canExecuteAction(gameTime)) return + + // Check network range (skip for DropOffItemsAction) + if (action !is DropOffItemsAction) { + val network = bee.network() + if (network != null && !network.isInRange(task.targetPos)) return + } + + BeeDebug.log(bee, "Executing: ${task.action.getDescription()}") + recordAction() + val done = task.action.execute(level, bee, bee.getBeeContext()) + + if (done) { + val drain = if (action is RemoveBlockAction) CBBeesConfig.springDrainBreak.get() else CBBeesConfig.springDrainPlace.get() + bee.consumeSpring(drain) + task.complete() + + if (!batch.advance()) { + // Batch complete + val nextBatch = hive.notifyTaskCompleted(task, bee.uuid) + if (nextBatch != null) { + bee.currentTask = nextBatch + nextBatch.assignToBee(bee.uuid, gameTime) + bee.beeState = ConstructionBeeState.GATHERING + } else { + bee.currentTask = null + transitionToHome(bee) + } + } else { + // More tasks in batch + val nextTask = batch.getCurrentTask() + if (nextTask?.action is DropOffItemsAction && bee.getInventoryContents().isEmpty()) { + nextTask.complete() + if (!batch.advance()) { + val nextBatch = hive.notifyTaskCompleted(nextTask, bee.uuid) + if (nextBatch != null) { + bee.currentTask = nextBatch + nextBatch.assignToBee(bee.uuid, gameTime) + bee.beeState = ConstructionBeeState.GATHERING + } else { + bee.currentTask = null + transitionToHome(bee) + } + } + } else { + nextTask?.action?.onActivate(bee) + bee.beeState = ConstructionBeeState.GATHERING // re-evaluate items + } + } + bee.walkTargetPos = null + } else { + // Task failed + BeeDebug.log(bee, "Task failed — releasing batch") + bee.network()?.releaseReservations(bee.uuid) + releaseBatch(bee, batch, gameTime) + bee.walkTargetPos = null + transitionToHome(bee) + } + } + + private fun tickFlyingHome(bee: MechanicalBeeEntity) { + // Drop excess items first + if (bee.getInventoryContents().isNotEmpty() && bee.currentTask == null) { + bee.beeState = ConstructionBeeState.DROPPING_ITEMS + bee.walkTargetPos = null + return + } + + val hive = bee.hiveInstance ?: return + if (bee.blockPosition().closerThan(hive.pos, 4.0)) { + bee.beeState = ConstructionBeeState.ENTERING_HIVE + bee.walkTargetPos = null + } else { + bee.walkTargetPos = hive.pos + } + } + + private fun tickEnteringHive(bee: MechanicalBeeEntity, level: ServerLevel, gameTime: Long) { + val hive = bee.hiveInstance ?: run { + bee.beeState = ConstructionBeeState.ORPHANED + bee.orphanedTicks = 0 + return + } + + if (!bee.blockPosition().closerThan(hive.pos, 4.0)) { + bee.beeState = ConstructionBeeState.FLYING_HOME + return + } + + val deficit = 1.0f - bee.springTension + val ctx = bee.getBeeContextForRecharge() + hive.chargeReturnFuel(deficit, ctx) + + val success = hive.returnBee(bee.beeItemStack()) + if (success) { + bee.discard() + } else { + bee.hiveEntryRetries++ + if (bee.hiveEntryRetries >= 3) { + bee.dropBeeItemAndDiscard("hive full — max retries") + return + } + val adopted = bee.tryAdoptHive(exclude = hive) + if (adopted == null) { + bee.dropBeeItemAndDiscard("hive full — no other hive") + } else { + bee.hiveInstance = adopted + bee.hivePos = adopted.pos + bee.beeState = ConstructionBeeState.FLYING_HOME + } + } + } + + private fun tickRecharging(bee: MechanicalBeeEntity, gameTime: Long) { + val hive = bee.hiveInstance ?: run { + bee.beeState = ConstructionBeeState.ORPHANED + bee.orphanedTicks = 0 + return + } + + if (bee.rechargeFinishTick >= 0) { + if (gameTime >= bee.rechargeFinishTick) { + bee.springTension = 1.0f + bee.rechargeFinishTick = -1 + // Resume work or go home + if (bee.currentTask != null) { + bee.beeState = ConstructionBeeState.GATHERING + } else { + transitionToHome(bee) + } + } + return + } + + if (bee.blockPosition().closerThan(hive.pos, 4.0)) { + val ctx = bee.getBeeContextForRecharge() + val rechargeTicks = hive.rechargeSpring(ctx) + bee.rechargeFinishTick = gameTime + rechargeTicks + } else { + bee.walkTargetPos = hive.pos + } + } + + private fun tickDroppingItems(bee: MechanicalBeeEntity, level: ServerLevel) { + val excess = getExcessItems(bee, bee.currentTask) + if (excess.isEmpty()) { + transitionToHome(bee) + return + } + + val network = bee.network() + val dropOffPort = network?.findDropOff(excess.first()) + + if (dropOffPort == null) { + val owner = bee.getOwnerPlayer() + if (owner != null) { + for (item in excess) { + bee.removeFromInventory(item, item.count) + if (!owner.inventory.add(item.copy())) { + level.addFreshEntity(ItemEntity(level, owner.x, owner.y, owner.z, item.copy())) + } + } + } else { + for (item in excess) { + bee.removeFromInventory(item, item.count) + level.addFreshEntity(ItemEntity(level, bee.x, bee.y, bee.z, item.copy())) + } + } + transitionToHome(bee) + return + } + + if (bee.blockPosition().closerThan(dropOffPort.pos, bee.workRange)) { + for (item in excess) { + val remainder = dropOffPort.addItemStack(item.copy()) + if (!remainder.isEmpty) { + level.addFreshEntity(ItemEntity(level, dropOffPort.pos.x + 0.5, dropOffPort.pos.y + 0.5, dropOffPort.pos.z + 0.5, remainder)) + } + bee.removeFromInventory(item, item.count) + } + transitionToHome(bee) + } else { + bee.walkTargetPos = dropOffPort.pos + } + } + + // ════════════════════════════════════════════════════════════════════ + // Helpers + // ════════════════════════════════════════════════════════════════════ + + private fun transitionToHome(bee: MechanicalBeeEntity) { + bee.beeState = ConstructionBeeState.FLYING_HOME + bee.walkTargetPos = bee.hiveInstance?.pos + } + + private fun releaseBatch(bee: MechanicalBeeEntity, batch: TaskBatch, gameTime: Long) { + batch.release(gameTick = gameTime) + bee.currentTask = null + } + + fun computeMissingItems(bee: de.devin.cbbees.content.bee.server.BeeWorker, batch: TaskBatch): List { + val totalRequired = mutableMapOf() + for (task in batch.getRemainingTasks()) { + val action = task.action + if (action is ItemConsumingAction) { + for (req in action.requiredItems) { + val key = ItemStackKey(req) + totalRequired[key] = (totalRequired[key] ?: 0) + req.count + } + } + } + for (carried in bee.getInventoryContents()) { + val key = ItemStackKey(carried) + val needed = totalRequired[key] ?: continue + val remaining = needed - carried.count + if (remaining <= 0) totalRequired.remove(key) else totalRequired[key] = remaining + } + return totalRequired.map { (key, count) -> key.stack.copy().also { it.count = count } } + } + + private fun buildGatherPlan(network: BeeNetwork, missing: List, beeId: java.util.UUID): Map> { + val plan = mutableMapOf>() + for (item in missing) { + val searchStack = item.copyWithCount(1) + val provider = network.findAvailableProvider(searchStack, beeId) ?: continue + if (provider is PortableBeeHive) continue + plan.getOrPut(provider) { mutableListOf() }.add(item) + } + return plan + } + + private fun getExcessItems(bee: de.devin.cbbees.content.bee.server.BeeWorker, currentTask: TaskBatch? = null): List { + val contents = bee.getInventoryContents() + if (contents.isEmpty()) return emptyList() + val batch = currentTask ?: return contents.map { it.copy() } + + val needed = mutableMapOf() + for (task in batch.getRemainingTasks()) { + val action = task.action + if (action is ItemConsumingAction) { + for (req in action.requiredItems) { + val key = ItemStackKey(req) + needed[key] = (needed[key] ?: 0) + req.count + } + } + } + + val excess = mutableListOf() + for (carried in contents) { + val key = ItemStackKey(carried) + val neededCount = needed[key] ?: 0 + if (neededCount <= 0) { + excess.add(carried.copy()) + } else { + val surplus = carried.count - neededCount + needed[key] = maxOf(0, neededCount - carried.count) + if (surplus > 0) excess.add(carried.copyWithCount(surplus)) + } + } + return excess + } + + private fun playerHasItem(player: net.minecraft.world.entity.player.Player, stack: ItemStack): Boolean { + if (player.isCreative) return true + for (i in 0 until player.inventory.containerSize) { + val slot = player.inventory.getItem(i) + if (!slot.isEmpty && ItemStack.isSameItemSameComponents(slot, stack)) return true + } + return false + } + + private fun extractFromPlayer(player: net.minecraft.world.entity.player.Player, needed: ItemStack): ItemStack { + if (player.isCreative) return needed.copy() + var remaining = needed.count + val result = needed.copy().also { it.count = 0 } + for (i in 0 until player.inventory.containerSize) { + val slot = player.inventory.getItem(i) + if (!slot.isEmpty && ItemStack.isSameItemSameComponents(slot, needed)) { + val take = minOf(remaining, slot.count) + slot.shrink(take) + if (slot.isEmpty) player.inventory.setItem(i, ItemStack.EMPTY) + result.grow(take) + remaining -= take + if (remaining <= 0) break + } + } + return if (result.count > 0) result else ItemStack.EMPTY + } + + private fun findSafeY(level: ServerLevel, targetPos: BlockPos): Double { + for (dy in 1..4) { + val checkPos = targetPos.above(dy) + if (!level.getBlockState(checkPos).isSuffocating(level, checkPos)) return checkPos.y.toDouble() + } + return targetPos.y + 1.0 + } +} diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/state/TransportBeeStateMachine.kt b/src/main/kotlin/de/devin/cbbees/content/bee/state/TransportBeeStateMachine.kt new file mode 100644 index 0000000..4ea3148 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/bee/state/TransportBeeStateMachine.kt @@ -0,0 +1,519 @@ +package de.devin.cbbees.content.bee.state + +import de.devin.cbbees.config.CBBeesConfig +import de.devin.cbbees.content.bee.BeeSeparation +import de.devin.cbbees.content.bee.MechanicalBumbleBeeEntity +import de.devin.cbbees.content.bee.debug.BeeDebug +import de.devin.cbbees.content.bee.server.ServerBeeData +import de.devin.cbbees.content.bee.server.ServerBeeManager +import de.devin.cbbees.content.beehive.MechanicalBeehiveBlockEntity +import de.devin.cbbees.content.domain.network.ServerBeeNetworkManager +import de.devin.cbbees.content.domain.task.TransportTask +import de.devin.cbbees.items.AllItems as CBeesItems +import net.minecraft.server.level.ServerLevel +import net.minecraft.world.entity.item.ItemEntity +import net.minecraft.world.item.ItemStack +import net.minecraft.world.phys.Vec3 +import net.neoforged.neoforge.items.ItemHandlerHelper +import java.util.UUID + +/** + * O(1) state machine for transport (bumble) bees. + */ +object TransportBeeStateMachine { + + fun tickData(bee: ServerBeeData, level: ServerLevel, gameTime: Long) { + // Flight drain + if (bee.velocity.lengthSqr() > 0.001) bee.consumeSpring(CBBeesConfig.springDrainFlight.get()) + + // Orphan check + if (bee.hiveInstance == null) { + val hive = ServerBeeNetworkManager.findHive( + bee.hiveId ?: UUID.randomUUID() + ) + if (hive != null) bee.hiveInstance = hive + else { + bee.orphanedTicks++ + if (bee.orphanedTicks >= 200) { + level.addFreshEntity(ItemEntity( + level, bee.pos.x, bee.pos.y, bee.pos.z, + ItemStack(CBeesItems.MECHANICAL_BUMBLE_BEE.get()) + )) + bee.springTension = -9999f + } + return + } + } + + // Spring empty → recharge + if (bee.transportState != TransportBeeState.RECHARGING + && bee.transportState != TransportBeeState.ENTERING_HIVE + && bee.transportState != TransportBeeState.ORPHANED + && bee.springTension <= 0f + ) { + bee.transportState = TransportBeeState.RECHARGING + bee.walkTarget = bee.hivePos + } + + when (bee.transportState) { + TransportBeeState.FLYING_TO_SOURCE -> { + val task = bee.transportTask ?: run { goHome(bee); return } + if (bee.blockPosition().closerThan(task.sourcePos, 2.5)) { + bee.transportState = TransportBeeState.PICKING_UP; bee.walkTarget = null + } else bee.walkTarget = task.sourcePos + } + TransportBeeState.PICKING_UP -> { + if (!bee.isInventoryEmpty()) { + val task = bee.transportTask ?: run { goHome(bee); return } + bee.transportState = TransportBeeState.FLYING_TO_TARGET; bee.walkTarget = task.targetPos; return + } + val task = bee.transportTask ?: run { goHome(bee); return } + if (!bee.blockPosition().closerThan(task.sourcePos, 2.5)) { + bee.transportState = TransportBeeState.FLYING_TO_SOURCE; bee.walkTarget = task.sourcePos; return + } + val network = bee.network() ?: run { bee.transportTask = null; goHome(bee); return } + val port = network.transportPortsByPos[task.sourcePos]?.takeIf { it.isValidProvider() } + ?: run { bee.transportTask = null; goHome(bee); return } + port.releaseReservation(bee.id) + val targetPort = network.transportPortsByPos[task.targetPos]?.takeIf { it.isValidRequester() } + val targetHandler = targetPort?.getItemHandler(targetPort.world) + var pickedUp = false + task.items.forEach { item -> + if (bee.isInventoryFull()) return@forEach + var toExtract = item + targetHandler?.let { th -> + val sim = ItemHandlerHelper.insertItemStacked(th, item.copy(), true) + val canAccept = item.count - sim.count + if (canAccept <= 0) return@forEach + if (canAccept < item.count) toExtract = item.copyWithCount(canAccept) + } + if (port.hasItemStack(toExtract) && port.removeItemStack(toExtract)) { + val rem = bee.addToInventory(toExtract.copy()) + if (!rem.isEmpty) port.addItemStack(rem) + pickedUp = true + bee.consumeSpring(CBBeesConfig.springDrainPickup.get()) + } + } + if (pickedUp) { bee.transportState = TransportBeeState.FLYING_TO_TARGET; bee.walkTarget = task.targetPos } + else { bee.transportTask = null; goHome(bee) } + } + TransportBeeState.FLYING_TO_TARGET -> { + val task = bee.transportTask ?: run { goHome(bee); return } + if (bee.blockPosition().closerThan(task.targetPos, 2.5)) { + bee.transportState = TransportBeeState.DEPOSITING; bee.walkTarget = null + } else bee.walkTarget = task.targetPos + } + TransportBeeState.DEPOSITING -> { + val task = bee.transportTask ?: run { goHome(bee); return } + if (bee.isInventoryEmpty()) { bee.transportTask = null; goHome(bee); return } + if (!bee.blockPosition().closerThan(task.targetPos, 2.5)) { + bee.transportState = TransportBeeState.FLYING_TO_TARGET; bee.walkTarget = task.targetPos; return + } + val network = bee.network() + val port = if (task.returningOverflow) network?.transportPortsByPos?.get(task.targetPos)?.takeIf { it.isValidProvider() } + else network?.transportPortsByPos?.get(task.targetPos)?.takeIf { it.isValidRequester() } + val items = bee.getInventoryContents().map { it.copy() } + val overflow = mutableListOf() + if (port != null) { + items.forEach { item -> val rem = port.addItemStack(item); bee.removeFromInventory(item, item.count); bee.consumeSpring(CBBeesConfig.springDrainDeposit.get()); if (!rem.isEmpty) overflow.add(rem) } + } else items.forEach { overflow.add(it); bee.removeFromInventory(it, it.count) } + if (overflow.isNotEmpty() && !task.returningOverflow) { + overflow.forEach { item -> val rem = bee.addToInventory(item); if (!rem.isEmpty) level.addFreshEntity(ItemEntity(level, bee.pos.x, bee.pos.y, bee.pos.z, rem)) } + bee.transportTask = TransportTask(task.targetPos, task.sourcePos, overflow, returningOverflow = true) + bee.transportState = TransportBeeState.FLYING_TO_TARGET; bee.walkTarget = task.sourcePos + } else { + overflow.forEach { level.addFreshEntity(ItemEntity(level, bee.pos.x, bee.pos.y, bee.pos.z, it)) } + bee.transportTask = null; goHome(bee) + } + } + TransportBeeState.FLYING_HOME -> { + val hive = bee.hiveInstance ?: return + if (bee.blockPosition().closerThan(hive.pos, 4.0)) { + bee.transportState = TransportBeeState.ENTERING_HIVE; bee.walkTarget = null + } else bee.walkTarget = hive.pos + } + TransportBeeState.ENTERING_HIVE -> { + val hive = bee.hiveInstance ?: run { bee.transportState = TransportBeeState.ORPHANED; bee.orphanedTicks = 0; return } + if (!bee.blockPosition().closerThan(hive.pos, 4.0)) { bee.transportState = TransportBeeState.FLYING_HOME; return } + hive.chargeReturnFuel(1.0f - bee.springTension, bee.getBeeContext()) + val beeItem = ItemStack(CBeesItems.MECHANICAL_BUMBLE_BEE.get()) + if (hive.returnBee(beeItem)) { + (hive as? MechanicalBeehiveBlockEntity)?.onBeeRemovedById(bee.id) + ServerBeeManager.removeBee(bee.id) + } else { + bee.hiveEntryRetries++ + if (bee.hiveEntryRetries >= 3) { + level.addFreshEntity(ItemEntity(level, bee.pos.x, bee.pos.y, bee.pos.z, beeItem)) + bee.dropInventory() + (hive as? MechanicalBeehiveBlockEntity)?.onBeeRemovedById(bee.id) + ServerBeeManager.removeBee(bee.id) + } + } + } + TransportBeeState.RECHARGING -> { + val hive = bee.hiveInstance ?: run { bee.transportState = TransportBeeState.ORPHANED; bee.orphanedTicks = 0; return } + if (bee.rechargeFinishTick >= 0) { + if (gameTime >= bee.rechargeFinishTick) { + bee.springTension = 1.0f; bee.rechargeFinishTick = -1 + if (bee.transportTask != null) { bee.transportState = TransportBeeState.FLYING_TO_SOURCE; bee.walkTarget = bee.transportTask?.sourcePos } + else goHome(bee) + } + return + } + if (bee.blockPosition().closerThan(hive.pos, 4.0)) { + bee.rechargeFinishTick = gameTime + hive.rechargeSpring(bee.getBeeContext()) + } else bee.walkTarget = hive.pos + } + TransportBeeState.ORPHANED -> { /* handled above */ } + } + } + + private fun goHome(bee: ServerBeeData) { + bee.transportState = TransportBeeState.FLYING_HOME + bee.walkTarget = bee.hivePos + } + + fun tick(bee: MechanicalBumbleBeeEntity, level: ServerLevel, gameTime: Long) { + // ── Cross-cutting ── + tickFlightDrain(bee) + if (bee.rechargeFinishTick < 0) BeeSeparation.applyFlightOffset(bee) + if (tickOrphanedCheck(bee, gameTime)) return + tickStuckCheck(bee, level, gameTime) + tickNavigation(bee) + + // Spring empty → recharge + if (bee.beeState != TransportBeeState.RECHARGING + && bee.beeState != TransportBeeState.ENTERING_HIVE + && bee.beeState != TransportBeeState.ORPHANED + && bee.springTension <= 0f + ) { + bee.beeState = TransportBeeState.RECHARGING + bee.walkTargetPos = null + } + + when (bee.beeState) { + TransportBeeState.FLYING_TO_SOURCE -> tickFlyingToSource(bee) + TransportBeeState.PICKING_UP -> tickPickingUp(bee, level, gameTime) + TransportBeeState.FLYING_TO_TARGET -> tickFlyingToTarget(bee) + TransportBeeState.DEPOSITING -> tickDepositing(bee, level, gameTime) + TransportBeeState.FLYING_HOME -> tickFlyingHome(bee) + TransportBeeState.ENTERING_HIVE -> tickEnteringHive(bee, level) + TransportBeeState.RECHARGING -> tickRecharging(bee, gameTime) + TransportBeeState.ORPHANED -> { /* handled in tickOrphanedCheck */ } + } + } + + // ── Cross-cutting ── + + private fun tickFlightDrain(bee: MechanicalBumbleBeeEntity) { + if (bee.deltaMovement.lengthSqr() > 0.001) { + bee.consumeSpring(CBBeesConfig.springDrainFlight.get()) + } + } + + private fun tickNavigation(bee: MechanicalBumbleBeeEntity) { + val target = bee.walkTargetPos ?: return + if (bee.navigation.isDone) { + bee.navigation.moveTo(target.x + 0.5, target.y.toDouble(), target.z + 0.5, 1.0) + } + } + + private fun tickOrphanedCheck(bee: MechanicalBumbleBeeEntity, gameTime: Long): Boolean { + if (bee.beeState == TransportBeeState.ORPHANED) { + bee.orphanedTicks++ + if (bee.orphanedTicks % 40 == 1) { + val adopted = bee.tryAdoptHive() + if (adopted != null) { + bee.orphanedTicks = 0 + bee.beeState = TransportBeeState.FLYING_HOME + return false + } + } + if (bee.orphanedTicks >= 200) { + bee.dropBeeItemAndDiscard("orphaned for 200 ticks") + } + return true + } + + if (bee.hiveInstance == null) { + val hive = bee.beehive() + if (hive == null) { + bee.beeState = TransportBeeState.ORPHANED + bee.orphanedTicks = 0 + bee.walkTargetPos = null + return true + } + bee.hiveInstance = hive + } + return false + } + + private fun tickStuckCheck(bee: MechanicalBumbleBeeEntity, level: ServerLevel, gameTime: Long) { + val target = bee.walkTargetPos ?: run { bee.stuckData.reset(); return } + val data = bee.stuckData + val targetVec = Vec3.atCenterOf(target) + + val dx = targetVec.x - data.lastTargetX + val dz = targetVec.z - data.lastTargetZ + if (dx * dx + dz * dz > 1.0) { + data.lastTargetX = targetVec.x + data.lastTargetY = targetVec.y + data.lastTargetZ = targetVec.z + data.lastDistanceToTarget = bee.position().distanceTo(targetVec) + data.ticksSinceCheck = 0 + data.failedChecks = 0 + return + } + + data.ticksSinceCheck++ + if (data.ticksSinceCheck < 20) return + data.ticksSinceCheck = 0 + + val currentDist = bee.position().distanceTo(targetVec) + if (data.lastDistanceToTarget - currentDist < 1.5) { + data.failedChecks++ + if (data.failedChecks < 3) bee.navigation.recomputePath() + } else { + data.failedChecks = 0 + } + data.lastDistanceToTarget = currentDist + + if (data.failedChecks >= 3) { + for (dy in 1..4) { + val cp = target.above(dy) + if (!level.getBlockState(cp).isSuffocating(level, cp)) { + bee.teleportTo(target.x + 0.5, cp.y.toDouble(), target.z + 0.5) + break + } + } + data.reset() + bee.walkTargetPos = null + } + } + + // ── State handlers ── + + private fun tickFlyingToSource(bee: MechanicalBumbleBeeEntity) { + val task = bee.transportTask ?: run { transitionToHome(bee); return } + if (bee.blockPosition().closerThan(task.sourcePos, bee.workRange)) { + bee.beeState = TransportBeeState.PICKING_UP + bee.walkTargetPos = null + } else { + bee.walkTargetPos = task.sourcePos + } + } + + private fun tickPickingUp(bee: MechanicalBumbleBeeEntity, level: ServerLevel, gameTime: Long) { + if (!bee.isInventoryEmpty()) { + // Already have items, go deliver + val task = bee.transportTask ?: run { transitionToHome(bee); return } + bee.beeState = TransportBeeState.FLYING_TO_TARGET + bee.walkTargetPos = task.targetPos + return + } + + val task = bee.transportTask ?: run { transitionToHome(bee); return } + if (!bee.blockPosition().closerThan(task.sourcePos, bee.workRange)) { + bee.beeState = TransportBeeState.FLYING_TO_SOURCE + bee.walkTargetPos = task.sourcePos + return + } + + val network = bee.network() ?: run { + BeeDebug.logForEntity(bee, "Bumble", "No network — clearing task") + bee.transportTask = null + transitionToHome(bee) + return + } + + val port = network.transportPortsByPos[task.sourcePos]?.takeIf { it.isValidProvider() } + if (port == null) { + BeeDebug.logForEntity(bee, "Bumble", "Source port gone — clearing task") + bee.transportTask = null + transitionToHome(bee) + return + } + + port.releaseReservation(bee.uuid) + + val targetPort = network.transportPortsByPos[task.targetPos]?.takeIf { it.isValidRequester() } + val targetHandler = targetPort?.getItemHandler(targetPort.world) + + var pickedUp = false + for (item in task.items) { + if (bee.isInventoryFull()) break + var toExtract = item + if (targetHandler != null) { + val simulated = ItemHandlerHelper.insertItemStacked(targetHandler, item.copy(), true) + val canAccept = item.count - simulated.count + if (canAccept <= 0) continue + if (canAccept < item.count) toExtract = item.copyWithCount(canAccept) + } + if (port.hasItemStack(toExtract) && port.removeItemStack(toExtract)) { + val remainder = bee.addToInventory(toExtract.copy()) + if (!remainder.isEmpty) port.addItemStack(remainder) + pickedUp = true + bee.consumeSpring(CBBeesConfig.springDrainPickup.get()) + } + } + + if (pickedUp) { + bee.beeState = TransportBeeState.FLYING_TO_TARGET + bee.walkTargetPos = task.targetPos + } else { + BeeDebug.logForEntity(bee, "Bumble", "No items at source — clearing task") + bee.transportTask = null + transitionToHome(bee) + } + } + + private fun tickFlyingToTarget(bee: MechanicalBumbleBeeEntity) { + val task = bee.transportTask ?: run { transitionToHome(bee); return } + if (bee.blockPosition().closerThan(task.targetPos, bee.workRange)) { + bee.beeState = TransportBeeState.DEPOSITING + bee.walkTargetPos = null + } else { + bee.walkTargetPos = task.targetPos + } + } + + private fun tickDepositing(bee: MechanicalBumbleBeeEntity, level: ServerLevel, gameTime: Long) { + val task = bee.transportTask ?: run { transitionToHome(bee); return } + if (bee.isInventoryEmpty()) { + bee.transportTask = null + transitionToHome(bee) + return + } + if (!bee.blockPosition().closerThan(task.targetPos, bee.workRange)) { + bee.beeState = TransportBeeState.FLYING_TO_TARGET + bee.walkTargetPos = task.targetPos + return + } + + val isReturning = task.returningOverflow + val network = bee.network() + val port = if (isReturning) { + network?.transportPortsByPos?.get(task.targetPos)?.takeIf { it.isValidProvider() } + } else { + network?.transportPortsByPos?.get(task.targetPos)?.takeIf { it.isValidRequester() } + } + + val items = bee.getInventoryContents().map { it.copy() } + val overflow = mutableListOf() + + if (port != null) { + for (item in items) { + val remainder = port.addItemStack(item) + bee.removeFromInventory(item, item.count) + bee.consumeSpring(CBBeesConfig.springDrainDeposit.get()) + if (!remainder.isEmpty) overflow.add(remainder) + } + } else { + for (item in items) { + overflow.add(item) + bee.removeFromInventory(item, item.count) + } + } + + if (overflow.isNotEmpty() && !isReturning) { + for (item in overflow) { + val rem = bee.addToInventory(item) + if (!rem.isEmpty) level.addFreshEntity(ItemEntity(level, bee.x, bee.y, bee.z, rem)) + } + bee.transportTask = TransportTask( + sourcePos = task.targetPos, + targetPos = task.sourcePos, + items = overflow, + returningOverflow = true + ) + bee.beeState = TransportBeeState.FLYING_TO_TARGET + bee.walkTargetPos = task.sourcePos + } else { + if (overflow.isNotEmpty()) { + for (item in overflow) level.addFreshEntity(ItemEntity(level, bee.x, bee.y, bee.z, item)) + } + bee.transportTask = null + transitionToHome(bee) + } + } + + private fun tickFlyingHome(bee: MechanicalBumbleBeeEntity) { + val hive = bee.hiveInstance ?: return + if (bee.blockPosition().closerThan(hive.pos, 4.0)) { + bee.beeState = TransportBeeState.ENTERING_HIVE + bee.walkTargetPos = null + } else { + bee.walkTargetPos = hive.pos + } + } + + private fun tickEnteringHive(bee: MechanicalBumbleBeeEntity, level: ServerLevel) { + val hive = bee.hiveInstance ?: run { + bee.beeState = TransportBeeState.ORPHANED + bee.orphanedTicks = 0 + return + } + + if (!bee.blockPosition().closerThan(hive.pos, 4.0)) { + bee.beeState = TransportBeeState.FLYING_HOME + return + } + + val deficit = 1.0f - bee.springTension + val ctx = bee.getBeeContextForRecharge() + hive.chargeReturnFuel(deficit, ctx) + + val success = hive.returnBee(bee.beeItemStack()) + if (success) { + bee.discard() + } else { + bee.hiveEntryRetries++ + if (bee.hiveEntryRetries >= 3) { + bee.dropBeeItemAndDiscard("hive full — max retries") + return + } + val adopted = bee.tryAdoptHive(exclude = hive) + if (adopted == null) { + bee.dropBeeItemAndDiscard("hive full — no other hive") + } else { + bee.hiveInstance = adopted + bee.hivePos = adopted.pos + bee.beeState = TransportBeeState.FLYING_HOME + } + } + } + + private fun tickRecharging(bee: MechanicalBumbleBeeEntity, gameTime: Long) { + val hive = bee.hiveInstance ?: run { + bee.beeState = TransportBeeState.ORPHANED + bee.orphanedTicks = 0 + return + } + + if (bee.rechargeFinishTick >= 0) { + if (gameTime >= bee.rechargeFinishTick) { + bee.springTension = 1.0f + bee.rechargeFinishTick = -1 + if (bee.transportTask != null) { + bee.beeState = TransportBeeState.FLYING_TO_SOURCE + } else { + transitionToHome(bee) + } + } + return + } + + if (bee.blockPosition().closerThan(hive.pos, 4.0)) { + val ctx = bee.getBeeContextForRecharge() + val rechargeTicks = hive.rechargeSpring(ctx) + bee.rechargeFinishTick = gameTime + rechargeTicks + } else { + bee.walkTargetPos = hive.pos + } + } + + private fun transitionToHome(bee: MechanicalBumbleBeeEntity) { + bee.beeState = TransportBeeState.FLYING_HOME + bee.walkTargetPos = bee.hiveInstance?.pos + } +} diff --git a/src/main/kotlin/de/devin/cbbees/content/beehive/MechanicalBeehiveBlockEntity.kt b/src/main/kotlin/de/devin/cbbees/content/beehive/MechanicalBeehiveBlockEntity.kt index 0af10ba..4ebfa1a 100644 --- a/src/main/kotlin/de/devin/cbbees/content/beehive/MechanicalBeehiveBlockEntity.kt +++ b/src/main/kotlin/de/devin/cbbees/content/beehive/MechanicalBeehiveBlockEntity.kt @@ -3,7 +3,8 @@ package de.devin.cbbees.content.beehive import com.simibubi.create.api.equipment.goggles.IHaveGoggleInformation import com.simibubi.create.content.kinetics.base.KineticBlockEntity import de.devin.cbbees.content.bee.* -import de.devin.cbbees.content.bee.brain.BeeMemoryModules +import de.devin.cbbees.content.bee.server.BeeType +import de.devin.cbbees.content.bee.server.ServerBeeManager import de.devin.cbbees.content.domain.GlobalJobPool import de.devin.cbbees.content.domain.beehive.BeeHive import de.devin.cbbees.content.domain.network.ServerBeeNetworkManager @@ -12,9 +13,6 @@ import de.devin.cbbees.content.domain.task.TaskBatch import de.devin.cbbees.config.CBBeesConfig import de.devin.cbbees.content.upgrades.BeeContext import de.devin.cbbees.items.AllItems -import de.devin.cbbees.registry.AllEffects -import de.devin.cbbees.registry.AllEntityTypes -import net.minecraft.world.effect.MobEffectInstance import net.createmod.catnip.lang.Lang import net.createmod.catnip.lang.LangNumberFormat import net.minecraft.ChatFormatting @@ -24,6 +22,7 @@ import net.minecraft.nbt.CompoundTag import net.minecraft.nbt.ListTag import net.minecraft.nbt.Tag import net.minecraft.network.chat.Component +import net.minecraft.server.level.ServerLevel import net.minecraft.world.entity.ai.memory.WalkTarget import net.minecraft.world.item.ItemStack import net.minecraft.world.level.Level @@ -86,60 +85,58 @@ class MechanicalBeehiveBlockEntity(type: BlockEntityType<*>, pos: BlockPos, stat } fun spawnBee(beeItem: ItemStack, batch: TaskBatch): Boolean { - val bee = MechanicalBeeEntity(AllEntityTypes.MECHANICAL_BEE.get(), level!!).apply { - setPos(Vec3.atCenterOf(blockPos.above()).add(BeeSeparation.spawnOffset(level!!.random))) - this.networkId = this@MechanicalBeehiveBlockEntity.network().id - this.springTension = 1.0f - } - - bee.setHomeId(this.homeId) - bee.getBrain().setMemory(BeeMemoryModules.HIVE_POS.get(), this.blockPos) - bee.getBrain().setMemory(BeeMemoryModules.HIVE_INSTANCE.get(), Optional.of(this)) - bee.getBrain().setMemory(BeeMemoryModules.CURRENT_TASK.get(), batch) - - batch.assignToRobot(bee) - - // Apply hive speed bonus as a MobEffect (scales with RPM) + val spawnPos = Vec3.atCenterOf(blockPos.above()).add( + BeeSeparation.spawnOffset(level!!.random) + ) val ctx = getBeeContext() - if (ctx.speedMultiplier > 1.0) { - val amplifier = ((ctx.speedMultiplier - 1.0) / 0.20).toInt().coerceIn(0, 9) - bee.addEffect(MobEffectInstance(AllEffects.HIVE_SPEED, -1, amplifier, false, false, false)) - } - level!!.addFreshEntity(bee) - activeBeesByJob.getOrPut(batch.job.jobId) { mutableSetOf() }.add(bee.uuid) - sync() + val bee = ServerBeeManager.spawnConstructionBee( + hive = this, + batch = batch, + networkId = network().id, + spawnPos = spawnPos, + context = ctx, + beeType = batch.beeType, + ) + activeBeesByJob.getOrPut(batch.job.jobId) { mutableSetOf() }.add(bee.id) + sync() return true } override fun acceptBatch(batch: TaskBatch): Boolean { if (getAvailableBeeCount() <= 0) return false - if (getActiveBeeCountForJob(batch.job.jobId) >= getBeeContext().maxActiveRobots) return false + if (getActiveBeeCountForJob(batch.job.jobId) >= getBeeContext().maxActiveBees) return false this.setChanged() - // Only consume regular Mechanical Bees for construction batches — - // Bumble Bees are transport-only and dispatched by TransportDispatcher - val beeItem = consumeBeeOfType(MechanicalBeeItem::class.java) + // Pickup batches use bumble bees; everything else uses construction bees + val beeItemClass = if (batch.beeType == BeeType.TRANSPORT) + MechanicalBumbleBeeItem::class.java + else + MechanicalBeeItem::class.java + val beeItem = consumeBeeOfType(beeItemClass) if (beeItem.isEmpty) return false return spawnBee(beeItem, batch) } - override fun notifyTaskCompleted(task: BeeTask, bee: MechanicalBeeEntity): TaskBatch? { + override fun notifyTaskCompleted(task: BeeTask, beeId: UUID): TaskBatch? { val nextBatch = GlobalJobPool.workBacklog(this) - - nextBatch?.assignToRobot(bee) - + nextBatch?.assignToBee(beeId, level!!.gameTime) return nextBatch } override fun onBeeRemoved(bee: net.minecraft.world.entity.Entity) { + onBeeRemovedById(bee.uuid) + } + + /** UUID-based removal for non-entity bees. */ + fun onBeeRemovedById(beeId: UUID) { var found = false val iter = activeBeesByJob.iterator() while (iter.hasNext()) { val (_, bees) = iter.next() - if (bees.remove(bee.uuid)) { + if (bees.remove(beeId)) { found = true if (bees.isEmpty()) iter.remove() break @@ -161,10 +158,10 @@ class MechanicalBeehiveBlockEntity(type: BlockEntityType<*>, pos: BlockPos, stat val beeIter = bees.iterator() while (beeIter.hasNext()) { val beeId = beeIter.next() - // Check if entity exists by scanning loaded entities - val entity = (level as? net.minecraft.server.level.ServerLevel) - ?.getEntity(beeId) - if (entity == null || !entity.isAlive) { + // Check both entity system AND non-entity ServerBeeManager + val existsAsEntity = (level as? ServerLevel)?.getEntity(beeId)?.isAlive == true + val existsAsData = ServerBeeManager.getBee(beeId) != null + if (!existsAsEntity && !existsAsData) { beeIter.remove() cleaned = true } @@ -271,28 +268,28 @@ class MechanicalBeehiveBlockEntity(type: BlockEntityType<*>, pos: BlockPos, stat val rpm = abs(getSpeed()) val speedDiv = CBBeesConfig.hiveRpmSpeedDivisor.get() - val robotDiv = CBBeesConfig.hiveRpmRobotDivisor.get() + val beeDivisor = CBBeesConfig.hiveRpmBeeDivisor.get() val baseRange = CBBeesConfig.hiveBaseRange.get() val rangePerRpm = CBBeesConfig.hiveRangePerRpm.get() if (rpm > 0) { context.speedMultiplier *= (1.0 + (rpm / speedDiv)) context.springEfficiency = 1.0 + (rpm / speedDiv) - val extraRobots = (rpm / robotDiv).toInt() - context.maxActiveRobots = maxOf( - context.maxActiveRobots + extraRobots, - CBBeesConfig.minActiveRobotsAtRpm.get() + val extraBees = (rpm / beeDivisor).toInt() + context.maxActiveBees = maxOf( + context.maxActiveBees + extraBees, + CBBeesConfig.minActiveBeesAtRpm.get() ) context.workRange = baseRange + rpm * rangePerRpm - context.maxContributedBees += extraRobots + context.maxContributedBees += extraBees } else { - context.maxActiveRobots = 0 + context.maxActiveBees = 0 context.maxContributedBees = 0 context.workRange = 0.0 } // Cap at config limit - context.maxActiveRobots = minOf(context.maxActiveRobots, CBBeesConfig.maxBeesPerHive.get()) + context.maxActiveBees = minOf(context.maxActiveBees, CBBeesConfig.maxBeesPerHive.get()) return context } @@ -409,7 +406,7 @@ class MechanicalBeehiveBlockEntity(type: BlockEntityType<*>, pos: BlockPos, stat .style(ChatFormatting.GRAY) .add( Lang.builder("cbbees") - .text(ChatFormatting.GOLD, LangNumberFormat.format(context.maxActiveRobots.toDouble())) + .text(ChatFormatting.GOLD, LangNumberFormat.format(context.maxActiveBees.toDouble())) ) .forGoggles(tooltip, 1) diff --git a/src/main/kotlin/de/devin/cbbees/content/beehive/client/BeehiveRangeHandler.kt b/src/main/kotlin/de/devin/cbbees/content/beehive/client/BeehiveRangeHandler.kt index eda5923..ec61976 100644 --- a/src/main/kotlin/de/devin/cbbees/content/beehive/client/BeehiveRangeHandler.kt +++ b/src/main/kotlin/de/devin/cbbees/content/beehive/client/BeehiveRangeHandler.kt @@ -1,5 +1,6 @@ package de.devin.cbbees.content.beehive.client +import com.simibubi.create.AllItems import com.simibubi.create.AllSpecialTextures import com.simibubi.create.foundation.utility.RaycastHelper import de.devin.cbbees.config.CBBeesClientConfig @@ -40,6 +41,13 @@ object BeehiveRangeHandler { return } + // Only show range indicators while holding a wrench + val holdingWrench = AllItems.WRENCH.isIn(player.mainHandItem) || AllItems.WRENCH.isIn(player.offhandItem) + if (!holdingWrench) { + clearAllSlots() + return + } + // Raycast to see if we are looking at a Network Component val trace = RaycastHelper.rayTraceRange(level, player, 20.0) if (trace != null && trace.type == HitResult.Type.BLOCK) { diff --git a/src/main/kotlin/de/devin/cbbees/content/deployer/DeployMode.kt b/src/main/kotlin/de/devin/cbbees/content/deployer/DeployMode.kt new file mode 100644 index 0000000..5a6defe --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/deployer/DeployMode.kt @@ -0,0 +1,6 @@ +package de.devin.cbbees.content.deployer + +enum class DeployMode { + ABSOLUTE, + RELATIVE +} diff --git a/src/main/kotlin/de/devin/cbbees/content/deployer/ProgrammedSchematicItem.kt b/src/main/kotlin/de/devin/cbbees/content/deployer/ProgrammedSchematicItem.kt new file mode 100644 index 0000000..6d60cb1 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/deployer/ProgrammedSchematicItem.kt @@ -0,0 +1,96 @@ +package de.devin.cbbees.content.deployer + +import de.devin.cbbees.registry.AllDataComponents +import net.minecraft.ChatFormatting +import net.minecraft.network.chat.Component +import net.minecraft.world.item.Item +import net.minecraft.world.item.ItemStack +import net.minecraft.world.item.TooltipFlag + +class ProgrammedSchematicItem(properties: Properties) : Item(properties) { + + override fun isFoil(stack: ItemStack): Boolean { + return stack.has(AllDataComponents.SCHEMATIC_PROGRAM) + } + + override fun appendHoverText( + stack: ItemStack, + context: TooltipContext, + tooltipComponents: MutableList, + tooltipFlag: TooltipFlag + ) { + val program = stack.get(AllDataComponents.SCHEMATIC_PROGRAM) ?: return + + when (program) { + is SchematicProgram.Construction -> { + tooltipComponents.add( + Component.translatable("cbbees.program.construction", program.displayName()) + .withStyle(ChatFormatting.GOLD) + ) + val a = program.anchor + tooltipComponents.add( + Component.translatable("cbbees.program.anchor", a.x, a.y, a.z) + .withStyle(ChatFormatting.GRAY) + ) + if (program.rotation != net.minecraft.world.level.block.Rotation.NONE) { + tooltipComponents.add( + Component.translatable("cbbees.program.rotation", program.rotation.name) + .withStyle(ChatFormatting.DARK_GRAY) + ) + } + if (program.mirror != net.minecraft.world.level.block.Mirror.NONE) { + tooltipComponents.add( + Component.translatable("cbbees.program.mirror", program.mirror.name) + .withStyle(ChatFormatting.DARK_GRAY) + ) + } + } + is SchematicProgram.Deconstruction -> { + tooltipComponents.add( + Component.translatable("cbbees.program.deconstruction") + .withStyle(ChatFormatting.GOLD) + ) + val c1 = program.corner1 + val c2 = program.corner2 + tooltipComponents.add( + Component.translatable("cbbees.program.corner1", c1.x, c1.y, c1.z) + .withStyle(ChatFormatting.GRAY) + ) + tooltipComponents.add( + Component.translatable("cbbees.program.corner2", c2.x, c2.y, c2.z) + .withStyle(ChatFormatting.GRAY) + ) + val sizeX = kotlin.math.abs(c1.x - c2.x) + 1 + val sizeY = kotlin.math.abs(c1.y - c2.y) + 1 + val sizeZ = kotlin.math.abs(c1.z - c2.z) + 1 + tooltipComponents.add( + Component.translatable("cbbees.program.dimensions", sizeX, sizeY, sizeZ) + .withStyle(ChatFormatting.DARK_GRAY) + ) + } + is SchematicProgram.Pickup -> { + tooltipComponents.add( + Component.translatable("cbbees.program.pickup") + .withStyle(ChatFormatting.GOLD) + ) + val c1 = program.corner1 + val c2 = program.corner2 + tooltipComponents.add( + Component.translatable("cbbees.program.corner1", c1.x, c1.y, c1.z) + .withStyle(ChatFormatting.GRAY) + ) + tooltipComponents.add( + Component.translatable("cbbees.program.corner2", c2.x, c2.y, c2.z) + .withStyle(ChatFormatting.GRAY) + ) + val sizeX = kotlin.math.abs(c1.x - c2.x) + 1 + val sizeY = kotlin.math.abs(c1.y - c2.y) + 1 + val sizeZ = kotlin.math.abs(c1.z - c2.z) + 1 + tooltipComponents.add( + Component.translatable("cbbees.program.dimensions", sizeX, sizeY, sizeZ) + .withStyle(ChatFormatting.DARK_GRAY) + ) + } + } + } +} diff --git a/src/main/kotlin/de/devin/cbbees/content/deployer/SchematicDeployerBlock.kt b/src/main/kotlin/de/devin/cbbees/content/deployer/SchematicDeployerBlock.kt new file mode 100644 index 0000000..1e8d57a --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/deployer/SchematicDeployerBlock.kt @@ -0,0 +1,172 @@ +package de.devin.cbbees.content.deployer + +import com.mojang.serialization.MapCodec +import com.simibubi.create.content.equipment.wrench.IWrenchable +import com.simibubi.create.foundation.block.IBE +import de.devin.cbbees.registry.AllBlockEntityTypes +import de.devin.cbbees.registry.AllDataComponents +import de.devin.cbbees.content.deployer.client.SchematicDeployerScreen +import net.minecraft.client.Minecraft +import net.minecraft.core.BlockPos +import net.minecraft.core.Direction +import net.minecraft.world.Containers +import net.minecraft.world.InteractionHand +import net.minecraft.world.InteractionResult +import net.minecraft.world.ItemInteractionResult +import net.minecraft.world.entity.player.Player +import net.minecraft.world.item.ItemStack +import net.minecraft.world.item.context.BlockPlaceContext +import net.minecraft.world.level.Level +import net.minecraft.world.level.block.Block +import net.minecraft.world.level.block.HorizontalDirectionalBlock +import net.minecraft.world.level.block.entity.BlockEntityType +import net.minecraft.world.level.block.state.BlockState +import net.minecraft.world.level.block.state.StateDefinition +import net.minecraft.world.level.block.state.properties.BlockStateProperties +import net.minecraft.world.level.block.state.properties.BooleanProperty +import net.minecraft.world.level.BlockGetter +import net.minecraft.world.phys.BlockHitResult +import net.minecraft.world.phys.shapes.CollisionContext +import net.minecraft.world.phys.shapes.VoxelShape + +class SchematicDeployerBlock(properties: Properties) : + HorizontalDirectionalBlock(properties), + IBE, + IWrenchable { + + companion object { + val CODEC: MapCodec = simpleCodec(::SchematicDeployerBlock) + val POWERED: BooleanProperty = BlockStateProperties.POWERED + private val SHAPE: VoxelShape = box(0.0, 0.0, 0.0, 16.0, 18.0, 16.0) + } + + override fun getShape(state: BlockState, level: BlockGetter, pos: BlockPos, context: CollisionContext): VoxelShape = SHAPE + + init { + registerDefaultState( + defaultBlockState() + .setValue(FACING, Direction.NORTH) + .setValue(POWERED, false) + ) + } + + override fun createBlockStateDefinition(builder: StateDefinition.Builder) { + builder.add(FACING, POWERED) + } + + override fun codec(): MapCodec = CODEC + + override fun getBlockEntityClass(): Class = + SchematicDeployerBlockEntity::class.java + + override fun getBlockEntityType(): BlockEntityType = + AllBlockEntityTypes.SCHEMATIC_DEPLOYER.get() + + override fun getStateForPlacement(context: BlockPlaceContext): BlockState { + return defaultBlockState() + .setValue(FACING, context.horizontalDirection.opposite) + .setValue(POWERED, context.level.hasNeighborSignal(context.clickedPos)) + } + + override fun neighborChanged( + state: BlockState, + level: Level, + pos: BlockPos, + neighborBlock: Block, + neighborPos: BlockPos, + movedByPiston: Boolean + ) { + if (level.isClientSide) return + + val wasPowered = state.getValue(POWERED) + val isPowered = level.hasNeighborSignal(pos) + + if (wasPowered != isPowered) { + level.setBlock(pos, state.setValue(POWERED, isPowered), 3) + + // Rising edge: deploy + if (!wasPowered && isPowered) { + withBlockEntityDo(level, pos) { be -> be.deploy() } + } + } + } + + override fun useItemOn( + stack: ItemStack, + state: BlockState, + level: Level, + pos: BlockPos, + player: Player, + hand: InteractionHand, + hitResult: BlockHitResult + ): ItemInteractionResult { + val be = getBlockEntity(level, pos) ?: return ItemInteractionResult.PASS_TO_DEFAULT_BLOCK_INTERACTION + + // Insert programmed schematic + if (!stack.isEmpty && stack.has(AllDataComponents.SCHEMATIC_PROGRAM) && be.heldItem.isEmpty) { + if (level.isClientSide) return ItemInteractionResult.SUCCESS + be.heldItem = stack.copyWithCount(1) + be.resetSettings() + stack.shrink(1) + be.setChanged() + be.sendData() + return ItemInteractionResult.SUCCESS + } + + // Fall through to useWithoutItem for GUI opening / extraction + return ItemInteractionResult.PASS_TO_DEFAULT_BLOCK_INTERACTION + } + + override fun useWithoutItem( + state: BlockState, + level: Level, + pos: BlockPos, + player: Player, + hitResult: BlockHitResult + ): InteractionResult { + val be = getBlockEntity(level, pos) ?: return InteractionResult.PASS + + // Shift+right-click to extract (server only) + if (player.isShiftKeyDown && !be.heldItem.isEmpty) { + if (level.isClientSide) return InteractionResult.SUCCESS + if (!player.inventory.add(be.heldItem.copy())) { + player.drop(be.heldItem.copy(), false) + } + be.heldItem = ItemStack.EMPTY + be.resetSettings() + be.setChanged() + be.sendData() + return InteractionResult.SUCCESS + } + + // Right-click without item: open GUI (when deployer has a schematic) + if (!player.isShiftKeyDown && !be.heldItem.isEmpty) { + if (level.isClientSide) { + openScreen(be) + } + return InteractionResult.SUCCESS + } + + return InteractionResult.PASS + } + + private fun openScreen(be: SchematicDeployerBlockEntity) { + Minecraft.getInstance().setScreen(SchematicDeployerScreen(be)) + } + + override fun hasAnalogOutputSignal(state: BlockState): Boolean = true + + override fun getAnalogOutputSignal(state: BlockState, level: Level, pos: BlockPos): Int { + return getBlockEntity(level, pos)?.getComparatorOutput() ?: 0 + } + + override fun onRemove(state: BlockState, level: Level, pos: BlockPos, newState: BlockState, movedByPiston: Boolean) { + if (!state.`is`(newState.block)) { + val be = getBlockEntity(level, pos) + if (be != null && !be.heldItem.isEmpty) { + Containers.dropItemStack(level, pos.x.toDouble(), pos.y.toDouble(), pos.z.toDouble(), be.heldItem) + } + } + super.onRemove(state, level, pos, newState, movedByPiston) + } +} diff --git a/src/main/kotlin/de/devin/cbbees/content/deployer/SchematicDeployerBlockEntity.kt b/src/main/kotlin/de/devin/cbbees/content/deployer/SchematicDeployerBlockEntity.kt new file mode 100644 index 0000000..04aa52b --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/deployer/SchematicDeployerBlockEntity.kt @@ -0,0 +1,441 @@ +package de.devin.cbbees.content.deployer + +import com.simibubi.create.foundation.blockEntity.SmartBlockEntity +import com.simibubi.create.foundation.blockEntity.behaviour.BlockEntityBehaviour +import de.devin.cbbees.content.bee.debug.BeeDebug +import de.devin.cbbees.content.domain.GlobalJobPool +import de.devin.cbbees.content.domain.action.impl.PlaceBlockAction +import de.devin.cbbees.content.domain.job.BeeJob +import de.devin.cbbees.content.domain.job.ClientBatchInfo +import de.devin.cbbees.content.domain.job.ClientJobInfo +import de.devin.cbbees.content.domain.job.ClientNetworkInfo +import de.devin.cbbees.content.domain.job.HiveSnapshot +import de.devin.cbbees.content.domain.job.JobStatus +import de.devin.cbbees.content.domain.job.SchematicPlacement +import de.devin.cbbees.content.domain.task.TaskBatch +import de.devin.cbbees.content.schematics.SchematicJobKey +import de.devin.cbbees.network.HiveJobsSyncPacket +import de.devin.cbbees.registry.AllDataComponents +import net.minecraft.core.BlockPos +import net.minecraft.core.HolderLookup +import net.minecraft.core.particles.ParticleTypes +import net.minecraft.nbt.CompoundTag +import net.minecraft.server.level.ServerLevel +import net.minecraft.sounds.SoundEvents +import net.minecraft.sounds.SoundSource +import net.minecraft.world.item.ItemStack +import net.minecraft.world.level.block.Mirror +import net.minecraft.world.level.block.Rotation +import net.minecraft.world.level.block.entity.BlockEntityType +import net.minecraft.world.level.block.state.BlockState +import net.neoforged.neoforge.network.PacketDistributor +import java.util.UUID + +class SchematicDeployerBlockEntity( + type: BlockEntityType<*>, + pos: BlockPos, + state: BlockState +) : SmartBlockEntity(type, pos, state) { + + /** + * Deploy result states — synced to client for renderer feedback. + * Cleared automatically after a short duration. + */ + enum class DeployResult { + NONE, + SUCCESS, + FAIL_BUSY, + FAIL_EMPTY, + FAIL_NO_TASKS + } + + var heldItem: ItemStack = ItemStack.EMPTY + var activeJobId: UUID? = null + private set + private var lazyTickCounter = 0 + + /** + * Deploy mode: ABSOLUTE uses stored coordinates as-is, + * RELATIVE computes target from deployer position + relativeOffset. + */ + var deployMode: DeployMode = DeployMode.ABSOLUTE + + /** + * Offset from the deployer to the build reference point (in RELATIVE mode). + * The actual build anchor = deployerPos + relativeOffset. + */ + var relativeOffset: BlockPos = BlockPos.ZERO + + /** Rotation override for relative mode (construction only). */ + var relativeRotation: Rotation = Rotation.NONE + + /** Mirror override for relative mode (construction only). */ + var relativeMirror: Mirror = Mirror.NONE + + /** Resets deploy settings when the held item is manually changed. */ + fun resetSettings() { + deployMode = DeployMode.ABSOLUTE + relativeOffset = BlockPos.ZERO + relativeRotation = Rotation.NONE + relativeMirror = Mirror.NONE + } + + /** Current deploy result — synced to client via sendData(). */ + var deployResult: DeployResult = DeployResult.NONE + private set + + /** Server tick when the result was set — used for auto-clear. */ + private var resultSetTick: Long = 0 + + override fun addBehaviours(behaviours: MutableList) { + // No special behaviours needed + } + + override fun tick() { + super.tick() + val level = level ?: return + if (level.isClientSide) return + + // Auto-clear deploy result after 2 seconds (40 ticks) + if (deployResult != DeployResult.NONE) { + if (level.gameTime - resultSetTick >= 40) { + deployResult = DeployResult.NONE + sendData() + } + } + + // Lazy tick every ~2 seconds (40 ticks) + lazyTickCounter++ + if (lazyTickCounter >= 40) { + lazyTickCounter = 0 + checkJobStatus() + } + } + + private fun checkJobStatus() { + val jobId = activeJobId ?: return + val level = level ?: return + + // Check if the job is completed or cancelled + val job = GlobalJobPool.getAllJobs().find { it.jobId == jobId } + if (job == null || job.status == JobStatus.COMPLETED || job.status == JobStatus.CANCELLED) { + val reason = if (job == null) "job not found" else "status=${job.status}" + BeeDebug.logAtPos(level, blockPos, "Deployer", "Job ${jobId.toString().substring(0, 8)} finished ($reason), ready for next deploy") + activeJobId = null + syncJobClearedToClients() + setChanged() + sendData() + level.updateNeighbourForOutputSignal(blockPos, blockState.block) + } + } + + /** + * Returns the comparator signal strength for this deployer. + * 0 = empty, 1 = has schematic (idle), 8 = job active, 15 = just deployed + */ + fun getComparatorOutput(): Int { + if (deployResult == DeployResult.SUCCESS) return 15 + if (activeJobId != null) { + val job = GlobalJobPool.getAllJobs().find { it.jobId == activeJobId } + if (job != null && job.status != JobStatus.COMPLETED && job.status != JobStatus.CANCELLED) return 8 + } + if (!heldItem.isEmpty && heldItem.has(AllDataComponents.SCHEMATIC_PROGRAM)) return 1 + return 0 + } + + private fun setResult(result: DeployResult) { + deployResult = result + resultSetTick = level?.gameTime ?: 0 + sendData() + } + + /** + * Resolves the final program to deploy based on the current deploy mode. + * ABSOLUTE: uses stored coordinates as-is + * RELATIVE: computes target from deployer position + offset + */ + private fun resolveProgram(storedProgram: SchematicProgram): SchematicProgram { + return when (deployMode) { + DeployMode.ABSOLUTE -> storedProgram + DeployMode.RELATIVE -> { + val targetPoint = blockPos.offset(relativeOffset) + when (storedProgram) { + is SchematicProgram.Construction -> { + // In relative mode, override anchor, rotation, and mirror + storedProgram.copy( + anchor = targetPoint, + rotation = relativeRotation, + mirror = relativeMirror + ) + } + is SchematicProgram.Deconstruction -> { + val referencePoint = BlockPos( + (storedProgram.corner1.x + storedProgram.corner2.x) / 2, + (storedProgram.corner1.y + storedProgram.corner2.y) / 2, + (storedProgram.corner1.z + storedProgram.corner2.z) / 2 + ) + val delta = targetPoint.subtract(referencePoint) + storedProgram.relocate(delta) + } + is SchematicProgram.Pickup -> { + val referencePoint = BlockPos( + (storedProgram.corner1.x + storedProgram.corner2.x) / 2, + (storedProgram.corner1.y + storedProgram.corner2.y) / 2, + (storedProgram.corner1.z + storedProgram.corner2.z) / 2 + ) + val delta = targetPoint.subtract(referencePoint) + storedProgram.relocate(delta) + } + } + } + } + } + + /** + * Attempts to deploy the programmed schematic as a bee job. + * Called on redstone rising edge. + */ + fun deploy() { + val level = level ?: return + if (level.isClientSide) return + val serverLevel = level as? ServerLevel ?: return + val center = blockPos.above() + val cx = center.x + 0.5 + val cy = center.y + 0.5 + val cz = center.z + 0.5 + val tag = "Deployer" + + BeeDebug.logAtPos(level, blockPos, tag, "Redstone signal received, attempting deploy (mode=$deployMode)") + + // Check if a job is already active + if (activeJobId != null) { + val existingJob = GlobalJobPool.getAllJobs().find { it.jobId == activeJobId } + if (existingJob != null && existingJob.status != JobStatus.COMPLETED && existingJob.status != JobStatus.CANCELLED) { + // Busy — orange flame particles + villager "no" sound + BeeDebug.logAtPos(level, blockPos, tag, "FAIL: Job already active (id=${activeJobId.toString().substring(0, 8)}, status=${existingJob.status})") + serverLevel.sendParticles(ParticleTypes.SMOKE, cx, cy, cz, 6, 0.2, 0.1, 0.2, 0.02) + level.playSound(null, blockPos, SoundEvents.VILLAGER_NO, SoundSource.BLOCKS, 0.6f, 1.2f) + setResult(DeployResult.FAIL_BUSY) + return + } + BeeDebug.logAtPos(level, blockPos, tag, "Previous job finished, clearing activeJobId") + activeJobId = null + } + + if (heldItem.isEmpty || !heldItem.has(AllDataComponents.SCHEMATIC_PROGRAM)) { + // Empty — puff of smoke + click + BeeDebug.logAtPos(level, blockPos, tag, "FAIL: No programmed schematic inserted") + serverLevel.sendParticles(ParticleTypes.SMOKE, cx, cy, cz, 4, 0.15, 0.1, 0.15, 0.01) + level.playSound(null, blockPos, SoundEvents.DISPENSER_FAIL, SoundSource.BLOCKS, 0.5f, 1.2f) + setResult(DeployResult.FAIL_EMPTY) + return + } + + val storedProgram = heldItem.get(AllDataComponents.SCHEMATIC_PROGRAM)!! + val program = resolveProgram(storedProgram) + + val programDesc = when (program) { + is SchematicProgram.Construction -> "Construction(${program.schematicName}, anchor=${program.anchor}, rot=${program.rotation}, mirror=${program.mirror})" + is SchematicProgram.Deconstruction -> "Deconstruction(${program.corner1} to ${program.corner2})" + is SchematicProgram.Pickup -> "Pickup(${program.corner1} to ${program.corner2})" + } + BeeDebug.logAtPos(level, blockPos, tag, "Resolved program: $programDesc") + + val jobId = UUID.randomUUID() + val centerPos = when (program) { + is SchematicProgram.Construction -> program.anchor + is SchematicProgram.Deconstruction -> BlockPos( + (program.corner1.x + program.corner2.x) / 2, + (program.corner1.y + program.corner2.y) / 2, + (program.corner1.z + program.corner2.z) / 2 + ) + is SchematicProgram.Pickup -> BlockPos( + (program.corner1.x + program.corner2.x) / 2, + (program.corner1.y + program.corner2.y) / 2, + (program.corner1.z + program.corner2.z) / 2 + ) + } + + val job = BeeJob(jobId, centerPos, level).apply { + uniquenessKey = SchematicJobKey( + UUID(blockPos.asLong(), blockPos.asLong()), + "deployer_${blockPos.x}_${blockPos.y}_${blockPos.z}", + centerPos.x, centerPos.y, centerPos.z + ) + if (program is SchematicProgram.Construction) { + schematicPlacement = SchematicPlacement( + file = program.schematicName, + anchor = program.anchor, + rotation = program.rotation, + mirror = program.mirror + ) + } + } + + val batches = program.generateBatches(level, job) + if (batches.isEmpty()) { + // No tasks — small smoke + note block bass + BeeDebug.logAtPos(level, blockPos, tag, "FAIL: No tasks generated (all blocks already placed or area empty)") + serverLevel.sendParticles(ParticleTypes.SMOKE, cx, cy, cz, 4, 0.15, 0.1, 0.15, 0.01) + level.playSound(null, blockPos, SoundEvents.NOTE_BLOCK_BASS.value(), SoundSource.BLOCKS, 0.6f, 0.5f) + setResult(DeployResult.FAIL_NO_TASKS) + return + } + + // Inject this deployer's config into any deployer blocks in the build + // so they are placed already programmed with the same schematic + if (!heldItem.isEmpty) { + injectDeployerConfig(batches, tag) + } + + job.addBatches(batches) + activeJobId = job.jobId + GlobalJobPool.dispatchNewJob(job) + + // Immediately sync job info to nearby clients so ghost blocks render + syncJobToClients(job, batches) + + val totalTasks = batches.sumOf { it.tasks.size } + BeeDebug.logAtPos(level, blockPos, tag, "SUCCESS: Deployed job ${jobId.toString().substring(0, 8)} with ${batches.size} batch(es), $totalTasks task(s)") + + // Success — happy particles + bell chime + serverLevel.sendParticles(ParticleTypes.HAPPY_VILLAGER, cx, cy, cz, 8, 0.3, 0.2, 0.3, 1.0) + serverLevel.sendParticles(ParticleTypes.END_ROD, cx, cy + 0.25, cz, 4, 0.05, 0.4, 0.05, 0.01) + level.playSound(null, blockPos, SoundEvents.NOTE_BLOCK_BELL.value(), SoundSource.BLOCKS, 1.0f, 1.5f) + setResult(DeployResult.SUCCESS) + + setChanged() + level.updateNeighbourForOutputSignal(blockPos, blockState.block) + } + + /** + * Injects this deployer's held item and deploy settings into any + * [PlaceBlockAction] tasks that place a [SchematicDeployerBlock], + * so child deployers are already programmed when placed by bees. + */ + private fun injectDeployerConfig(batches: List, debugTag: String) { + val registries = level?.registryAccess() ?: return + val configNbt = CompoundTag().apply { + put("HeldItem", heldItem.save(registries)) + putInt("DeployMode", deployMode.ordinal) + putLong("RelativeOffset", relativeOffset.asLong()) + putInt("RelativeRotation", relativeRotation.ordinal) + putInt("RelativeMirror", relativeMirror.ordinal) + } + + var injected = 0 + for (batch in batches) { + for (task in batch.tasks) { + val action = task.action + if (action is PlaceBlockAction && action.blockState.block is SchematicDeployerBlock) { + val existingTag = action.blockEntityTag + if (existingTag != null) { + existingTag.merge(configNbt) + } + injected++ + } + } + } + if (injected > 0) { + BeeDebug.logAtPos(level!!, blockPos, debugTag, "Injected deployer config into $injected child deployer task(s)") + } + } + + /** + * Sends the newly deployed job to nearby clients via [HiveJobsSyncPacket] + * so [ConstructionRenderer] can render ghost blocks immediately. + */ + private fun syncJobToClients(job: BeeJob, batches: List) { + val level = level as? ServerLevel ?: return + val clientBatches = batches.map { b -> + ClientBatchInfo( + status = b.status.name, + target = b.targetPosition, + required = emptyList(), + assignedBeeIds = emptyList(), + ghostBlocks = emptyMap() + ) + } + val clientJob = ClientJobInfo( + jobId = job.jobId, + name = job.jobId.toString().substring(0, 6).uppercase(), + status = job.status.name, + completed = 0, + total = job.tasks.size, + reason = null, + batches = clientBatches, + schematicPlacement = job.schematicPlacement + ) + val snapshot = HiveSnapshot(ClientNetworkInfo("Deployer", 0, 0, 0), listOf(clientJob)) + val packet = HiveJobsSyncPacket(blockPos, snapshot) + for (player in level.server.playerList.players) { + if (player.level() == level && player.blockPosition().closerThan(blockPos, 128.0)) { + PacketDistributor.sendToPlayer(player, packet) + } + } + } + + /** + * Sends an empty snapshot for this deployer's position to clear stale job entries + * from [ClientJobCache] when the job completes. + */ + private fun syncJobClearedToClients() { + val level = level as? ServerLevel ?: return + val snapshot = HiveSnapshot(ClientNetworkInfo("Deployer", 0, 0, 0), emptyList()) + val packet = HiveJobsSyncPacket(blockPos, snapshot) + for (player in level.server.playerList.players) { + if (player.level() == level && player.blockPosition().closerThan(blockPos, 128.0)) { + PacketDistributor.sendToPlayer(player, packet) + } + } + } + + override fun write(tag: CompoundTag, registries: HolderLookup.Provider, clientPacket: Boolean) { + super.write(tag, registries, clientPacket) + if (!heldItem.isEmpty) { + tag.put("HeldItem", heldItem.save(registries)) + } + activeJobId?.let { tag.putUUID("ActiveJobId", it) } + tag.putInt("DeployMode", deployMode.ordinal) + tag.putLong("RelativeOffset", relativeOffset.asLong()) + tag.putInt("RelativeRotation", relativeRotation.ordinal) + tag.putInt("RelativeMirror", relativeMirror.ordinal) + if (clientPacket) { + tag.putInt("DeployResult", deployResult.ordinal) + } + } + + override fun read(tag: CompoundTag, registries: HolderLookup.Provider, clientPacket: Boolean) { + super.read(tag, registries, clientPacket) + heldItem = if (tag.contains("HeldItem")) { + ItemStack.parseOptional(registries, tag.getCompound("HeldItem")) + } else { + ItemStack.EMPTY + } + activeJobId = if (tag.hasUUID("ActiveJobId")) tag.getUUID("ActiveJobId") else null + deployMode = if (tag.contains("DeployMode")) { + DeployMode.entries.getOrElse(tag.getInt("DeployMode")) { DeployMode.ABSOLUTE } + } else { + DeployMode.ABSOLUTE + } + relativeOffset = if (tag.contains("RelativeOffset")) { + BlockPos.of(tag.getLong("RelativeOffset")) + } else { + BlockPos.ZERO + } + relativeRotation = if (tag.contains("RelativeRotation")) { + Rotation.entries.getOrElse(tag.getInt("RelativeRotation")) { Rotation.NONE } + } else { + Rotation.NONE + } + relativeMirror = if (tag.contains("RelativeMirror")) { + Mirror.entries.getOrElse(tag.getInt("RelativeMirror")) { Mirror.NONE } + } else { + Mirror.NONE + } + if (clientPacket && tag.contains("DeployResult")) { + val ord = tag.getInt("DeployResult") + deployResult = DeployResult.entries.getOrElse(ord) { DeployResult.NONE } + } + } +} diff --git a/src/main/kotlin/de/devin/cbbees/content/deployer/SchematicDeployerItemHandler.kt b/src/main/kotlin/de/devin/cbbees/content/deployer/SchematicDeployerItemHandler.kt new file mode 100644 index 0000000..cc3c599 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/deployer/SchematicDeployerItemHandler.kt @@ -0,0 +1,58 @@ +package de.devin.cbbees.content.deployer + +import de.devin.cbbees.registry.AllDataComponents +import net.minecraft.world.item.ItemStack +import net.neoforged.neoforge.items.IItemHandler + +/** + * Single-slot IItemHandler for the Schematic Deployer, enabling hopper/pipe automation. + * Only accepts items with a SCHEMATIC_PROGRAM data component. + */ +class SchematicDeployerItemHandler( + private val be: SchematicDeployerBlockEntity +) : IItemHandler { + + override fun getSlots(): Int = 1 + + override fun getStackInSlot(slot: Int): ItemStack { + if (slot != 0) return ItemStack.EMPTY + return be.heldItem + } + + override fun insertItem(slot: Int, stack: ItemStack, simulate: Boolean): ItemStack { + if (slot != 0) return stack + if (!be.heldItem.isEmpty) return stack + if (!stack.has(AllDataComponents.SCHEMATIC_PROGRAM)) return stack + + if (!simulate) { + be.heldItem = stack.copyWithCount(1) + be.resetSettings() + be.setChanged() + be.sendData() + } + + val remainder = stack.copy() + remainder.shrink(1) + return remainder + } + + override fun extractItem(slot: Int, amount: Int, simulate: Boolean): ItemStack { + if (slot != 0) return ItemStack.EMPTY + if (be.heldItem.isEmpty) return ItemStack.EMPTY + + val extracted = be.heldItem.copy() + if (!simulate) { + be.heldItem = ItemStack.EMPTY + be.resetSettings() + be.setChanged() + be.sendData() + } + return extracted + } + + override fun getSlotLimit(slot: Int): Int = 1 + + override fun isItemValid(slot: Int, stack: ItemStack): Boolean { + return slot == 0 && stack.has(AllDataComponents.SCHEMATIC_PROGRAM) + } +} diff --git a/src/main/kotlin/de/devin/cbbees/content/deployer/SchematicProgram.kt b/src/main/kotlin/de/devin/cbbees/content/deployer/SchematicProgram.kt new file mode 100644 index 0000000..ec8f131 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/deployer/SchematicProgram.kt @@ -0,0 +1,232 @@ +package de.devin.cbbees.content.deployer + +import com.mojang.serialization.Codec +import com.mojang.serialization.MapCodec +import com.mojang.serialization.codecs.RecordCodecBuilder +import com.simibubi.create.AllDataComponents +import de.devin.cbbees.content.domain.job.BeeJob +import de.devin.cbbees.content.domain.task.TaskBatch +import de.devin.cbbees.content.schematics.SchematicCreateBridge +import de.devin.cbbees.network.ensureSchematicUploaded +import net.minecraft.core.BlockPos +import net.minecraft.network.RegistryFriendlyByteBuf +import net.minecraft.network.codec.StreamCodec +import net.minecraft.world.item.ItemStack +import net.minecraft.world.level.Level +import net.minecraft.world.level.block.Mirror +import net.minecraft.world.level.block.Rotation + +/** + * Sealed class representing a programmed schematic — either a construction or deconstruction job. + * Stored as a data component on a [ProgrammedSchematicItem]. + */ +sealed class SchematicProgram { + + abstract fun generateBatches(level: Level, job: BeeJob): List + abstract fun displayName(): String + + /** + * Returns a copy of this program with all world coordinates offset by [delta]. + * Used by the Schematic Deployer for self-populating schematics — when bees + * recreate a deployer at a new position, the delta shifts the build target + * so the schematic is placed relative to the new deployer. + */ + abstract fun relocate(delta: BlockPos): SchematicProgram + + /** + * Construction program: loads a schematic file and generates build tasks. + */ + data class Construction( + val schematicName: String, + val anchor: BlockPos, + val rotation: Rotation, + val mirror: Mirror, + val owner: String + ) : SchematicProgram() { + + override fun generateBatches(level: Level, job: BeeJob): List { + ensureSchematicUploaded(owner, schematicName) + + val stack = ItemStack(com.simibubi.create.AllItems.SCHEMATIC.get()) + stack.set(AllDataComponents.SCHEMATIC_FILE, schematicName) + stack.set(AllDataComponents.SCHEMATIC_OWNER, owner) + stack.set(AllDataComponents.SCHEMATIC_DEPLOYED, true) + stack.set(AllDataComponents.SCHEMATIC_ANCHOR, anchor) + stack.set(AllDataComponents.SCHEMATIC_ROTATION, rotation) + stack.set(AllDataComponents.SCHEMATIC_MIRROR, mirror) + + val bridge = SchematicCreateBridge(level) + if (!bridge.loadSchematic(stack)) return emptyList() + return bridge.generateBuildTasks(job) + } + + override fun displayName(): String = schematicName.removeSuffix(".nbt") + + override fun relocate(delta: BlockPos): Construction { + if (delta == BlockPos.ZERO) return this + return copy(anchor = anchor.offset(delta)) + } + + companion object { + val CODEC: MapCodec = RecordCodecBuilder.mapCodec { instance -> + instance.group( + Codec.STRING.fieldOf("schematic_name").forGetter { it.schematicName }, + BlockPos.CODEC.fieldOf("anchor").forGetter { it.anchor }, + Rotation.CODEC.fieldOf("rotation").forGetter { it.rotation }, + Mirror.CODEC.fieldOf("mirror").forGetter { it.mirror }, + Codec.STRING.fieldOf("owner").forGetter { it.owner } + ).apply(instance, ::Construction) + } + + val STREAM_CODEC: StreamCodec = StreamCodec.of( + { buf, p -> + buf.writeUtf(p.schematicName) + buf.writeBlockPos(p.anchor) + buf.writeEnum(p.rotation) + buf.writeEnum(p.mirror) + buf.writeUtf(p.owner) + }, + { buf -> + Construction( + buf.readUtf(), + buf.readBlockPos(), + buf.readEnum(Rotation::class.java), + buf.readEnum(Mirror::class.java), + buf.readUtf() + ) + } + ) + } + } + + /** + * Pickup program: scans an area for loose [ItemEntity] objects and sends + * bumble bees to collect them and deposit at the nearest logistics port. + */ + data class Pickup( + val corner1: BlockPos, + val corner2: BlockPos, + ) : SchematicProgram() { + + override fun generateBatches(level: Level, job: BeeJob): List { + val bridge = SchematicCreateBridge(level) + return bridge.generatePickupBatches(corner1, corner2, job).batches + } + + override fun displayName(): String = "Item Pickup" + + override fun relocate(delta: BlockPos): Pickup { + if (delta == BlockPos.ZERO) return this + return copy(corner1 = corner1.offset(delta), corner2 = corner2.offset(delta)) + } + + companion object { + val CODEC: MapCodec = RecordCodecBuilder.mapCodec { instance -> + instance.group( + BlockPos.CODEC.fieldOf("corner1").forGetter { it.corner1 }, + BlockPos.CODEC.fieldOf("corner2").forGetter { it.corner2 } + ).apply(instance, ::Pickup) + } + + val STREAM_CODEC: StreamCodec = StreamCodec.of( + { buf, p -> + buf.writeBlockPos(p.corner1) + buf.writeBlockPos(p.corner2) + }, + { buf -> Pickup(buf.readBlockPos(), buf.readBlockPos()) } + ) + } + } + + /** + * Deconstruction program: removes blocks between two corners. + */ + data class Deconstruction( + val corner1: BlockPos, + val corner2: BlockPos + ) : SchematicProgram() { + + override fun generateBatches(level: Level, job: BeeJob): List { + val bridge = SchematicCreateBridge(level) + return bridge.generateRemovalTasks(corner1, corner2, job) + } + + override fun displayName(): String = "Deconstruction" + + override fun relocate(delta: BlockPos): Deconstruction { + if (delta == BlockPos.ZERO) return this + return copy(corner1 = corner1.offset(delta), corner2 = corner2.offset(delta)) + } + + companion object { + val CODEC: MapCodec = RecordCodecBuilder.mapCodec { instance -> + instance.group( + BlockPos.CODEC.fieldOf("corner1").forGetter { it.corner1 }, + BlockPos.CODEC.fieldOf("corner2").forGetter { it.corner2 } + ).apply(instance, ::Deconstruction) + } + + val STREAM_CODEC: StreamCodec = StreamCodec.of( + { buf, p -> + buf.writeBlockPos(p.corner1) + buf.writeBlockPos(p.corner2) + }, + { buf -> + Deconstruction(buf.readBlockPos(), buf.readBlockPos()) + } + ) + } + } + + companion object { + private const val TYPE_CONSTRUCTION = "construction" + private const val TYPE_DECONSTRUCTION = "deconstruction" + private const val TYPE_PICKUP = "pickup" + + val CODEC: Codec = Codec.STRING.fieldOf("type").codec().dispatch( + { program -> + when (program) { + is Construction -> TYPE_CONSTRUCTION + is Deconstruction -> TYPE_DECONSTRUCTION + is Pickup -> TYPE_PICKUP + } + }, + { type -> + when (type) { + TYPE_CONSTRUCTION -> Construction.CODEC + TYPE_DECONSTRUCTION -> Deconstruction.CODEC + TYPE_PICKUP -> Pickup.CODEC + else -> throw IllegalArgumentException("Unknown SchematicProgram type: $type") + } + } + ) + + val STREAM_CODEC: StreamCodec = object : StreamCodec { + override fun decode(buf: RegistryFriendlyByteBuf): SchematicProgram { + return when (buf.readUtf()) { + TYPE_CONSTRUCTION -> Construction.STREAM_CODEC.decode(buf) + TYPE_DECONSTRUCTION -> Deconstruction.STREAM_CODEC.decode(buf) + TYPE_PICKUP -> Pickup.STREAM_CODEC.decode(buf) + else -> throw IllegalArgumentException("Unknown SchematicProgram type") + } + } + + override fun encode(buf: RegistryFriendlyByteBuf, value: SchematicProgram) { + when (value) { + is Construction -> { + buf.writeUtf(TYPE_CONSTRUCTION) + Construction.STREAM_CODEC.encode(buf, value) + } + is Deconstruction -> { + buf.writeUtf(TYPE_DECONSTRUCTION) + Deconstruction.STREAM_CODEC.encode(buf, value) + } + is Pickup -> { + buf.writeUtf(TYPE_PICKUP) + Pickup.STREAM_CODEC.encode(buf, value) + } + } + } + } + } +} diff --git a/src/main/kotlin/de/devin/cbbees/content/deployer/client/DeployerPreviewRenderer.kt b/src/main/kotlin/de/devin/cbbees/content/deployer/client/DeployerPreviewRenderer.kt new file mode 100644 index 0000000..96c7f34 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/deployer/client/DeployerPreviewRenderer.kt @@ -0,0 +1,319 @@ +package de.devin.cbbees.content.deployer.client + +import com.mojang.blaze3d.systems.RenderSystem +import com.mojang.blaze3d.vertex.VertexConsumer +import com.simibubi.create.AllSpecialTextures +import com.simibubi.create.content.schematics.SchematicItem +import com.simibubi.create.content.schematics.client.SchematicRenderer +import de.devin.cbbees.CreateBuzzyBeez +import de.devin.cbbees.content.deployer.DeployMode +import de.devin.cbbees.content.deployer.SchematicDeployerBlockEntity +import de.devin.cbbees.content.deployer.SchematicProgram +import de.devin.cbbees.content.schematics.client.GhostSchematicRenderer +import de.devin.cbbees.registry.AllDataComponents +import de.devin.cbbees.util.ClientSide +import net.createmod.catnip.animation.AnimationTickHolder +import net.createmod.catnip.impl.client.render.ColoringVertexConsumer +import net.createmod.catnip.levelWrappers.SchematicLevel +import net.createmod.catnip.outliner.AABBOutline +import net.createmod.catnip.render.DefaultSuperRenderTypeBuffer +import net.createmod.catnip.render.SuperRenderTypeBuffer +import net.minecraft.client.Minecraft +import net.minecraft.client.renderer.RenderType +import net.minecraft.core.BlockPos +import net.minecraft.world.level.Level +import net.minecraft.world.level.block.Block +import net.minecraft.world.level.block.Mirror +import net.minecraft.world.level.block.Rotation +import net.minecraft.world.level.levelgen.structure.templatesystem.StructurePlaceSettings +import net.minecraft.world.phys.AABB +import net.minecraft.world.phys.BlockHitResult +import net.neoforged.api.distmarker.Dist +import net.neoforged.api.distmarker.OnlyIn +import net.neoforged.bus.api.SubscribeEvent +import net.neoforged.neoforge.client.event.RenderLevelStageEvent + +/** + * Renders a ghost block preview of the schematic when the player is looking + * at a Schematic Deployer that has a programmed schematic loaded. + * Also accounts for ABSOLUTE vs RELATIVE deploy mode. + */ +@ClientSide +@OnlyIn(Dist.CLIENT) +object DeployerPreviewRenderer { + + private const val GHOST_ALPHA = 0.4f + private const val CONSTRUCTION_COLOR = 0x6886c5 + private const val DECONSTRUCTION_COLOR = 0xc56868 + + private var cachedRenderer: SchematicRenderer? = null + private var cachedOutline: AABBOutline? = null + private var cachedDeployerPos: BlockPos? = null + private var cachedProgram: SchematicProgram? = null + private var cachedMode: DeployMode? = null + private var cachedOffset: BlockPos? = null + private var cachedRotation: Rotation? = null + private var cachedMirror: Mirror? = null + + private val chunkLayers = RenderType.chunkBufferLayers().toSet() + + private class TransparentBuffer( + private val delegate: SuperRenderTypeBuffer, + private val alpha: Float + ) : SuperRenderTypeBuffer { + private fun wrap(consumer: VertexConsumer): VertexConsumer = + ColoringVertexConsumer(consumer, 1f, 1f, 1f, alpha) + + override fun getBuffer(type: RenderType): VertexConsumer { + val redirected = if (type in RenderType.chunkBufferLayers()) RenderType.translucent() else type + return wrap(delegate.getBuffer(redirected)) + } + + override fun getEarlyBuffer(type: RenderType): VertexConsumer = wrap(delegate.getEarlyBuffer(type)) + override fun getLateBuffer(type: RenderType): VertexConsumer = wrap(delegate.getLateBuffer(type)) + override fun draw() = delegate.draw() + override fun draw(type: RenderType) = delegate.draw(type) + } + + @SubscribeEvent + @JvmStatic + fun onRenderLevel(event: RenderLevelStageEvent) { + if (event.stage != RenderLevelStageEvent.Stage.AFTER_TRANSLUCENT_BLOCKS) return + + val mc = Minecraft.getInstance() + val level = mc.level ?: return + val hitResult = mc.hitResult + + // Check if looking at a deployer block entity + val be = getTargetedDeployer(level, hitResult) + if (be == null || be.activeJobId != null) { + // Not looking at a deployer, or it has an active job + // (active jobs are rendered by ConstructionRenderer via ClientJobCache) + clearCache() + return + } + + val program = be.heldItem.get(AllDataComponents.SCHEMATIC_PROGRAM) + if (program == null) { + clearCache() + return + } + + // Resolve the effective program based on deploy mode + val effectiveProgram = resolveProgram(be, program) + + // Rebuild cache if anything changed + if (be.blockPos != cachedDeployerPos || program != cachedProgram + || be.deployMode != cachedMode || be.relativeOffset != cachedOffset + || be.relativeRotation != cachedRotation || be.relativeMirror != cachedMirror + ) { + rebuildCache(effectiveProgram, level) + cachedDeployerPos = be.blockPos + cachedProgram = program + cachedMode = be.deployMode + cachedOffset = be.relativeOffset + cachedRotation = be.relativeRotation + cachedMirror = be.relativeMirror + } + + val poseStack = event.poseStack + val camera = mc.gameRenderer.mainCamera.position + val superBuffer = DefaultSuperRenderTypeBuffer.getInstance() + + // Render ghost blocks (construction only) + cachedRenderer?.let { renderer -> + val transparentBuffer = TransparentBuffer(superBuffer, GHOST_ALPHA) + poseStack.pushPose() + poseStack.translate(-camera.x, -camera.y, -camera.z) + renderer.render(poseStack, transparentBuffer) + poseStack.popPose() + } + + // Render outline + cachedOutline?.let { outline -> + val pt = AnimationTickHolder.getPartialTicks() + outline.render(poseStack, superBuffer, camera, pt) + } + + superBuffer.draw() + RenderSystem.enableCull() + } + + private fun getTargetedDeployer(level: Level, hitResult: net.minecraft.world.phys.HitResult?): SchematicDeployerBlockEntity? { + if (hitResult !is BlockHitResult) return null + val be = level.getBlockEntity(hitResult.blockPos) + return be as? SchematicDeployerBlockEntity + } + + private fun resolveProgram(be: SchematicDeployerBlockEntity, storedProgram: SchematicProgram): SchematicProgram { + return when (be.deployMode) { + DeployMode.ABSOLUTE -> storedProgram + DeployMode.RELATIVE -> { + val targetPoint = be.blockPos.offset(be.relativeOffset) + when (storedProgram) { + is SchematicProgram.Construction -> { + storedProgram.copy( + anchor = targetPoint, + rotation = be.relativeRotation, + mirror = be.relativeMirror + ) + } + is SchematicProgram.Deconstruction -> { + val referencePoint = BlockPos( + (storedProgram.corner1.x + storedProgram.corner2.x) / 2, + (storedProgram.corner1.y + storedProgram.corner2.y) / 2, + (storedProgram.corner1.z + storedProgram.corner2.z) / 2 + ) + val delta = targetPoint.subtract(referencePoint) + storedProgram.relocate(delta) + } + is SchematicProgram.Pickup -> { + val referencePoint = BlockPos( + (storedProgram.corner1.x + storedProgram.corner2.x) / 2, + (storedProgram.corner1.y + storedProgram.corner2.y) / 2, + (storedProgram.corner1.z + storedProgram.corner2.z) / 2 + ) + val delta = targetPoint.subtract(referencePoint) + storedProgram.relocate(delta) + } + } + } + } + } + + private fun rebuildCache(program: SchematicProgram, level: Level) { + cachedRenderer = null + cachedOutline = null + + when (program) { + is SchematicProgram.Construction -> { + cachedRenderer = buildConstructionRenderer(program, level) + cachedOutline = buildConstructionOutline(program, level) + } + is SchematicProgram.Deconstruction -> { + cachedOutline = buildDeconstructionOutline(program) + } + is SchematicProgram.Pickup -> { + cachedOutline = buildDeconstructionOutline( + SchematicProgram.Deconstruction(program.corner1, program.corner2) + ) + } + } + } + + private fun buildConstructionRenderer(program: SchematicProgram.Construction, clientLevel: Level): SchematicRenderer? { + try { + val mc = Minecraft.getInstance() + val player = mc.player ?: return null + + val fakeStack = com.simibubi.create.AllItems.SCHEMATIC.asStack() + fakeStack.set(com.simibubi.create.AllDataComponents.SCHEMATIC_FILE, program.schematicName) + fakeStack.set(com.simibubi.create.AllDataComponents.SCHEMATIC_OWNER, player.gameProfile.name) + fakeStack.set(com.simibubi.create.AllDataComponents.SCHEMATIC_ANCHOR, program.anchor) + fakeStack.set(com.simibubi.create.AllDataComponents.SCHEMATIC_ROTATION, program.rotation) + fakeStack.set(com.simibubi.create.AllDataComponents.SCHEMATIC_MIRROR, program.mirror) + fakeStack.set(com.simibubi.create.AllDataComponents.SCHEMATIC_DEPLOYED, true) + + val template = SchematicItem.loadSchematic(clientLevel, fakeStack) + if (template.size.x == 0 && template.size.y == 0 && template.size.z == 0) return null + + val schematicLevel = SchematicLevel(clientLevel) + val settings = StructurePlaceSettings() + settings.rotation = program.rotation + settings.mirror = program.mirror + + template.placeInWorld(schematicLevel, program.anchor, program.anchor, settings, schematicLevel.random, Block.UPDATE_CLIENTS) + + for (blockEntity in schematicLevel.blockEntities) { + blockEntity.setLevel(schematicLevel) + } + + // Remove blocks already placed in the real world + val blockMap = schematicLevel.blockMap + val toRemove = mutableListOf() + for ((localPos, state) in blockMap) { + val worldPos = localPos.offset(schematicLevel.anchor) + if (clientLevel.getBlockState(worldPos) == state) { + toRemove.add(localPos) + } + } + toRemove.forEach { blockMap.remove(it) } + + return GhostSchematicRenderer(schematicLevel) + } catch (e: Exception) { + CreateBuzzyBeez.LOGGER.debug("Failed to load schematic for deployer preview: ${program.schematicName}", e) + return null + } + } + + private fun buildConstructionOutline(program: SchematicProgram.Construction, clientLevel: Level): AABBOutline? { + try { + val mc = Minecraft.getInstance() + val player = mc.player ?: return null + + val fakeStack = com.simibubi.create.AllItems.SCHEMATIC.asStack() + fakeStack.set(com.simibubi.create.AllDataComponents.SCHEMATIC_FILE, program.schematicName) + fakeStack.set(com.simibubi.create.AllDataComponents.SCHEMATIC_OWNER, player.gameProfile.name) + fakeStack.set(com.simibubi.create.AllDataComponents.SCHEMATIC_ANCHOR, program.anchor) + fakeStack.set(com.simibubi.create.AllDataComponents.SCHEMATIC_ROTATION, program.rotation) + fakeStack.set(com.simibubi.create.AllDataComponents.SCHEMATIC_MIRROR, program.mirror) + fakeStack.set(com.simibubi.create.AllDataComponents.SCHEMATIC_DEPLOYED, true) + + val template = SchematicItem.loadSchematic(clientLevel, fakeStack) + val rawSize = template.size + if (rawSize.x == 0 && rawSize.y == 0 && rawSize.z == 0) return null + + val effectiveSize = when (program.rotation) { + Rotation.NONE, Rotation.CLOCKWISE_180 -> BlockPos(rawSize.x, rawSize.y, rawSize.z) + Rotation.CLOCKWISE_90, Rotation.COUNTERCLOCKWISE_90 -> BlockPos(rawSize.z, rawSize.y, rawSize.x) + } + val anchor = program.anchor + val bounds = AABB( + anchor.x.toDouble(), anchor.y.toDouble(), anchor.z.toDouble(), + (anchor.x + effectiveSize.x).toDouble(), + (anchor.y + effectiveSize.y).toDouble(), + (anchor.z + effectiveSize.z).toDouble() + ) + val outline = AABBOutline(bounds) + outline.params + .colored(CONSTRUCTION_COLOR) + .withFaceTexture(AllSpecialTextures.CHECKERED) + .lineWidth(1 / 16f) + return outline + } catch (_: Exception) { + return null + } + } + + private fun buildDeconstructionOutline(program: SchematicProgram.Deconstruction): AABBOutline { + val c1 = program.corner1 + val c2 = program.corner2 + val bounds = AABB( + minOf(c1.x, c2.x).toDouble(), + minOf(c1.y, c2.y).toDouble(), + minOf(c1.z, c2.z).toDouble(), + (maxOf(c1.x, c2.x) + 1).toDouble(), + (maxOf(c1.y, c2.y) + 1).toDouble(), + (maxOf(c1.z, c2.z) + 1).toDouble() + ) + val outline = AABBOutline(bounds) + outline.params + .colored(DECONSTRUCTION_COLOR) + .withFaceTexture(AllSpecialTextures.CHECKERED) + .lineWidth(1 / 16f) + return outline + } + + private fun clearCache() { + if (cachedDeployerPos != null) { + cachedRenderer = null + cachedOutline = null + cachedDeployerPos = null + cachedProgram = null + cachedMode = null + cachedOffset = null + cachedRotation = null + cachedMirror = null + } + } +} diff --git a/src/main/kotlin/de/devin/cbbees/content/deployer/client/ProgrammedSchematicRenderer.kt b/src/main/kotlin/de/devin/cbbees/content/deployer/client/ProgrammedSchematicRenderer.kt new file mode 100644 index 0000000..691bd95 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/deployer/client/ProgrammedSchematicRenderer.kt @@ -0,0 +1,170 @@ +package de.devin.cbbees.content.deployer.client + +import com.simibubi.create.AllSpecialTextures +import com.simibubi.create.content.schematics.SchematicItem +import de.devin.cbbees.content.deployer.SchematicProgram +import de.devin.cbbees.items.AllItems +import de.devin.cbbees.registry.AllDataComponents +import de.devin.cbbees.util.ClientSide +import net.createmod.catnip.outliner.Outliner +import net.minecraft.client.Minecraft +import net.minecraft.core.BlockPos +import net.minecraft.world.item.ItemStack +import net.minecraft.world.level.block.Mirror +import net.minecraft.world.level.block.Rotation +import net.minecraft.world.phys.AABB +import net.neoforged.api.distmarker.Dist +import net.neoforged.api.distmarker.OnlyIn +import net.neoforged.bus.api.SubscribeEvent +import net.neoforged.neoforge.client.event.RenderLevelStageEvent + +/** + * Renders a translucent bounding box outline in the world when the player + * is holding a Programmed Schematic, so they can see exactly where the + * construction or deconstruction will happen. + */ +@ClientSide +@OnlyIn(Dist.CLIENT) +object ProgrammedSchematicRenderer { + + private val outlineSlot = Any() + private const val CONSTRUCTION_COLOR = 0x6886c5 // blue + private const val DECONSTRUCTION_COLOR = 0xc56868 // red + private const val PICKUP_COLOR = 0x68c588 // green + + /** Cached AABB so we don't recompute every frame. */ + private var cachedBounds: AABB? = null + private var cachedColor: Int = CONSTRUCTION_COLOR + private var cachedProgram: SchematicProgram? = null + private var wasActive = false + + @SubscribeEvent + @JvmStatic + fun onRenderLevel(event: RenderLevelStageEvent) { + if (event.stage != RenderLevelStageEvent.Stage.AFTER_TRANSLUCENT_BLOCKS) return + + val mc = Minecraft.getInstance() + val player = mc.player ?: return + + val heldProgram = findHeldProgram(player.mainHandItem) + ?: findHeldProgram(player.offhandItem) + + if (heldProgram == null) { + if (wasActive) { + cachedBounds = null + cachedProgram = null + wasActive = false + } + return + } + + // Recompute bounds only when program changes + if (heldProgram != cachedProgram) { + cachedProgram = heldProgram + cachedBounds = computeBounds(heldProgram, mc) + cachedColor = when (heldProgram) { + is SchematicProgram.Construction -> CONSTRUCTION_COLOR + is SchematicProgram.Deconstruction -> DECONSTRUCTION_COLOR + is SchematicProgram.Pickup -> PICKUP_COLOR + } + } + + val bounds = cachedBounds ?: return + wasActive = true + + Outliner.getInstance() + .chaseAABB(outlineSlot, bounds) + .colored(cachedColor) + .withFaceTextures(AllSpecialTextures.CHECKERED, AllSpecialTextures.HIGHLIGHT_CHECKERED) + .lineWidth(1 / 16f) + } + + private fun findHeldProgram(stack: ItemStack): SchematicProgram? { + if (!AllItems.PROGRAMMED_SCHEMATIC.isIn(stack)) return null + return stack.get(AllDataComponents.SCHEMATIC_PROGRAM) + } + + private fun computeBounds(program: SchematicProgram, mc: Minecraft): AABB? { + return when (program) { + is SchematicProgram.Deconstruction -> { + val c1 = program.corner1 + val c2 = program.corner2 + AABB( + minOf(c1.x, c2.x).toDouble(), + minOf(c1.y, c2.y).toDouble(), + minOf(c1.z, c2.z).toDouble(), + (maxOf(c1.x, c2.x) + 1).toDouble(), + (maxOf(c1.y, c2.y) + 1).toDouble(), + (maxOf(c1.z, c2.z) + 1).toDouble() + ) + } + is SchematicProgram.Construction -> { + computeConstructionBounds(program, mc) + } + is SchematicProgram.Pickup -> { + val c1 = program.corner1 + val c2 = program.corner2 + AABB( + minOf(c1.x, c2.x).toDouble(), + minOf(c1.y, c2.y).toDouble(), + minOf(c1.z, c2.z).toDouble(), + (maxOf(c1.x, c2.x) + 1).toDouble(), + (maxOf(c1.y, c2.y) + 1).toDouble(), + (maxOf(c1.z, c2.z) + 1).toDouble() + ) + } + } + } + + /** + * Computes the world-space AABB for a construction program by loading + * the schematic template and applying rotation/mirror. + */ + private fun computeConstructionBounds(program: SchematicProgram.Construction, mc: Minecraft): AABB? { + val level = mc.level ?: return null + val player = mc.player ?: return null + + try { + // Build a temporary schematic stack to load the template + val fakeStack = com.simibubi.create.AllItems.SCHEMATIC.asStack() + fakeStack.set(com.simibubi.create.AllDataComponents.SCHEMATIC_FILE, program.schematicName) + fakeStack.set(com.simibubi.create.AllDataComponents.SCHEMATIC_OWNER, program.owner) + fakeStack.set(com.simibubi.create.AllDataComponents.SCHEMATIC_ANCHOR, program.anchor) + fakeStack.set(com.simibubi.create.AllDataComponents.SCHEMATIC_ROTATION, program.rotation) + fakeStack.set(com.simibubi.create.AllDataComponents.SCHEMATIC_MIRROR, program.mirror) + fakeStack.set(com.simibubi.create.AllDataComponents.SCHEMATIC_DEPLOYED, true) + + val template = SchematicItem.loadSchematic(level, fakeStack) + val rawSize = template.size + if (rawSize.x == 0 && rawSize.y == 0 && rawSize.z == 0) return null + + // Apply rotation to the size to get the effective dimensions + val effectiveSize = transformSize(rawSize.x, rawSize.y, rawSize.z, program.rotation) + val anchor = program.anchor + + // Compute world AABB from anchor + effective size + // Anchor is the min corner after Create's transform + val minX = anchor.x.toDouble() + val minY = anchor.y.toDouble() + val minZ = anchor.z.toDouble() + return AABB( + minX, minY, minZ, + minX + effectiveSize.x, minY + effectiveSize.y, minZ + effectiveSize.z + ) + } catch (_: Exception) { + // Schematic file not found on client — fall back to anchor point only + return null + } + } + + /** + * Transforms a schematic's size vector by rotation. + * 90/270 rotations swap X and Z dimensions. + */ + private fun transformSize(sizeX: Int, sizeY: Int, sizeZ: Int, rotation: Rotation): BlockPos { + return when (rotation) { + Rotation.NONE, Rotation.CLOCKWISE_180 -> BlockPos(sizeX, sizeY, sizeZ) + Rotation.CLOCKWISE_90, Rotation.COUNTERCLOCKWISE_90 -> BlockPos(sizeZ, sizeY, sizeX) + } + } +} diff --git a/src/main/kotlin/de/devin/cbbees/content/deployer/client/SchematicDeployerRenderer.kt b/src/main/kotlin/de/devin/cbbees/content/deployer/client/SchematicDeployerRenderer.kt new file mode 100644 index 0000000..3fd9b80 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/deployer/client/SchematicDeployerRenderer.kt @@ -0,0 +1,60 @@ +package de.devin.cbbees.content.deployer.client + +import com.mojang.blaze3d.vertex.PoseStack +import com.mojang.math.Axis +import com.simibubi.create.foundation.blockEntity.renderer.SmartBlockEntityRenderer +import de.devin.cbbees.content.deployer.SchematicDeployerBlockEntity +import net.createmod.catnip.animation.AnimationTickHolder +import net.minecraft.client.Minecraft +import net.minecraft.client.renderer.MultiBufferSource +import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider +import net.minecraft.world.item.ItemDisplayContext +import kotlin.math.sin + +/** + * Renders the held Programmed Schematic floating above the Schematic Deployer, + * gently bobbing and rotating so players can see at a glance what's loaded. + */ +class SchematicDeployerRenderer(context: BlockEntityRendererProvider.Context) : + SmartBlockEntityRenderer(context) { + + override fun renderSafe( + be: SchematicDeployerBlockEntity, + partialTicks: Float, + ms: PoseStack, + buffer: MultiBufferSource, + light: Int, + overlay: Int + ) { + super.renderSafe(be, partialTicks, ms, buffer, light, overlay) + + if (be.heldItem.isEmpty) return + + ms.pushPose() + + // Position: centered on the block, floating above it + val level = be.level ?: return + val time = AnimationTickHolder.getRenderTime(level) + val bob = sin((time / 12.0)) * (1.0 / 32.0) + ms.translate(0.5, 0.8 + bob, 0.5) + + // Slow rotation + ms.mulPose(Axis.YP.rotationDegrees(time * 2f)) + + // Scale down a bit + ms.scale(0.5f, 0.5f, 0.5f) + + Minecraft.getInstance().itemRenderer.renderStatic( + be.heldItem, + ItemDisplayContext.FIXED, + light, + overlay, + ms, + buffer, + level, + 0 + ) + + ms.popPose() + } +} diff --git a/src/main/kotlin/de/devin/cbbees/content/deployer/client/SchematicDeployerScreen.kt b/src/main/kotlin/de/devin/cbbees/content/deployer/client/SchematicDeployerScreen.kt new file mode 100644 index 0000000..b433708 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/deployer/client/SchematicDeployerScreen.kt @@ -0,0 +1,488 @@ +package de.devin.cbbees.content.deployer.client + +import com.simibubi.create.foundation.gui.AllGuiTextures +import com.simibubi.create.foundation.gui.AllIcons +import com.simibubi.create.foundation.gui.widget.IconButton +import com.simibubi.create.foundation.gui.widget.Label +import com.simibubi.create.foundation.gui.widget.ScrollInput +import de.devin.cbbees.content.deployer.DeployMode +import de.devin.cbbees.content.deployer.SchematicDeployerBlockEntity +import de.devin.cbbees.content.deployer.SchematicProgram +import de.devin.cbbees.network.DeployerSettingsPacket +import de.devin.cbbees.registry.AllDataComponents +import net.createmod.catnip.gui.AbstractSimiScreen +import net.createmod.catnip.platform.CatnipServices +import net.minecraft.ChatFormatting +import net.minecraft.client.gui.GuiGraphics +import net.minecraft.core.BlockPos +import net.minecraft.network.chat.Component +import net.minecraft.world.level.block.Mirror +import net.minecraft.world.level.block.Rotation + +/** + * Create-styled GUI for the Schematic Deployer with two tabs: + * - Absolute: shows stored coordinates (read-only) + * - Relative: shows editable X/Y/Z offset + rotation/mirror from the deployer + */ +class SchematicDeployerScreen( + private val be: SchematicDeployerBlockEntity +) : AbstractSimiScreen(Component.translatable("block.cbbees.schematic_deployer")) { + + private val background = AllGuiTextures.SCHEMATIC + + private var currentMode: DeployMode = be.deployMode + private var offsetX: Int = be.relativeOffset.x + private var offsetY: Int = be.relativeOffset.y + private var offsetZ: Int = be.relativeOffset.z + private var currentRotation: Rotation = be.relativeRotation + private var currentMirror: Mirror = be.relativeMirror + + private var absoluteButton: IconButton? = null + private var relativeButton: IconButton? = null + private var confirmButton: IconButton? = null + + private var scrollInputX: ScrollInput? = null + private var scrollInputY: ScrollInput? = null + private var scrollInputZ: ScrollInput? = null + private var labelX: Label? = null + private var labelY: Label? = null + private var labelZ: Label? = null + + private var rotateButton: IconButton? = null + private var mirrorButton: IconButton? = null + + override fun init() { + setWindowSize(background.width + 50, background.height + 86) + super.init() + + val x = guiLeft + val y = guiTop + + // Tab buttons + absoluteButton = IconButton(x + 7, y + 22, AllIcons.I_PLACEMENT_SETTINGS).also { + it.setToolTip(Component.translatable("gui.cbbees.deployer.mode.absolute")) + addRenderableWidget(it) + } + relativeButton = IconButton(x + 27, y + 22, AllIcons.I_TOOL_MOVE_XZ).also { + it.setToolTip(Component.translatable("gui.cbbees.deployer.mode.relative")) + addRenderableWidget(it) + } + + // Confirm button + confirmButton = IconButton(x + windowWidth - 25, y + windowHeight - 25, AllIcons.I_CONFIRM).also { + it.setToolTip(Component.translatable("gui.cbbees.deployer.confirm")) + addRenderableWidget(it) + } + + updateTabHighlights() + initRelativeWidgets() + } + + private fun initRelativeWidgets() { + // Remove old widgets + scrollInputX?.let { removeWidget(it) } + scrollInputY?.let { removeWidget(it) } + scrollInputZ?.let { removeWidget(it) } + labelX?.let { removeWidget(it) } + labelY?.let { removeWidget(it) } + labelZ?.let { removeWidget(it) } + rotateButton?.let { removeWidget(it) } + mirrorButton?.let { removeWidget(it) } + + if (currentMode != DeployMode.RELATIVE) { + scrollInputX = null; scrollInputY = null; scrollInputZ = null + labelX = null; labelY = null; labelZ = null + rotateButton = null; mirrorButton = null + return + } + + val program = be.heldItem.get(AllDataComponents.SCHEMATIC_PROGRAM) + val isConstruction = program is SchematicProgram.Construction + + // Left-aligned inputs, right after the axis labels + val inputLeft = guiLeft + 26 + val inputTop = guiTop + 110 + val inputW = windowWidth - 36 + val inputH = 14 + val gap = 16 + + labelX = Label(inputLeft + 3, inputTop + 3, Component.empty()).also { + it.withShadow() + addRenderableWidget(it) + } + labelY = Label(inputLeft + 3, inputTop + gap + 3, Component.empty()).also { + it.withShadow() + addRenderableWidget(it) + } + labelZ = Label(inputLeft + 3, inputTop + gap * 2 + 3, Component.empty()).also { + it.withShadow() + addRenderableWidget(it) + } + + scrollInputX = ScrollInput(inputLeft, inputTop, inputW, inputH).also { + it.withRange(-512, 512) + it.setState(offsetX) + it.titled(Component.translatable("gui.cbbees.deployer.offset.x")) + it.writingTo(labelX!!) + it.calling { v -> offsetX = v } + addRenderableWidget(it) + } + scrollInputY = ScrollInput(inputLeft, inputTop + gap, inputW, inputH).also { + it.withRange(-512, 512) + it.setState(offsetY) + it.titled(Component.translatable("gui.cbbees.deployer.offset.y")) + it.writingTo(labelY!!) + it.calling { v -> offsetY = v } + addRenderableWidget(it) + } + scrollInputZ = ScrollInput(inputLeft, inputTop + gap * 2, inputW, inputH).also { + it.withRange(-512, 512) + it.setState(offsetZ) + it.titled(Component.translatable("gui.cbbees.deployer.offset.z")) + it.writingTo(labelZ!!) + it.calling { v -> offsetZ = v } + addRenderableWidget(it) + } + + // Rotate/Mirror buttons (construction only) + if (isConstruction) { + val btnY = inputTop + gap * 3 + 6 + rotateButton = IconButton(guiLeft + 10, btnY, AllIcons.I_TOOL_ROTATE).also { + it.setToolTip(Component.translatable("gui.cbbees.deployer.rotate")) + addRenderableWidget(it) + } + mirrorButton = IconButton(guiLeft + 30, btnY, AllIcons.I_TOOL_MIRROR).also { + it.setToolTip(Component.translatable("gui.cbbees.deployer.mirror")) + addRenderableWidget(it) + } + } + } + + private fun updateTabHighlights() { + absoluteButton?.green = currentMode == DeployMode.ABSOLUTE + relativeButton?.green = currentMode == DeployMode.RELATIVE + } + + private fun switchMode(mode: DeployMode) { + if (currentMode == mode) return + + // When switching to RELATIVE for the first time, initialize from the program + if (mode == DeployMode.RELATIVE && currentMode == DeployMode.ABSOLUTE) { + val program = be.heldItem.get(AllDataComponents.SCHEMATIC_PROGRAM) + if (program != null) { + if (offsetX == 0 && offsetY == 0 && offsetZ == 0) { + val refPoint = when (program) { + is SchematicProgram.Construction -> program.anchor + is SchematicProgram.Deconstruction -> BlockPos( + (program.corner1.x + program.corner2.x) / 2, + (program.corner1.y + program.corner2.y) / 2, + (program.corner1.z + program.corner2.z) / 2 + ) + is SchematicProgram.Pickup -> BlockPos( + (program.corner1.x + program.corner2.x) / 2, + (program.corner1.y + program.corner2.y) / 2, + (program.corner1.z + program.corner2.z) / 2 + ) + } + offsetX = refPoint.x - be.blockPos.x + offsetY = refPoint.y - be.blockPos.y + offsetZ = refPoint.z - be.blockPos.z + } + // Initialize rotation/mirror from the stored program + if (program is SchematicProgram.Construction + && currentRotation == Rotation.NONE && currentMirror == Mirror.NONE + ) { + currentRotation = program.rotation + currentMirror = program.mirror + } + } + } + + currentMode = mode + updateTabHighlights() + initRelativeWidgets() + } + + private fun cycleRotation() { + currentRotation = when (currentRotation) { + Rotation.NONE -> Rotation.CLOCKWISE_90 + Rotation.CLOCKWISE_90 -> Rotation.CLOCKWISE_180 + Rotation.CLOCKWISE_180 -> Rotation.COUNTERCLOCKWISE_90 + Rotation.COUNTERCLOCKWISE_90 -> Rotation.NONE + } + } + + private fun cycleMirror() { + currentMirror = when (currentMirror) { + Mirror.NONE -> Mirror.LEFT_RIGHT + Mirror.LEFT_RIGHT -> Mirror.FRONT_BACK + Mirror.FRONT_BACK -> Mirror.NONE + } + } + + override fun mouseScrolled(mouseX: Double, mouseY: Double, scrollX: Double, scrollY: Double): Boolean { + if (currentMode == DeployMode.RELATIVE && hasShiftDown()) { + val input = listOf(scrollInputX, scrollInputY, scrollInputZ) + .firstOrNull { it != null && it.isMouseOver(mouseX, mouseY) } + if (input != null) { + val scroll = if (scrollY != 0.0) scrollY else scrollX + if (scroll != 0.0) { + val sign = Math.signum(scroll).toInt() + input.setState(input.getState() + sign * 16) + input.onChanged() + } + return true + } + } + return super.mouseScrolled(mouseX, mouseY, scrollX, scrollY) + } + + override fun mouseClicked(mouseX: Double, mouseY: Double, button: Int): Boolean { + absoluteButton?.let { btn -> + if (btn.isMouseOver(mouseX, mouseY)) { + switchMode(DeployMode.ABSOLUTE) + return true + } + } + relativeButton?.let { btn -> + if (btn.isMouseOver(mouseX, mouseY)) { + switchMode(DeployMode.RELATIVE) + return true + } + } + rotateButton?.let { btn -> + if (btn.isMouseOver(mouseX, mouseY)) { + cycleRotation() + return true + } + } + mirrorButton?.let { btn -> + if (btn.isMouseOver(mouseX, mouseY)) { + cycleMirror() + return true + } + } + confirmButton?.let { btn -> + if (btn.isMouseOver(mouseX, mouseY)) { + confirm() + return true + } + } + return super.mouseClicked(mouseX, mouseY, button) + } + + override fun keyPressed(keyCode: Int, scanCode: Int, modifiers: Int): Boolean { + if (keyCode == org.lwjgl.glfw.GLFW.GLFW_KEY_ENTER) { + confirm() + return true + } + return super.keyPressed(keyCode, scanCode, modifiers) + } + + private fun confirm() { + CatnipServices.NETWORK.sendToServer( + DeployerSettingsPacket( + be.blockPos, + currentMode, + BlockPos(offsetX, offsetY, offsetZ), + currentRotation, + currentMirror + ) + ) + onClose() + } + + override fun renderWindow(graphics: GuiGraphics, mouseX: Int, mouseY: Int, partialTicks: Float) { + val x = guiLeft + val y = guiTop + + // Draw dark background panel + graphics.fill(x, y, x + windowWidth, y + windowHeight, 0xCC2A2A2A.toInt()) + // Border + graphics.fill(x, y, x + windowWidth, y + 1, 0xFF555555.toInt()) + graphics.fill(x, y + windowHeight - 1, x + windowWidth, y + windowHeight, 0xFF555555.toInt()) + graphics.fill(x, y, x + 1, y + windowHeight, 0xFF555555.toInt()) + graphics.fill(x + windowWidth - 1, y, x + windowWidth, y + windowHeight, 0xFF555555.toInt()) + + // Title bar + graphics.fill(x + 1, y + 1, x + windowWidth - 1, y + 18, 0xFF3A3A3A.toInt()) + graphics.drawCenteredString( + font, + title, + x + windowWidth / 2, + y + 5, + 0xFFD4AA00.toInt() + ) + + // Tab labels + val absColor = if (currentMode == DeployMode.ABSOLUTE) 0xFFFFFF else 0x888888 + val relColor = if (currentMode == DeployMode.RELATIVE) 0xFFFFFF else 0x888888 + graphics.drawString(font, "Absolute", x + 48, y + 26, absColor, false) + graphics.drawString(font, "Relative", x + 100, y + 26, relColor, false) + + // Mode indicator line + val lineX = if (currentMode == DeployMode.ABSOLUTE) x + 48 else x + 100 + val lineW = if (currentMode == DeployMode.ABSOLUTE) font.width("Absolute") else font.width("Relative") + graphics.fill(lineX, y + 36, lineX + lineW, y + 37, 0xFFD4AA00.toInt()) + + // Program info section + val program = be.heldItem.get(AllDataComponents.SCHEMATIC_PROGRAM) + val infoY = y + 42 + + if (program != null) { + renderProgramInfo(graphics, x + 10, infoY, program) + } else { + graphics.drawString(font, Component.translatable("cbbees.deployer.empty"), x + 10, infoY, 0xFF6666, false) + } + + // Position section + val posY = y + 96 + graphics.fill(x + 5, posY - 4, x + windowWidth - 5, posY - 3, 0xFF555555.toInt()) + + if (currentMode == DeployMode.ABSOLUTE) { + renderAbsoluteCoords(graphics, x + 10, posY, program) + } else { + renderRelativeControls(graphics, x + 10, posY, program) + } + } + + private fun renderProgramInfo(graphics: GuiGraphics, x: Int, y: Int, program: SchematicProgram) { + when (program) { + is SchematicProgram.Construction -> { + val typeLabel = + Component.translatable("cbbees.program.construction", program.schematicName.removeSuffix(".nbt")) + graphics.drawString(font, typeLabel, x, y, 0x8BDB8B, false) + + val rotStr = when (program.rotation) { + Rotation.NONE -> "None" + Rotation.CLOCKWISE_90 -> "90\u00B0" + Rotation.CLOCKWISE_180 -> "180\u00B0" + Rotation.COUNTERCLOCKWISE_90 -> "270\u00B0" + } + val mirrorStr = when (program.mirror) { + Mirror.NONE -> "None" + Mirror.LEFT_RIGHT -> "Left-Right" + Mirror.FRONT_BACK -> "Front-Back" + } + graphics.drawString(font, "Rotation: $rotStr Mirror: $mirrorStr", x, y + 12, 0x999999, false) + + graphics.drawString( + font, + Component.literal("Anchor: ${program.anchor.x}, ${program.anchor.y}, ${program.anchor.z}"), + x, y + 24, 0x888888, false + ) + } + + is SchematicProgram.Deconstruction -> { + graphics.drawString( + font, + Component.translatable("cbbees.program.deconstruction"), + x, y, 0xDB8B8B, false + ) + val sizeX = kotlin.math.abs(program.corner2.x - program.corner1.x) + 1 + val sizeY = kotlin.math.abs(program.corner2.y - program.corner1.y) + 1 + val sizeZ = kotlin.math.abs(program.corner2.z - program.corner1.z) + 1 + graphics.drawString( + font, + Component.translatable("cbbees.program.dimensions", sizeX, sizeY, sizeZ), + x, y + 12, 0x999999, false + ) + graphics.drawString( + font, + "From: ${program.corner1.x}, ${program.corner1.y}, ${program.corner1.z}", + x, y + 24, 0x888888, false + ) + graphics.drawString( + font, + "To: ${program.corner2.x}, ${program.corner2.y}, ${program.corner2.z}", + x, y + 36, 0x888888, false + ) + } + + is SchematicProgram.Pickup -> { + graphics.drawString( + font, + Component.translatable("cbbees.program.pickup"), + x, y, 0x8BDB8B, false + ) + val sizeX = kotlin.math.abs(program.corner2.x - program.corner1.x) + 1 + val sizeY = kotlin.math.abs(program.corner2.y - program.corner1.y) + 1 + val sizeZ = kotlin.math.abs(program.corner2.z - program.corner1.z) + 1 + graphics.drawString( + font, + Component.translatable("cbbees.program.dimensions", sizeX, sizeY, sizeZ), + x, y + 12, 0x999999, false + ) + } + } + } + + private fun renderAbsoluteCoords(graphics: GuiGraphics, x: Int, y: Int, program: SchematicProgram?) { + graphics.drawString(font, "Deploy Mode: Absolute", x, y, 0xD4AA00, false) + graphics.drawString( + font, + Component.literal("Coordinates are used as-is").withStyle(ChatFormatting.GRAY), + x, y + 14, 0x888888, false + ) + + if (program != null) { + val posText = when (program) { + is SchematicProgram.Construction -> + "Build at: ${program.anchor.x}, ${program.anchor.y}, ${program.anchor.z}" + + is SchematicProgram.Deconstruction -> + "Area: ${program.corner1.x},${program.corner1.y},${program.corner1.z} to ${program.corner2.x},${program.corner2.y},${program.corner2.z}" + + is SchematicProgram.Pickup -> + "Scan: ${program.corner1.x},${program.corner1.y},${program.corner1.z} to ${program.corner2.x},${program.corner2.y},${program.corner2.z}" + } + graphics.drawString(font, posText, x, y + 28, 0xAAAAAA, false) + } + } + + private fun renderRelativeControls(graphics: GuiGraphics, x: Int, y: Int, program: SchematicProgram?) { + graphics.drawString(font, "Offset from Deployer:", x, y, 0xD4AA00, false) + + // Axis labels left of scroll inputs + val labelX = guiLeft + 14 + val inputTop = guiTop + 110 + val gap = 16 + graphics.drawString(font, "X:", labelX, inputTop + 3, 0xDD5555, true) + graphics.drawString(font, "Y:", labelX, inputTop + gap + 3, 0x55DD55, true) + graphics.drawString(font, "Z:", labelX, inputTop + gap * 2 + 3, 0x5555DD, true) + + // Rotation/Mirror display (construction only) + val isConstruction = program is SchematicProgram.Construction + val btnY = inputTop + gap * 3 + 6 + + if (isConstruction) { + val rotStr = when (currentRotation) { + Rotation.NONE -> "0\u00B0" + Rotation.CLOCKWISE_90 -> "90\u00B0" + Rotation.CLOCKWISE_180 -> "180\u00B0" + Rotation.COUNTERCLOCKWISE_90 -> "270\u00B0" + } + val mirrorStr = when (currentMirror) { + Mirror.NONE -> "None" + Mirror.LEFT_RIGHT -> "L-R" + Mirror.FRONT_BACK -> "F-B" + } + graphics.drawString(font, rotStr, guiLeft + 52, btnY + 5, 0xCCCCCC, true) + graphics.drawString(font, mirrorStr, guiLeft + 80, btnY + 5, 0xCCCCCC, true) + } + + // Preview actual target position + val targetPos = be.blockPos.offset(offsetX, offsetY, offsetZ) + val previewY = btnY + (if (isConstruction) 22 else 0) + graphics.drawString( + font, + "Target: ${targetPos.x}, ${targetPos.y}, ${targetPos.z}", + x, previewY, 0xAAAAAA, false + ) + graphics.drawString( + font, + "Scroll to adjust | Shift: x16", + x, previewY + 12, 0x666666, false + ) + } +} diff --git a/src/main/kotlin/de/devin/cbbees/content/domain/GlobalJobPool.kt b/src/main/kotlin/de/devin/cbbees/content/domain/GlobalJobPool.kt index 1421966..d2933fb 100644 --- a/src/main/kotlin/de/devin/cbbees/content/domain/GlobalJobPool.kt +++ b/src/main/kotlin/de/devin/cbbees/content/domain/GlobalJobPool.kt @@ -126,16 +126,14 @@ object GlobalJobPool : SavedData() { if (!batch.isCooldownElapsed(gameTime)) continue if (!job.isPhaseReady(batch.phase)) continue - val candidateNetworks = allNetworks.filter { network -> + val targetNetwork = allNetworks.filter { network -> val firstComp = network.components.firstOrNull() firstComp != null && firstComp.world == job.level && network.isInRange(batch.targetPosition) && network.hives.any { it.getAvailableBeeCount() > 0 } - }.sortedBy { network -> + }.minByOrNull { network -> network.hives.minOfOrNull { it.pos.distSqr(batch.targetPosition) } ?: Double.MAX_VALUE - } - - val targetNetwork = candidateNetworks.firstOrNull() ?: continue + } ?: continue batch.assignedNetworkId = targetNetwork.id targetNetwork.dispatchBatch(batch) } @@ -146,86 +144,86 @@ object GlobalJobPool : SavedData() { fun getAllJobs(): List = jobBacklog - @Synchronized fun workBacklog(beeHive: BeeHive): TaskBatch? { val network = beeHive.network() val gameTime = (beeHive.world as? net.minecraft.server.level.ServerLevel)?.gameTime ?: 0L + val networkId = network.id + val hivePos = beeHive.pos fun isDispatchable(batch: TaskBatch): Boolean = batch.status == TaskStatus.PENDING && batch.canRetry() && batch.isCooldownElapsed(gameTime) && batch.job.isPhaseReady(batch.phase) - // 1. Find batches already assigned to this network - val assignedBatch = jobBacklog.flatMap { it.batches } - .filter { isDispatchable(it) && it.assignedNetworkId == network.id } - .minByOrNull { it.targetPosition.distSqr(beeHive.pos) } + // 1. Find closest dispatchable batch already assigned to this network (no intermediate allocations) + var bestAssigned: TaskBatch? = null + var bestAssignedDist = Double.MAX_VALUE + for (job in jobBacklog) { + if (job.status == JobStatus.COMPLETED || job.status == JobStatus.CANCELLED) continue + for (batch in job.batches) { + if (!isDispatchable(batch)) continue + if (batch.assignedNetworkId != networkId) continue + val dist = batch.targetPosition.distSqr(hivePos) + if (dist < bestAssignedDist) { + bestAssignedDist = dist + bestAssigned = batch + } + } + } - if (assignedBatch != null) { - assignedBatch.status = TaskStatus.PICKED - return assignedBatch + if (bestAssigned != null) { + bestAssigned.status = TaskStatus.PICKED + return bestAssigned } - // 2. Fallback: try to find any unassigned batch that this network can do - // This handles cases where a job was dispatched before the network was fully ready or split/merge events - val job = jobBacklog.filter { network.isInRange(it.centerPos) } - .sortedBy { it.centerPos.distSqr(beeHive.pos) } - .firstOrNull { j -> j.batches.any { isDispatchable(it) && (it.assignedNetworkId == null || it.assignedNetworkId == network.id) } } - ?: return null + // 2. Fallback: find closest unassigned batch this network can handle + var bestJob: BeeJob? = null + var bestJobDist = Double.MAX_VALUE + for (job in jobBacklog) { + if (job.status == JobStatus.COMPLETED || job.status == JobStatus.CANCELLED) continue + if (!network.isInRange(job.centerPos)) continue + val hasDispatchable = job.batches.any { isDispatchable(it) && (it.assignedNetworkId == null || it.assignedNetworkId == networkId) } + if (!hasDispatchable) continue + val dist = job.centerPos.distSqr(hivePos) + if (dist < bestJobDist) { + bestJobDist = dist + bestJob = job + } + } - val batch = - job.batches.firstOrNull { isDispatchable(it) && (it.assignedNetworkId == null || it.assignedNetworkId == network.id) } - ?: return null + val job = bestJob ?: return null + val batch = job.batches.firstOrNull { isDispatchable(it) && (it.assignedNetworkId == null || it.assignedNetworkId == networkId) } + ?: return null // Verification if (!network.isInRange(batch.targetPosition)) return null - batch.assignedNetworkId = network.id + batch.assignedNetworkId = networkId batch.status = TaskStatus.PICKED return batch } - @Synchronized + /** + * Registers a new job for processing. The job is added to the backlog immediately + * but batch-to-network assignment is deferred to [redispatchPendingBatches] which + * runs every few ticks. This avoids a synchronous O(batches × networks × hives) + * spike when placing large schematics. + */ fun dispatchNewJob(job: BeeJob) { // Prevent duplicate active jobs with same uniqueness key - if (job.uniquenessKey != null && jobBacklog.any { it.uniquenessKey == job.uniquenessKey && it.status != JobStatus.COMPLETED && it.status != JobStatus.CANCELLED }) { - return - } - - val batchesToDistribute = job.batches - .filter { it.status == TaskStatus.PENDING && job.isPhaseReady(it.phase) } - .sortedByDescending { it.priority } - - if (batchesToDistribute.isEmpty()) return - - val allNetworks = ServerBeeNetworkManager.getNetworks() - var assignedCount = 0 - var unassignedCount = 0 - - for (batch in batchesToDistribute) { - // Find networks that can do this batch - val candidateNetworks = allNetworks.filter { network -> - val firstComp = network.components.firstOrNull() - firstComp != null && firstComp.world == job.level && - network.isInRange(batch.targetPosition) - }.sortedBy { network -> - // Prefer network with closest hive - network.hives.minOf { it.pos.distSqr(batch.targetPosition) } - } - - val targetNetwork = candidateNetworks.firstOrNull() - if (targetNetwork != null) { - batch.assignedNetworkId = targetNetwork.id - targetNetwork.dispatchBatch(batch) - assignedCount++ - } else { - unassignedCount++ - } - } - - CreateBuzzyBeez.LOGGER.debug("[JobPool] Dispatched job: ${batchesToDistribute.size} batches, $assignedCount assigned, $unassignedCount unassigned, ${allNetworks.size} networks available") + if (job.uniquenessKey != null && jobBacklog.any { + it.uniquenessKey == job.uniquenessKey && + it.status != JobStatus.COMPLETED && + it.status != JobStatus.CANCELLED + }) return if (!jobBacklog.contains(job)) jobBacklog.add(job) this.setDirty() + + // Dispatch immediately so bees start flying without waiting for the next + // 10-tick redispatch cycle + redispatchPendingBatches(0L) + + CreateBuzzyBeez.LOGGER.debug("[JobPool] Registered job with ${job.batches.size} batches") } override fun save( diff --git a/src/main/kotlin/de/devin/cbbees/content/domain/TransportDispatcher.kt b/src/main/kotlin/de/devin/cbbees/content/domain/TransportDispatcher.kt index 7b03fab..887fe0d 100644 --- a/src/main/kotlin/de/devin/cbbees/content/domain/TransportDispatcher.kt +++ b/src/main/kotlin/de/devin/cbbees/content/domain/TransportDispatcher.kt @@ -3,17 +3,18 @@ package de.devin.cbbees.content.domain import de.devin.cbbees.content.bee.BeeSeparation import de.devin.cbbees.content.bee.MechanicalBumbleBeeEntity import de.devin.cbbees.content.bee.MechanicalBumbleBeeItem -import de.devin.cbbees.content.bee.brain.BeeMemoryModules +import de.devin.cbbees.content.bee.server.ServerBeeData +import de.devin.cbbees.content.bee.server.ServerBeeManager import de.devin.cbbees.content.beehive.MechanicalBeehiveBlockEntity import de.devin.cbbees.content.domain.network.ServerBeeNetworkManager import de.devin.cbbees.content.domain.task.TransportTask -import de.devin.cbbees.registry.AllEntityTypes import de.devin.cbbees.util.ServerSide import net.minecraft.core.HolderLookup import net.minecraft.nbt.CompoundTag import net.minecraft.world.item.ItemStack import net.minecraft.world.level.saveddata.SavedData import net.minecraft.world.phys.Vec3 +import net.neoforged.neoforge.items.ItemHandlerHelper import java.util.* /** @@ -57,16 +58,21 @@ object TransportDispatcher : SavedData() { if (hivesWithBumbles.isEmpty()) continue + // Pre-group requesters by frequency key for O(1) matching per provider + val requestersByFreq = requesterPorts.groupBy { it.linkBehaviour.networkKey } + for (provider in providerPorts) { val handler = provider.getItemHandler(provider.world) ?: continue - // Find a requester that matches this provider's frequency - val requester = requesterPorts - .filter { it.frequenciesMatch(provider) } - .sortedByDescending { it.priority() } - .firstOrNull() ?: continue + // Find a requester that matches this provider's frequency and has space + val matchingRequesters = requestersByFreq[provider.linkBehaviour.networkKey] ?: continue + val requester = matchingRequesters + .filter { it.hasInventorySpace() } + .maxByOrNull { it.priority() } ?: continue - // Collect up to INVENTORY_SIZE distinct stacks for a single trip + // Collect up to INVENTORY_SIZE distinct stacks for a single trip, + // only items the requester can actually accept + val requesterHandler = requester.getItemHandler(requester.world) val itemsToTransport = mutableListOf() for (slot in 0 until handler.slots) { if (itemsToTransport.size >= MechanicalBumbleBeeEntity.INVENTORY_SIZE) break @@ -75,6 +81,12 @@ object TransportDispatcher : SavedData() { if (stack.isEmpty) continue if (!provider.hasAvailableItemStack(stack)) continue + // Check if the requester can accept this item (simulate insert) + if (requesterHandler != null) { + val simulated = ItemHandlerHelper.insertItemStacked(requesterHandler, stack.copy(), true) + if (simulated.count == stack.count) continue // no space at all + } + itemsToTransport.add(stack.copy()) } @@ -86,9 +98,10 @@ object TransportDispatcher : SavedData() { items = itemsToTransport ) + // Use minByOrNull instead of sortedBy.firstOrNull to avoid full sort val hive = hivesWithBumbles - .sortedBy { it.pos.distSqr(provider.pos) } - .firstOrNull { it.getAvailableBeeCountOfType(MechanicalBumbleBeeItem::class.java) > 0 } + .filter { it.getAvailableBeeCountOfType(MechanicalBumbleBeeItem::class.java) > 0 } + .minByOrNull { it.pos.distSqr(provider.pos) } ?: break // no more bumble bees available in any hive val beeItem = hive.consumeBeeOfType(MechanicalBumbleBeeItem::class.java) @@ -96,7 +109,7 @@ object TransportDispatcher : SavedData() { val bee = spawnMechanicalBumbleBee(hive, task) if (bee != null) { - provider.reserve(bee.uuid, task.items, hive.world.gameTime) + provider.reserve(bee.id, task.items, hive.world.gameTime) } } } @@ -105,22 +118,15 @@ object TransportDispatcher : SavedData() { private fun spawnMechanicalBumbleBee( hive: MechanicalBeehiveBlockEntity, task: TransportTask - ): MechanicalBumbleBeeEntity? { - val level = hive.world - - val bee = MechanicalBumbleBeeEntity(AllEntityTypes.MECHANICAL_BUMBLE_BEE.get(), level).apply { - setPos(Vec3.atCenterOf(hive.pos.above()).add(BeeSeparation.spawnOffset(level.random))) - this.networkId = hive.network().id - this.springTension = 1.0f - } - - bee.setHomeId(hive.id) - bee.getBrain().setMemory(BeeMemoryModules.HIVE_POS.get(), hive.pos) - bee.getBrain().setMemory(BeeMemoryModules.HIVE_INSTANCE.get(), Optional.of(hive)) - bee.getBrain().setMemory(BeeMemoryModules.TRANSPORT_TASK.get(), task) - - level.addFreshEntity(bee) - return bee + ): ServerBeeData? { + val spawnPos = Vec3.atCenterOf(hive.pos.above()).add(BeeSeparation.spawnOffset(hive.world.random)) + + return ServerBeeManager.spawnTransportBee( + hive = hive, + task = task, + networkId = hive.network().id, + spawnPos = spawnPos, + ) } override fun save(tag: CompoundTag, registries: HolderLookup.Provider): CompoundTag { diff --git a/src/main/kotlin/de/devin/cbbees/content/domain/action/BeeAction.kt b/src/main/kotlin/de/devin/cbbees/content/domain/action/BeeAction.kt index af18a44..49b26ad 100644 --- a/src/main/kotlin/de/devin/cbbees/content/domain/action/BeeAction.kt +++ b/src/main/kotlin/de/devin/cbbees/content/domain/action/BeeAction.kt @@ -1,40 +1,29 @@ package de.devin.cbbees.content.domain.action -import de.devin.cbbees.content.bee.MechanicalBeeEntity +import de.devin.cbbees.content.bee.server.BeeWorker import de.devin.cbbees.content.upgrades.BeeContext import net.minecraft.core.BlockPos import net.minecraft.world.level.Level /** * Interface for actions bees perform on blocks. + * + * Uses [BeeWorker] instead of Entity — works with both the legacy entity system + * and the lightweight [de.devin.cbbees.content.bee.server.ServerBeeData] system. */ interface BeeAction { val pos: BlockPos fun getWorkTicks(context: BeeContext): Int = 0 - fun onStart(robot: MechanicalBeeEntity) {} - fun onTick(robot: MechanicalBeeEntity, tick: Int) {} + fun onStart(worker: BeeWorker) {} + fun onTick(worker: BeeWorker, tick: Int) {} - /** - * Called when this action's task becomes the current task in its batch. - * Use this to resolve dynamic targets (e.g. finding the nearest port). - */ - fun onActivate(bee: MechanicalBeeEntity) {} + /** Called when this action's task becomes the current task in its batch. */ + fun onActivate(worker: BeeWorker) {} - fun execute(level: Level, bee: MechanicalBeeEntity, context: BeeContext): Boolean + fun execute(level: Level, worker: BeeWorker, context: BeeContext): Boolean - /** - * Whether the bee should return to home after performing this action. - */ fun shouldReturnAfter(context: BeeContext): Boolean = true - - /** - * Priority offset for this action. - */ fun getPriorityOffset(): Int = 0 - - /** - * Gets a human-readable description of this action. - */ fun getDescription(): String } \ No newline at end of file diff --git a/src/main/kotlin/de/devin/cbbees/content/domain/action/ItemConsumingAction.kt b/src/main/kotlin/de/devin/cbbees/content/domain/action/ItemConsumingAction.kt index ef830f3..a9ccf27 100644 --- a/src/main/kotlin/de/devin/cbbees/content/domain/action/ItemConsumingAction.kt +++ b/src/main/kotlin/de/devin/cbbees/content/domain/action/ItemConsumingAction.kt @@ -1,26 +1,24 @@ package de.devin.cbbees.content.domain.action -import de.devin.cbbees.content.bee.MechanicalBeeEntity +import de.devin.cbbees.content.bee.server.BeeWorker import net.minecraft.world.item.ItemStack /** - * Interface for actions that require items to be consumed from the bee's inventory. - * Implemented by actions like placing blocks, belts, or fertilizing crops. + * Actions that consume items from the bee's inventory (placing blocks, belts, etc.). */ interface ItemConsumingAction { val requiredItems: List - fun hasItems(bee: MechanicalBeeEntity): Boolean { - return requiredItems.all { req -> - bee.countInInventory(req) >= req.count + fun hasItems(worker: BeeWorker): Boolean = + requiredItems.all { req -> + worker.getInventoryContents() + .filter { ItemStack.isSameItemSameComponents(it, req) } + .sumOf { it.count } >= req.count } - } - fun consumeItems(bee: MechanicalBeeEntity): Boolean { - if (!hasItems(bee)) return false - for (req in requiredItems) { - bee.removeFromInventory(req, req.count) - } + fun consumeItems(worker: BeeWorker): Boolean { + if (!hasItems(worker)) return false + requiredItems.forEach { req -> worker.removeFromInventory(req, req.count) } return true } } diff --git a/src/main/kotlin/de/devin/cbbees/content/domain/action/impl/DropOffItemsAction.kt b/src/main/kotlin/de/devin/cbbees/content/domain/action/impl/DropOffItemsAction.kt index 2d8b27a..538983a 100644 --- a/src/main/kotlin/de/devin/cbbees/content/domain/action/impl/DropOffItemsAction.kt +++ b/src/main/kotlin/de/devin/cbbees/content/domain/action/impl/DropOffItemsAction.kt @@ -1,7 +1,6 @@ package de.devin.cbbees.content.domain.action.impl -import de.devin.cbbees.content.bee.MechanicalBeeEntity -import de.devin.cbbees.content.bee.debug.BeeDebug +import de.devin.cbbees.content.bee.server.BeeWorker import de.devin.cbbees.content.domain.action.BeeAction import de.devin.cbbees.content.upgrades.BeeContext import net.minecraft.core.BlockPos @@ -10,134 +9,46 @@ import net.minecraft.world.item.ItemStack import net.minecraft.world.level.Level /** - * Action that deposits the bee's inventory contents at a logistics drop-off port. - * Appended as the last task in removal batches so bees return picked-up items. - * - * The target port is resolved dynamically via [onActivate] when this task becomes - * current, since the best port may change at runtime. - * - * When no port is available, the bee flies to the owner player and drops items - * at their feet (the player acts as a fallback logistics port). + * Deposits the bee's inventory at a logistics port or the owner player. + * Appended as the last task in removal batches to return picked-up items. */ class DropOffItemsAction(initialPos: BlockPos) : BeeAction { private var _pos: BlockPos = initialPos - override val pos: BlockPos get() { - if (dropAtPlayer) { - // Dynamically track the player (above head to avoid blocking vision) - bee?.getOwnerPlayer()?.let { _pos = it.blockPosition().above(2) } - } - return _pos - } + override val pos: BlockPos get() = _pos - /** When true, the bee should drop items at the owner player on arrival. */ - private var dropAtPlayer = false - private var bee: MechanicalBeeEntity? = null + private var cachedWorker: BeeWorker? = null - override fun onActivate(bee: MechanicalBeeEntity) { - this.bee = bee - val network = bee.network() - BeeDebug.log(bee, "DropOffAction.onActivate: network=${network?.id}, inventory=${bee.getInventoryContents().size} stacks") - val port = network?.findDropOff(ItemStack.EMPTY) - if (port != null) { - _pos = port.pos - BeeDebug.log(bee, "DropOffAction.onActivate: found port at $_pos") - } else { - // No port — fly to the owner player instead - val owner = bee.getOwnerPlayer() - BeeDebug.log(bee, "DropOffAction.onActivate: no port, ownerPlayer=${owner?.name?.string}, ownerUUID=${bee.getOwnerUUID()}") - if (owner != null) { - _pos = owner.blockPosition().above(2) - dropAtPlayer = true - BeeDebug.log(bee, "DropOffAction.onActivate: dropAtPlayer=true, target=$_pos") - } else { - // No port or owner — drop items on ground immediately - BeeDebug.log(bee, "DropOffAction.onActivate: no port or owner, dropping items on ground") - bee.dropInventory() - _pos = bee.blockPosition() - } - } + override fun onActivate(worker: BeeWorker) { + cachedWorker = worker + val port = worker.network()?.findDropOff(ItemStack.EMPTY) + // Just set the target — actual item handling happens in execute() + _pos = port?.pos ?: worker.blockPosition() } - override fun execute(level: Level, bee: MechanicalBeeEntity, context: BeeContext): Boolean { - val contents = bee.getInventoryContents() - BeeDebug.log(bee, "DropOffAction.execute: contents=${contents.size} stacks, dropAtPlayer=$dropAtPlayer") - if (contents.isEmpty()) { - BeeDebug.log(bee, "DropOffAction.execute: inventory empty, returning true") - return true - } - - if (dropAtPlayer) { - val owner = bee.getOwnerPlayer() - BeeDebug.log(bee, "DropOffAction.execute: dropAtPlayer=true, owner=${owner?.name?.string}") - if (owner != null) { - for (item in contents) { - val copy = item.copy() - val added = owner.inventory.add(copy) - bee.removeFromInventory(item, item.count) - BeeDebug.log(bee, "DropOffAction.execute: ${item.count}x ${item.item} added=$added") - if (!added && !copy.isEmpty) { - val drop = ItemEntity(level, owner.x, owner.y, owner.z, copy) - level.addFreshEntity(drop) - } + override fun execute(level: Level, worker: BeeWorker, context: BeeContext): Boolean { + val contents = worker.getInventoryContents() + if (contents.isEmpty()) return true + + // Try each item individually — different items might go to different ports + contents.forEach { item -> + val port = worker.network()?.findDropOff(item) + if (port != null) { + val remainder = port.addItemStack(item.copy()) + worker.removeFromInventory(item, item.count) + if (!remainder.isEmpty) { + // Port full — drop only the remainder that didn't fit + level.addFreshEntity(ItemEntity(level, port.pos.x + 0.5, port.pos.y + 0.5, port.pos.z + 0.5, remainder)) } } else { - // Owner logged off — drop at bee's position - BeeDebug.log(bee, "DropOffAction.execute: owner null, dropping on ground") - for (item in contents) { - bee.removeFromInventory(item, item.count) - val drop = ItemEntity(level, bee.x, bee.y, bee.z, item.copy()) - level.addFreshEntity(drop) - } + // No port accepts this item — drop on ground as last resort + worker.removeFromInventory(item, item.count) + level.addFreshEntity(ItemEntity(level, worker.getWorkerX(), worker.getWorkerY(), worker.getWorkerZ(), item.copy())) } - return true - } - - val port = bee.network()?.findDropOff(contents.first()) - - if (port == null) { - // Port disappeared mid-flight — drop at owner or on ground - val owner = bee.getOwnerPlayer() - if (owner != null) { - BeeDebug.log(bee, "DropOff: port gone, giving ${contents.size} stack(s) to player ${owner.name.string}") - for (item in contents) { - val copy = item.copy() - bee.removeFromInventory(item, item.count) - if (!owner.inventory.add(copy) && !copy.isEmpty) { - val drop = ItemEntity(level, owner.x, owner.y, owner.z, copy) - level.addFreshEntity(drop) - } - } - } else { - BeeDebug.log(bee, "DropOff: no port or owner, dropping ${contents.size} stack(s) on ground") - for (item in contents) { - bee.removeFromInventory(item, item.count) - val itemEntity = ItemEntity(level, bee.x, bee.y, bee.z, item.copy()) - level.addFreshEntity(itemEntity) - } - } - return true - } - - BeeDebug.log(bee, "DropOff: depositing ${contents.size} stack(s) at port ${port.pos}") - for (item in contents) { - val remainder = port.addItemStack(item.copy()) - if (!remainder.isEmpty) { - val itemEntity = ItemEntity( - level, - port.pos.x + 0.5, - port.pos.y + 0.5, - port.pos.z + 0.5, - remainder - ) - level.addFreshEntity(itemEntity) - } - bee.removeFromInventory(item, item.count) } return true } - override fun shouldReturnAfter(context: BeeContext): Boolean = false - - override fun getDescription(): String = "Dropping off items at (${pos.x}, ${pos.y}, ${pos.z})" + override fun shouldReturnAfter(context: BeeContext) = false + override fun getDescription() = "Dropping off items at (${pos.x}, ${pos.y}, ${pos.z})" } diff --git a/src/main/kotlin/de/devin/cbbees/content/domain/action/impl/PickupItemsAction.kt b/src/main/kotlin/de/devin/cbbees/content/domain/action/impl/PickupItemsAction.kt new file mode 100644 index 0000000..6eb55a6 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/domain/action/impl/PickupItemsAction.kt @@ -0,0 +1,48 @@ +package de.devin.cbbees.content.domain.action.impl + +import de.devin.cbbees.config.CBBeesConfig +import de.devin.cbbees.content.bee.server.BeeWorker +import de.devin.cbbees.content.domain.action.BeeAction +import de.devin.cbbees.content.upgrades.BeeContext +import net.minecraft.core.BlockPos +import net.minecraft.world.entity.item.ItemEntity +import net.minecraft.world.level.Level +import net.minecraft.world.phys.AABB +import net.minecraft.world.phys.Vec3 + +/** + * Scans a small radius around the target position for loose [ItemEntity] objects + * and picks them up into the bee's inventory. Used by the Pickup Schematic program. + * + * On arrival the bee collects as many items as its inventory allows in a single pass. + * Items that don't fit remain on the ground for a future pickup batch. + */ +class PickupItemsAction(private val targetPos: BlockPos) : BeeAction { + + override val pos: BlockPos get() = targetPos + + override fun onActivate(worker: BeeWorker) { + // Target position already set + } + + override fun execute(level: Level, worker: BeeWorker, context: BeeContext): Boolean { + val center = Vec3.atCenterOf(targetPos) + val scanBox = AABB.ofSize(center, 3.0, 3.0, 3.0) + val items = level.getEntitiesOfClass(ItemEntity::class.java, scanBox) { it.isAlive } + + for (entity in items) { + if (worker.isInventoryFull()) break + val stack = entity.item.copy() + val remainder = worker.addToInventory(stack) + if (remainder.isEmpty) { + entity.discard() + } else { + entity.item = remainder + } + worker.consumeSpring(CBBeesConfig.springDrainPickup.get()) + } + return true + } + + override fun getDescription() = "Picking up items at (${targetPos.x}, ${targetPos.y}, ${targetPos.z})" +} diff --git a/src/main/kotlin/de/devin/cbbees/content/domain/action/impl/PlaceBeltAction.kt b/src/main/kotlin/de/devin/cbbees/content/domain/action/impl/PlaceBeltAction.kt index 8c42658..6d1fa44 100644 --- a/src/main/kotlin/de/devin/cbbees/content/domain/action/impl/PlaceBeltAction.kt +++ b/src/main/kotlin/de/devin/cbbees/content/domain/action/impl/PlaceBeltAction.kt @@ -4,7 +4,7 @@ import com.simibubi.create.AllBlocks import com.simibubi.create.content.kinetics.belt.BeltBlock import com.simibubi.create.content.kinetics.belt.BeltBlockEntity import com.simibubi.create.content.kinetics.belt.item.BeltConnectorItem -import de.devin.cbbees.content.bee.MechanicalBeeEntity +import de.devin.cbbees.content.bee.server.BeeWorker import de.devin.cbbees.content.domain.action.BeeAction import de.devin.cbbees.content.domain.action.ItemConsumingAction import de.devin.cbbees.content.domain.beehive.BeeHive @@ -34,11 +34,11 @@ class PlaceBeltAction( override val requiredItems: List = emptyList() ) : BeeAction, ItemConsumingAction { - override fun execute(level: Level, bee: MechanicalBeeEntity, context: BeeContext): Boolean { + override fun execute(level: Level, worker: BeeWorker, context: BeeContext): Boolean { // Never replace a Mechanical Beehive — abort belt if any position overlaps if (chain.any { level.getBlockEntity(it) is BeeHive }) return true - consumeItems(bee) + consumeItems(worker) BeltConnectorItem.createBelts(level, pos, end) @@ -67,7 +67,7 @@ class PlaceBeltAction( level.sendParticles( ParticleTypes.HAPPY_VILLAGER, pos.x + 0.5, pos.y + 0.5, pos.z + 0.5, - 5, 0.3, 0.3, 0.3, 0.0 + 1, 0.2, 0.2, 0.2, 0.0 ) } diff --git a/src/main/kotlin/de/devin/cbbees/content/domain/action/impl/PlaceBlockAction.kt b/src/main/kotlin/de/devin/cbbees/content/domain/action/impl/PlaceBlockAction.kt index 6995d3c..4885e63 100644 --- a/src/main/kotlin/de/devin/cbbees/content/domain/action/impl/PlaceBlockAction.kt +++ b/src/main/kotlin/de/devin/cbbees/content/domain/action/impl/PlaceBlockAction.kt @@ -6,7 +6,7 @@ import com.simibubi.create.content.kinetics.belt.BeltHelper import com.simibubi.create.foundation.utility.BlockHelper import de.devin.cbbees.content.domain.action.BeeAction import de.devin.cbbees.content.domain.action.ItemConsumingAction -import de.devin.cbbees.content.bee.MechanicalBeeEntity +import de.devin.cbbees.content.bee.server.BeeWorker import de.devin.cbbees.content.domain.beehive.BeeHive import de.devin.cbbees.content.upgrades.BeeContext import net.minecraft.core.BlockPos @@ -37,7 +37,7 @@ class PlaceBlockAction( override val requiredItems: List = emptyList() ) : BeeAction, ItemConsumingAction { - override fun execute(level: Level, bee: MechanicalBeeEntity, context: BeeContext): Boolean { + override fun execute(level: Level, worker: BeeWorker, context: BeeContext): Boolean { // Never replace a Mechanical Beehive — skip silently if (level.getBlockEntity(pos) is BeeHive) return true @@ -56,7 +56,7 @@ class PlaceBlockAction( } } - consumeItems(bee) + consumeItems(worker) // Use Create's BlockHelper for proper schematic block placement. // This handles all edge cases: rails, state filtering, block entity data, @@ -68,7 +68,7 @@ class PlaceBlockAction( level.sendParticles( ParticleTypes.HAPPY_VILLAGER, pos.x + 0.5, pos.y + 0.5, pos.z + 0.5, - 5, 0.3, 0.3, 0.3, 0.0 + 1, 0.2, 0.2, 0.2, 0.0 ) } diff --git a/src/main/kotlin/de/devin/cbbees/content/domain/action/impl/RemoveBlockAction.kt b/src/main/kotlin/de/devin/cbbees/content/domain/action/impl/RemoveBlockAction.kt index 457d5d6..d927fe1 100644 --- a/src/main/kotlin/de/devin/cbbees/content/domain/action/impl/RemoveBlockAction.kt +++ b/src/main/kotlin/de/devin/cbbees/content/domain/action/impl/RemoveBlockAction.kt @@ -3,7 +3,7 @@ package de.devin.cbbees.content.domain.action.impl import com.simibubi.create.content.decoration.copycat.CopycatBlockEntity import de.devin.cbbees.config.CBBeesConfig import de.devin.cbbees.content.domain.action.BeeAction -import de.devin.cbbees.content.bee.MechanicalBeeEntity +import de.devin.cbbees.content.bee.server.BeeWorker import de.devin.cbbees.content.domain.beehive.BeeHive import de.devin.cbbees.content.upgrades.BeeContext import net.minecraft.core.BlockPos @@ -14,39 +14,31 @@ import net.minecraft.world.item.ItemStack import net.minecraft.world.level.Level import net.minecraft.world.level.block.Block -/** - * Models an action to break a block at a given position - */ class RemoveBlockAction(override val pos: BlockPos) : BeeAction { - //TODO calculate how long it takes to break block - override fun getWorkTicks(context: BeeContext): Int = 5 // BASE_BREAK_TICKS - override fun onTick(robot: MechanicalBeeEntity, tick: Int) { - if (robot.level() is ServerLevel) { - (robot.level() as ServerLevel).sendParticles( + override fun getWorkTicks(context: BeeContext): Int = 5 + + override fun onTick(worker: BeeWorker, tick: Int) { + val level = worker.level() + if (level is ServerLevel) { + level.sendParticles( ParticleTypes.ELECTRIC_SPARK, - robot.x, robot.y, robot.z, + worker.getWorkerX(), worker.getWorkerY(), worker.getWorkerZ(), 2, 0.2, 0.2, 0.2, 0.05 ) } } - override fun execute(level: Level, bee: MechanicalBeeEntity, context: BeeContext): Boolean { + override fun execute(level: Level, worker: BeeWorker, context: BeeContext): Boolean { if (level !is ServerLevel) return false - - // Never destroy a Mechanical Beehive — skip silently if (level.getBlockEntity(pos) is BeeHive) return true - - // Drop Items upgrade: break block and let items drop naturally, skip pickup entirely if (context.dropItemsEnabled) { level.destroyBlock(pos, true) return true } - val shouldPickUp = CBBeesConfig.beePickupItems.get() - - if (shouldPickUp) { + if (CBBeesConfig.beePickupItems.get()) { val state = level.getBlockState(pos) val blockEntity = level.getBlockEntity(pos) val drops = if (context.silkTouchEnabled) { @@ -55,34 +47,32 @@ class RemoveBlockAction(override val pos: BlockPos) : BeeAction { Block.getDrops(state, level, pos, blockEntity) } - // Clear container contents before destroying so onRemove doesn't drop them. - // Collect the contents to add to bee inventory alongside block drops. val extraDrops = mutableListOf() if (blockEntity is net.minecraft.world.Container) { - for (i in 0 until blockEntity.containerSize) { - val stack = blockEntity.getItem(i) - if (!stack.isEmpty) { - extraDrops.add(stack.copy()) - } - } + (0 until blockEntity.containerSize) + .map { blockEntity.getItem(it) } + .filter { !it.isEmpty } + .forEach { extraDrops.add(it.copy()) } blockEntity.clearContent() } - - // Extract copycat material before destroying so onRemove doesn't pop it on the ground. if (blockEntity is CopycatBlockEntity) { - val consumedItem = blockEntity.consumedItem - if (!consumedItem.isEmpty) { - extraDrops.add(consumedItem.copy()) - } + blockEntity.consumedItem.takeIf { !it.isEmpty }?.let { extraDrops.add(it.copy()) } blockEntity.clearContent() } level.destroyBlock(pos, false) - for (drop in drops + extraDrops) { - val remainder = bee.addToInventory(drop) - if (!remainder.isEmpty) { - val itemEntity = ItemEntity(level, pos.x + 0.5, pos.y + 0.5, pos.z + 0.5, remainder) - level.addFreshEntity(itemEntity) + val hasPort = worker.network()?.findDropOff(ItemStack.EMPTY) != null + (drops + extraDrops).forEach { drop -> + if (hasPort) { + // Port available — pick up into inventory for later deposit + val remainder = worker.addToInventory(drop) + if (!remainder.isEmpty) { + // Inventory full — drop overflow on ground + level.addFreshEntity(ItemEntity(level, pos.x + 0.5, pos.y + 0.5, pos.z + 0.5, remainder)) + } + } else { + // No port — drop everything on ground immediately + level.addFreshEntity(ItemEntity(level, pos.x + 0.5, pos.y + 0.5, pos.z + 0.5, drop)) } } } else { @@ -94,7 +84,5 @@ class RemoveBlockAction(override val pos: BlockPos) : BeeAction { override fun shouldReturnAfter(context: BeeContext): Boolean = !context.dropItemsEnabled && CBBeesConfig.beePickupItems.get() - override fun getDescription(): String { - return "Removing block at (${pos.x}, ${pos.y}, ${pos.z})" - } -} \ No newline at end of file + override fun getDescription() = "Removing block at (${pos.x}, ${pos.y}, ${pos.z})" +} diff --git a/src/main/kotlin/de/devin/cbbees/content/domain/bee/BeeInventoryManager.kt b/src/main/kotlin/de/devin/cbbees/content/domain/bee/BeeInventoryManager.kt index 57208c8..642c9c1 100644 --- a/src/main/kotlin/de/devin/cbbees/content/domain/bee/BeeInventoryManager.kt +++ b/src/main/kotlin/de/devin/cbbees/content/domain/bee/BeeInventoryManager.kt @@ -10,35 +10,35 @@ import net.minecraft.server.level.ServerPlayer import net.minecraft.world.item.ItemStack /** - * Handles inventory operations for constructor robots, including item collection + * Handles inventory operations for construction bees, including item collection * from player inventory and wireless storages. */ -class BeeInventoryManager(private val robot: MechanicalBeeEntity) { +class BeeInventoryManager(private val bee: MechanicalBeeEntity) { /** * Picks up items for the current task into the bee's inventory. */ fun pickUpItems(required: List, context: BeeContext): Boolean { - val ownerPlayer = robot.getOwnerPlayer() ?: return false + val ownerPlayer = bee.getOwnerPlayer() ?: return false if (ownerPlayer.isCreative) return true val source = getMaterialSource(ownerPlayer, context) - robot.inventory.clearContent() + bee.inventory.clearContent() for (req in required) { if (req.isEmpty) continue - if (robot.isInventoryFull()) break + if (bee.isInventoryFull()) break val extracted = source.extractItems(req, req.count) if (!extracted.isEmpty) { - robot.addToInventory(extracted) + bee.addToInventory(extracted) } } val totalRequired = required.sumOf { it.count } var totalCarried = 0 - for (i in 0 until robot.inventory.containerSize) { - totalCarried += robot.inventory.getItem(i).count + for (i in 0 until bee.inventory.containerSize) { + totalCarried += bee.inventory.getItem(i).count } return totalCarried >= totalRequired } @@ -47,19 +47,19 @@ class BeeInventoryManager(private val robot: MechanicalBeeEntity) { * Deposits carried items back into player or wireless inventory. */ fun depositItems(context: BeeContext) { - val ownerPlayer = robot.getOwnerPlayer() ?: return + val ownerPlayer = bee.getOwnerPlayer() ?: return if (ownerPlayer.isCreative) { - robot.inventory.clearContent() + bee.inventory.clearContent() return } val source = getMaterialSource(ownerPlayer, context) - for (i in 0 until robot.inventory.containerSize) { - val stack = robot.inventory.getItem(i) + for (i in 0 until bee.inventory.containerSize) { + val stack = bee.inventory.getItem(i) if (stack.isEmpty) continue val remaining = source.insertItems(stack) - robot.inventory.setItem(i, remaining) + bee.inventory.setItem(i, remaining) } } @@ -73,8 +73,8 @@ class BeeInventoryManager(private val robot: MechanicalBeeEntity) { sources.add(PlayerMaterialSource(ownerPlayer)) // 2. Network inventory - robot.network()?.let { - sources.add(NetworkMaterialSource(it, robot.level())) + bee.network()?.let { + sources.add(NetworkMaterialSource(it, bee.level())) } return CompositeMaterialSource(sources) diff --git a/src/main/kotlin/de/devin/cbbees/content/domain/beehive/BeeHive.kt b/src/main/kotlin/de/devin/cbbees/content/domain/beehive/BeeHive.kt index fec1e4b..8d0fd01 100644 --- a/src/main/kotlin/de/devin/cbbees/content/domain/beehive/BeeHive.kt +++ b/src/main/kotlin/de/devin/cbbees/content/domain/beehive/BeeHive.kt @@ -105,7 +105,7 @@ interface BeeHive : INetworkComponent { * Marks the given task as completed by the given bee. * @return optionally next task batch to be processed, or null if all tasks are completed. */ - fun notifyTaskCompleted(task: BeeTask, bee: MechanicalBeeEntity): TaskBatch? + fun notifyTaskCompleted(task: BeeTask, beeId: java.util.UUID): TaskBatch? /** * Called when a bee arrives at the hive to recharge its spring. diff --git a/src/main/kotlin/de/devin/cbbees/content/domain/beehive/PortableBeeHive.kt b/src/main/kotlin/de/devin/cbbees/content/domain/beehive/PortableBeeHive.kt index 40bfc4b..7951710 100644 --- a/src/main/kotlin/de/devin/cbbees/content/domain/beehive/PortableBeeHive.kt +++ b/src/main/kotlin/de/devin/cbbees/content/domain/beehive/PortableBeeHive.kt @@ -1,9 +1,8 @@ package de.devin.cbbees.content.domain.beehive import de.devin.cbbees.content.backpack.PortableBeehiveItem -import de.devin.cbbees.registry.AllDataComponents import de.devin.cbbees.content.bee.MechanicalBeeEntity -import de.devin.cbbees.content.bee.brain.BeeMemoryModules +import de.devin.cbbees.content.bee.server.ServerBeeManager import de.devin.cbbees.content.domain.GlobalJobPool import de.devin.cbbees.content.domain.logistics.LogisticsPort import de.devin.cbbees.content.domain.task.BeeTask @@ -11,8 +10,9 @@ import de.devin.cbbees.content.domain.task.TaskBatch import de.devin.cbbees.content.logistics.ports.PortType import de.devin.cbbees.content.upgrades.BeeContext import de.devin.cbbees.config.CBBeesConfig -import de.devin.cbbees.registry.AllEntityTypes +import de.devin.cbbees.registry.AllDataComponents import net.minecraft.core.BlockPos +import net.minecraft.server.level.ServerLevel import net.minecraft.world.entity.ai.memory.WalkTarget import net.minecraft.world.entity.player.Player import net.minecraft.world.item.ItemStack @@ -43,7 +43,7 @@ class PortableBeeHive(val player: Player) : BeeHive, LogisticsPort { if (getAvailableBeeCount() <= 0) { return false } - if (getActiveBeeCount() >= getBeeContext().maxActiveRobots) return false + if (getActiveBeeCount() >= getBeeContext().maxActiveBees) return false val beeItem = consumeBee() if (beeItem.isEmpty) return false @@ -56,28 +56,21 @@ class PortableBeeHive(val player: Player) : BeeHive, LogisticsPort { } private fun spawnBee(beeItem: ItemStack, batch: TaskBatch): Boolean { - val bee = MechanicalBeeEntity(AllEntityTypes.MECHANICAL_BEE.get(), player.level()).apply { - setOwner(player.uuid) - setPos(player.position().add(0.0, 2.0, 0.0)) - this.networkId = this@PortableBeeHive.network().id - } - - // Always charge honey to wind the spring for deployment val ctx = getBeeContext() - val honeyCost = - (CBBeesConfig.portableHoneyPerRewind.get() * ctx.fuelConsumptionMultiplier).toInt().coerceAtLeast(1) + val honeyCost = (CBBeesConfig.portableHoneyPerRewind.get() * ctx.fuelConsumptionMultiplier).toInt().coerceAtLeast(1) consumeHoney(honeyCost) - bee.springTension = 1.0f - - bee.setHomeId(player.uuid) - bee.brain.setMemory(BeeMemoryModules.HIVE_POS.get(), player.blockPosition().above(2)) - bee.brain.setMemory(BeeMemoryModules.HIVE_INSTANCE.get(), Optional.of(this)) - bee.brain.setMemory(BeeMemoryModules.CURRENT_TASK.get(), Optional.of(batch)) - - batch.assignToRobot(bee) - player.level().addFreshEntity(bee) - activeBees.add(bee.uuid) + val spawnPos = player.position().add(0.0, 2.0, 0.0) + val bee = ServerBeeManager.spawnConstructionBee( + hive = this, + batch = batch, + networkId = network().id, + spawnPos = spawnPos, + context = ctx, + ownerId = player.uuid, + ) + + activeBees.add(bee.id) return true } @@ -90,17 +83,17 @@ class PortableBeeHive(val player: Player) : BeeHive, LogisticsPort { * Called by the watchdog to prevent ghost bee counts from blocking new dispatches. */ fun cleanupOrphanedBees() { - val level = player.level() as? net.minecraft.server.level.ServerLevel ?: return + val level = player.level() as? ServerLevel ?: return activeBees.removeIf { beeId -> val entity = level.getEntity(beeId) entity == null || !entity.isAlive } } - override fun notifyTaskCompleted(task: BeeTask, bee: MechanicalBeeEntity): TaskBatch? { + override fun notifyTaskCompleted(task: BeeTask, beeId: UUID): TaskBatch? { if (!isValid()) return null val nextBatch = GlobalJobPool.workBacklog(this) - nextBatch?.assignToRobot(bee) + nextBatch?.assignToBee(beeId, player.level().gameTime) return nextBatch } diff --git a/src/main/kotlin/de/devin/cbbees/content/domain/events/PlayerTickEvent.kt b/src/main/kotlin/de/devin/cbbees/content/domain/events/PlayerTickEvent.kt index d0a9e91..17ed5d6 100644 --- a/src/main/kotlin/de/devin/cbbees/content/domain/events/PlayerTickEvent.kt +++ b/src/main/kotlin/de/devin/cbbees/content/domain/events/PlayerTickEvent.kt @@ -2,13 +2,17 @@ package de.devin.cbbees.content.domain.events import de.devin.cbbees.CreateBuzzyBeez import de.devin.cbbees.content.backpack.PortableBeehiveItem -import de.devin.cbbees.content.domain.network.ServerBeeNetworkManager import de.devin.cbbees.content.domain.beehive.PortableBeeHive +import de.devin.cbbees.content.domain.job.JobCalculationProgress +import de.devin.cbbees.content.domain.network.ServerBeeNetworkManager +import de.devin.cbbees.network.JobProgressPacket +import de.devin.cbbees.util.ServerSide +import net.minecraft.server.level.ServerPlayer import net.neoforged.bus.api.SubscribeEvent import net.neoforged.neoforge.event.entity.player.PlayerEvent import net.neoforged.neoforge.event.tick.PlayerTickEvent +import net.neoforged.neoforge.network.PacketDistributor import top.theillusivec4.curios.api.CuriosApi -import de.devin.cbbees.util.ServerSide @ServerSide class PlayerTickEvent { @@ -26,6 +30,17 @@ class PlayerTickEvent { hive.networkId = ServerBeeNetworkManager.stableNetworkId(player.uuid) ServerBeeNetworkManager.registerWorker(hive) } + + // Replay any cached calculation progress so the player resumes seeing + // live progress for jobs that were running while they were offline. + if (player is ServerPlayer) { + JobCalculationProgress.snapshotsForOwner(player.uuid).forEach { snap -> + PacketDistributor.sendToPlayer( + player, + JobProgressPacket(snap.jobId, snap.phase, snap.labelKey, snap.processedBlocks, snap.expectedBlocks, snap.resultKey, snap.resultCount), + ) + } + } } @SubscribeEvent diff --git a/src/main/kotlin/de/devin/cbbees/content/domain/job/BeeJob.kt b/src/main/kotlin/de/devin/cbbees/content/domain/job/BeeJob.kt index 10eb3a1..7379860 100644 --- a/src/main/kotlin/de/devin/cbbees/content/domain/job/BeeJob.kt +++ b/src/main/kotlin/de/devin/cbbees/content/domain/job/BeeJob.kt @@ -1,7 +1,5 @@ package de.devin.cbbees.content.domain.job -import de.devin.cbbees.content.bee.MechanicalBeeEntity -import de.devin.cbbees.content.bee.brain.BeeMemoryModules import de.devin.cbbees.content.domain.beehive.BeeHive import de.devin.cbbees.content.domain.task.BeeTask import de.devin.cbbees.content.domain.task.TaskBatch @@ -121,9 +119,9 @@ data class BeeJob( * Gets the next pending task and assigns it to a robot. */ @Synchronized - fun claimNextTaskBatch(bee: MechanicalBeeEntity): TaskBatch? { + fun claimNextTaskBatch(beeId: UUID, gameTime: Long): TaskBatch? { val batch = batches.firstOrNull { it.status == TaskStatus.PENDING } - batch?.assignToRobot(bee) + batch?.assignToBee(beeId, gameTime) return batch } @@ -164,7 +162,6 @@ data class BeeJob( status = JobStatus.CANCELLED tasks.forEach { if (it.status == TaskStatus.PENDING || it.status == TaskStatus.IN_PROGRESS) { - it.mechanicalBee?.brain?.eraseMemory(BeeMemoryModules.CURRENT_TASK.get()) it.cancel() } } diff --git a/src/main/kotlin/de/devin/cbbees/content/domain/job/JobCalculationProgress.kt b/src/main/kotlin/de/devin/cbbees/content/domain/job/JobCalculationProgress.kt new file mode 100644 index 0000000..b181ead --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/domain/job/JobCalculationProgress.kt @@ -0,0 +1,144 @@ +package de.devin.cbbees.content.domain.job + +import de.devin.cbbees.network.JobProgressPacket +import net.minecraft.server.MinecraftServer +import net.neoforged.neoforge.network.PacketDistributor +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import kotlin.math.min + +/** + * Server-side singleton tracking calculation progress for long-running job setup + * (schematic build/removal task generation). Drives [JobProgressPacket] broadcasts + * to job owners and caches the latest snapshot per active job so that re-joining + * players can resume seeing live progress. + * + * Trackers are created via [newTracker] by packet handlers. The bridge advances + * each tracker once per processed chunk by calling [Tracker.advance] with the + * actual block count consumed in that chunk. The chunk size is purely a server + * pacing concern — clients only see real block counts. + * + * Completed/failed entries linger in the cache for [EVICTION_DELAY_TICKS] so a + * player who joins right at the end still sees the final state briefly. + */ +object JobCalculationProgress { + + enum class Phase { STARTED, IN_PROGRESS, COMPLETED, FAILED } + + /** Immutable progress snapshot — used for the in-memory cache and packet payload. */ + data class Snapshot( + val jobId: UUID, + val ownerId: UUID, + val labelKey: String, + val expectedBlocks: Int, + val processedBlocks: Int, + val phase: Phase, + /** Translation key for the completion message (only set when [phase] is COMPLETED). */ + val resultKey: String = "", + /** Argument passed to the completion translation (e.g. task count). */ + val resultCount: Int = 0, + ) + + /** + * Mutable per-job tracker. Created by packet handlers, passed into + * `SchematicCreateBridge` and advanced per chunk. Mutated only on the + * server thread; reads are safe via @Volatile. + */ + class Tracker internal constructor( + val jobId: UUID, + val ownerId: UUID, + val labelKey: String, + val expectedBlocks: Int, + private val server: MinecraftServer, + ) { + @Volatile private var processedBlocks: Int = 0 + @Volatile private var phase: Phase = Phase.STARTED + @Volatile private var resultKey: String = "" + @Volatile private var resultCount: Int = 0 + + /** Register, broadcast STARTED. */ + fun start() { + register(this) + broadcast() + } + + /** Add [blocksThisChunk] to the running total and broadcast. Clamped at [expectedBlocks]. */ + fun advance(blocksThisChunk: Int) { + processedBlocks = min(processedBlocks + blocksThisChunk, expectedBlocks) + phase = Phase.IN_PROGRESS + broadcast() + } + + /** + * Force processedBlocks = expectedBlocks, broadcast COMPLETED, schedule eviction. + * @param completionKey translation key for the completion message (e.g. `cbbees.construction.started`) + * @param count argument passed to the translation (e.g. number of task batches) + */ + fun complete(completionKey: String = "", count: Int = 0) { + processedBlocks = expectedBlocks + phase = Phase.COMPLETED + resultKey = completionKey + resultCount = count + broadcast() + scheduleEviction() + } + + /** Broadcast FAILED, schedule eviction. */ + fun fail() { + phase = Phase.FAILED + broadcast() + scheduleEviction() + } + + internal fun snapshot(): Snapshot = + Snapshot(jobId, ownerId, labelKey, expectedBlocks, processedBlocks, phase, resultKey, resultCount) + + private fun broadcast() { + val snap = snapshot() + val player = server.playerList.getPlayer(ownerId) ?: return + PacketDistributor.sendToPlayer( + player, + JobProgressPacket(snap.jobId, snap.phase, snap.labelKey, snap.processedBlocks, snap.expectedBlocks, snap.resultKey, snap.resultCount), + ) + } + + private fun scheduleEviction() { + // Defer eviction to a later tick so the final packet still resolves from + // the cache for any player who joins within the next ~5s. + val targetTick = server.tickCount + EVICTION_DELAY_TICKS + evictionQueue[jobId] = targetTick + } + } + + private val active = ConcurrentHashMap() + private val evictionQueue = ConcurrentHashMap() + + private const val EVICTION_DELAY_TICKS = 100 // 5 seconds at 20 TPS + + fun newTracker( + jobId: UUID, + ownerId: UUID, + labelKey: String, + expectedBlocks: Int, + server: MinecraftServer, + ): Tracker = Tracker(jobId, ownerId, labelKey, expectedBlocks.coerceAtLeast(1), server) + + /** Returns snapshots of all active jobs owned by [ownerId]. Used for late-joiner replay. */ + fun snapshotsForOwner(ownerId: UUID): List = + active.values.filter { it.ownerId == ownerId }.map { it.snapshot() } + + /** Called every server tick to evict expired entries. */ + fun tickEvictions(currentTick: Int) { + if (evictionQueue.isEmpty()) return + val expired = evictionQueue.entries.filter { currentTick >= it.value }.map { it.key } + for (id in expired) { + evictionQueue.remove(id) + active.remove(id) + } + } + + private fun register(tracker: Tracker) { + active[tracker.jobId] = tracker + evictionQueue.remove(tracker.jobId) + } +} diff --git a/src/main/kotlin/de/devin/cbbees/content/domain/job/client/JobProgressClient.kt b/src/main/kotlin/de/devin/cbbees/content/domain/job/client/JobProgressClient.kt new file mode 100644 index 0000000..c4f4348 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/domain/job/client/JobProgressClient.kt @@ -0,0 +1,28 @@ +package de.devin.cbbees.content.domain.job.client + +import de.devin.cbbees.network.JobProgressPacket +import net.minecraft.client.Minecraft +import net.neoforged.api.distmarker.Dist +import net.neoforged.api.distmarker.OnlyIn + +/** + * Client-side handler for [JobProgressPacket]. Looks up an existing + * [JobProgressToast] for the job (by jobId) and updates it in place, or creates + * a new toast if none exists. Multiple concurrent jobs each get their own toast + * which stack in the vanilla toast queue. + */ +@OnlyIn(Dist.CLIENT) +object JobProgressClient { + + fun apply(packet: JobProgressPacket) { + val toasts = Minecraft.getInstance().toasts + val existing = toasts.getToast(JobProgressToast::class.java, packet.jobId) + if (existing != null) { + existing.applyUpdate(packet) + } else { + val toast = JobProgressToast(packet.jobId) + toast.applyUpdate(packet) + toasts.addToast(toast) + } + } +} diff --git a/src/main/kotlin/de/devin/cbbees/content/domain/job/client/JobProgressToast.kt b/src/main/kotlin/de/devin/cbbees/content/domain/job/client/JobProgressToast.kt new file mode 100644 index 0000000..9bfd626 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/domain/job/client/JobProgressToast.kt @@ -0,0 +1,143 @@ +package de.devin.cbbees.content.domain.job.client + +import de.devin.cbbees.content.domain.job.JobCalculationProgress +import de.devin.cbbees.network.JobProgressPacket +import net.minecraft.client.Minecraft +import net.minecraft.client.gui.Font +import net.minecraft.client.gui.GuiGraphics +import net.minecraft.client.gui.components.toasts.Toast +import net.minecraft.client.gui.components.toasts.ToastComponent +import net.minecraft.network.chat.Component +import net.minecraft.resources.ResourceLocation +import net.neoforged.api.distmarker.Dist +import net.neoforged.api.distmarker.OnlyIn +import java.util.UUID + +/** + * Vanilla-style toast that displays the live calculation progress of a single job. + * + * Uses [jobId] as the toast token, so [JobProgressClient] can look up and mutate + * an existing toast in-place via [ToastComponent.getToast] instead of replacing it + * each tick. Multiple concurrent jobs each get their own toast and stack in the + * vanilla toast queue automatically. + * + * @see JobProgressClient + * @see JobCalculationProgress + */ +@OnlyIn(Dist.CLIENT) +class JobProgressToast(private val jobId: UUID) : Toast { + + private var labelKey: String = "" + private var phase: JobCalculationProgress.Phase = JobCalculationProgress.Phase.STARTED + private var processedBlocks: Int = 0 + private var expectedBlocks: Int = 1 + private var resultKey: String = "" + private var resultCount: Int = 0 + + /** Wall-clock millis when phase entered a terminal state. Drives fade-out timing. */ + private var terminalAtMillis: Long = 0L + + override fun width(): Int = WIDTH + override fun height(): Int = HEIGHT + override fun getToken(): Any = jobId + + fun applyUpdate(packet: JobProgressPacket) { + labelKey = packet.labelKey + processedBlocks = packet.processedBlocks + expectedBlocks = packet.expectedBlocks.coerceAtLeast(1) + resultKey = packet.resultKey + resultCount = packet.resultCount + val previousPhase = phase + phase = packet.phase + if (phase != previousPhase && + (phase == JobCalculationProgress.Phase.COMPLETED || phase == JobCalculationProgress.Phase.FAILED) + ) { + terminalAtMillis = System.currentTimeMillis() + } + } + + override fun render( + graphics: GuiGraphics, + toastComponent: ToastComponent, + timeSinceLastVisible: Long, + ): Toast.Visibility { + // Background — vanilla system toast sprite (160x32). Our width is 160 to match. + graphics.blitSprite(BACKGROUND_SPRITE, 0, 0, WIDTH, HEIGHT) + + val font = Minecraft.getInstance().font + + if (phase == JobCalculationProgress.Phase.COMPLETED && resultKey.isNotEmpty()) { + // Row 1: checkmark + result label + val msg = Component.literal("§a✓ §f").append(Component.translatable(resultKey, resultCount)) + graphics.drawString(font, msg, 8, 9, TITLE_COLOR, false) + + // Row 2: count detail + val unit = if (labelKey.contains("item") || labelKey.contains("pickup")) "items" else "blocks" + val detail = Component.literal("${formatBlocks(expectedBlocks)} $unit") + drawScaled(graphics, font, detail, 8, 19, DETAIL_COLOR, SMALL_SCALE) + } else { + // Row 1: title + val title: Component = Component.translatable(labelKey) + graphics.drawString(font, title, 8, 7, TITLE_COLOR, false) + + // Row 2: block counts + val pct = (processedBlocks.toLong() * 100 / expectedBlocks.coerceAtLeast(1)).toInt().coerceIn(0, 100) + val counts = Component.literal("${formatBlocks(processedBlocks)} / ${formatBlocks(expectedBlocks)} ($pct%)") + drawScaled(graphics, font, counts, 8, 19, DETAIL_COLOR, SMALL_SCALE) + } + + // Visibility logic — keep showing until calculation finishes + fade delay, + // or fail-safe at the absolute timeout in case the server stops sending updates. + return when (phase) { + JobCalculationProgress.Phase.STARTED, JobCalculationProgress.Phase.IN_PROGRESS -> + if (timeSinceLastVisible >= STALE_TIMEOUT_MS) Toast.Visibility.HIDE else Toast.Visibility.SHOW + + JobCalculationProgress.Phase.COMPLETED, JobCalculationProgress.Phase.FAILED -> { + val elapsed = System.currentTimeMillis() - terminalAtMillis + if (elapsed >= TERMINAL_FADE_MS) Toast.Visibility.HIDE else Toast.Visibility.SHOW + } + } + } + + private fun formatBlocks(n: Int): String = "%,d".format(n) + + private fun drawScaled( + graphics: GuiGraphics, + font: Font, + text: Component, + x: Int, + y: Int, + color: Int, + scale: Float + ) { + graphics.pose().pushPose() + graphics.pose().scale(scale, scale, 1f) + graphics.drawString(font, text, (x / scale).toInt(), (y / scale).toInt(), color, false) + graphics.pose().popPose() + } + + companion object { + private const val WIDTH = 160 + private const val HEIGHT = 32 + private const val BAR_WIDTH = 144 + private const val BAR_HEIGHT = 4 + + private const val SMALL_SCALE = 0.8f + + private const val TITLE_COLOR = 0xFFFFFFFF.toInt() + private const val DETAIL_COLOR = 0xFFAAAAAA.toInt() + private const val BAR_BG_COLOR = 0xFF333333.toInt() + private const val BAR_FILL_ACTIVE = 0xFFFFCC00.toInt() // bee yellow + private const val BAR_FILL_DONE = 0xFF55DD55.toInt() + private const val BAR_FILL_FAIL = 0xFFDD5555.toInt() + + /** Auto-hide if no progress packets arrive for this long (server hung / left dim). */ + private const val STALE_TIMEOUT_MS = 60_000L + + /** How long to keep the COMPLETED/FAILED toast visible before fading out. */ + private const val TERMINAL_FADE_MS = 4_000L + + private val BACKGROUND_SPRITE: ResourceLocation = + ResourceLocation.fromNamespaceAndPath("cbbees", "toast/background") + } +} diff --git a/src/main/kotlin/de/devin/cbbees/content/domain/logistics/PortReservationManager.kt b/src/main/kotlin/de/devin/cbbees/content/domain/logistics/PortReservationManager.kt index df695a3..cc5c029 100644 --- a/src/main/kotlin/de/devin/cbbees/content/domain/logistics/PortReservationManager.kt +++ b/src/main/kotlin/de/devin/cbbees/content/domain/logistics/PortReservationManager.kt @@ -1,5 +1,6 @@ package de.devin.cbbees.content.domain.logistics +import de.devin.cbbees.util.ItemStackKey import net.minecraft.world.item.ItemStack import java.util.UUID @@ -9,6 +10,8 @@ import java.util.UUID * Both [de.devin.cbbees.content.logistics.ports.LogisticPortBlockEntity] and * [de.devin.cbbees.content.logistics.transport.TransportPortBlockEntity] use identical * reservation tracking. This class extracts that shared logic. + * + * Maintains a pre-aggregated count map for O(1) lookups in the common case. */ class PortReservationManager { @@ -16,36 +19,75 @@ class PortReservationManager { private val reservations = mutableMapOf() + // Pre-aggregated total reserved counts by item type, maintained incrementally + private val aggregatedCounts = mutableMapOf() + val hasReservations: Boolean get() = reservations.isNotEmpty() /** * Returns the total reserved count of [stack], optionally excluding one bee's reservation. */ fun getReservedCount(stack: ItemStack, excludeBeeId: UUID? = null): Int { - return reservations - .filter { excludeBeeId == null || it.key != excludeBeeId } - .values.flatMap { it.items } - .filter { ItemStack.isSameItemSameComponents(it, stack) } - .sumOf { it.count } + if (reservations.isEmpty()) return 0 + val key = ItemStackKey(stack) + val total = aggregatedCounts[key] ?: return 0 + if (excludeBeeId == null) return total + // Subtract the excluded bee's contribution + val excluded = reservations[excludeBeeId]?.items + ?.filter { ItemStack.isSameItemSameComponents(it, stack) } + ?.sumOf { it.count } ?: 0 + return total - excluded } fun reserve(beeId: UUID, items: List, tick: Long) { + // Remove old reservation counts if overwriting + reservations[beeId]?.let { old -> removeFromAggregated(old.items) } reservations[beeId] = Reservation(items, tick) + addToAggregated(items) } fun release(beeId: UUID): Boolean { - return reservations.remove(beeId) != null + val removed = reservations.remove(beeId) ?: return false + removeFromAggregated(removed.items) + return true } fun cleanup(currentTick: Long, maxAge: Long = 600): Boolean { val sizeBefore = reservations.size - reservations.entries.removeAll { currentTick - it.value.tick > maxAge } + val iter = reservations.iterator() + while (iter.hasNext()) { + val entry = iter.next() + if (currentTick - entry.value.tick > maxAge) { + removeFromAggregated(entry.value.items) + iter.remove() + } + } return reservations.size != sizeBefore } fun clear(): Boolean { val had = reservations.isNotEmpty() reservations.clear() + aggregatedCounts.clear() return had } + + private fun addToAggregated(items: List) { + for (item in items) { + val key = ItemStackKey(item) + aggregatedCounts[key] = (aggregatedCounts[key] ?: 0) + item.count + } + } + + private fun removeFromAggregated(items: List) { + for (item in items) { + val key = ItemStackKey(item) + val newCount = (aggregatedCounts[key] ?: 0) - item.count + if (newCount <= 0) { + aggregatedCounts.remove(key) + } else { + aggregatedCounts[key] = newCount + } + } + } } diff --git a/src/main/kotlin/de/devin/cbbees/content/domain/logistics/ReservablePort.kt b/src/main/kotlin/de/devin/cbbees/content/domain/logistics/ReservablePort.kt index dadcfc7..ca63140 100644 --- a/src/main/kotlin/de/devin/cbbees/content/domain/logistics/ReservablePort.kt +++ b/src/main/kotlin/de/devin/cbbees/content/domain/logistics/ReservablePort.kt @@ -38,6 +38,18 @@ interface ReservablePort : INetworkComponent { fun addItemStack(stack: ItemStack): ItemStack + /** + * Checks whether the attached inventory has any free space at all. + */ + fun hasInventorySpace(): Boolean { + val handler = getItemHandler(world) ?: return false + for (i in 0 until handler.slots) { + val slotStack = handler.getStackInSlot(i) + if (slotStack.isEmpty || slotStack.count < slotStack.maxStackSize) return true + } + return false + } + /** * Reserves [items] at this port for the given bee. One reservation per bee; * calling again replaces the previous reservation. diff --git a/src/main/kotlin/de/devin/cbbees/content/domain/network/BeeNetwork.kt b/src/main/kotlin/de/devin/cbbees/content/domain/network/BeeNetwork.kt index 6102176..d1a50a6 100644 --- a/src/main/kotlin/de/devin/cbbees/content/domain/network/BeeNetwork.kt +++ b/src/main/kotlin/de/devin/cbbees/content/domain/network/BeeNetwork.kt @@ -29,19 +29,29 @@ class BeeNetwork( private var _ports: List? = null private var _transportPorts: List? = null private var _reservablePorts: List? = null + private var _transportPortsByPos: Map? = null private fun invalidateComponentCaches() { _hives = null _ports = null _transportPorts = null _reservablePorts = null + _transportPortsByPos = null } /** * Removes components whose block entity no longer exists at their position in the world. * Guards against ghost components that weren't properly unregistered. + * + * Protected by a tick-based guard so it runs at most once per tick per network, + * avoiding hundreds of redundant [net.minecraft.world.level.Level.getBlockEntity] calls. */ - fun purgeStaleComponents() { + private var lastPurgeTick: Long = -1 + + fun purgeStaleComponents(currentTick: Long = -1L) { + if (currentTick >= 0 && currentTick == lastPurgeTick) return + if (currentTick >= 0) lastPurgeTick = currentTick + val removed = _components.removeAll { comp -> val be = comp as? BlockEntity ?: return@removeAll false // Check if the block entity is marked as removed @@ -59,13 +69,13 @@ class BeeNetwork( val hives: List get() = _hives ?: components.filterIsInstance().also { _hives = it } val ports: List get() = _ports ?: components.filterIsInstance().also { _ports = it } val transportPorts: List get() = _transportPorts ?: components.filterIsInstance().also { _transportPorts = it } + val transportPortsByPos: Map get() = _transportPortsByPos ?: transportPorts.associateBy { it.pos }.also { _transportPortsByPos = it } val reservablePorts: List get() = _reservablePorts ?: components.filterIsInstance().also { _reservablePorts = it } /** * The aggregate operational range of all anchors in this network. */ fun isInRange(pos: BlockPos): Boolean { - purgeStaleComponents() return components.any { topology.isAnchor(it) && topology.isOperationalRange(it, pos) } } @@ -74,7 +84,6 @@ class BeeNetwork( * Considers both block-based anchors (mechanical beehives) and portable beehives. */ fun isInLogisticsRange(pos: BlockPos): Boolean { - purgeStaleComponents() return components.any { c -> (c is BlockEntity || c is PortableBeeHive) && topology.isAnchor(c) && topology.isLogisticsRange(c, pos) } @@ -82,20 +91,17 @@ class BeeNetwork( fun findProvider(stack: ItemStack): LogisticsPort? { return ports.filter { it.isValidForPickup() && it.testFilter(stack) && it.hasItemStack(stack) } - .sortedByDescending { it.priority() } - .firstOrNull() + .maxByOrNull { it.priority() } } fun findDropOff(stack: ItemStack): LogisticsPort? { return ports.filter { it.isValidForDropOff() && (stack.isEmpty || it.testFilter(stack)) } - .sortedByDescending { it.priority() } - .firstOrNull() + .maxByOrNull { it.priority() } } fun findAvailableProvider(stack: ItemStack, excludeBeeId: UUID? = null): LogisticsPort? { return ports.filter { it.isValidForPickup() && it.testFilter(stack) && it.hasAvailableItemStack(stack, excludeBeeId) } - .sortedByDescending { it.priority() } - .firstOrNull() + .maxByOrNull { it.priority() } } fun releaseReservations(beeId: UUID) { @@ -122,7 +128,6 @@ class BeeNetwork( } fun canConnect(component: INetworkComponent): Boolean { - purgeStaleComponents() if (components.isEmpty()) { de.devin.cbbees.CreateBuzzyBeez.LOGGER.info("[NET] canConnect: network $id is EMPTY → true") return true diff --git a/src/main/kotlin/de/devin/cbbees/content/domain/network/BeeNetworkManager.kt b/src/main/kotlin/de/devin/cbbees/content/domain/network/BeeNetworkManager.kt index 4f3b520..6f9624f 100644 --- a/src/main/kotlin/de/devin/cbbees/content/domain/network/BeeNetworkManager.kt +++ b/src/main/kotlin/de/devin/cbbees/content/domain/network/BeeNetworkManager.kt @@ -18,6 +18,11 @@ object ServerBeeNetworkManager { private val networks = mutableListOf() private var isScanning = false + // O(1) lookup indexes — rebuilt after each structural mutation + private val networkById = mutableMapOf() + private val componentToNetwork = mutableMapOf() + private val hiveById = mutableMapOf() + /** Topology used for creating new networks on the server. */ private var topology: NetworkTopology = DefaultAnchorTopology @@ -27,9 +32,31 @@ object ServerBeeNetworkManager { fun getNetworks(): List = networks + /** + * Rebuilds all O(1) lookup indexes from the authoritative [networks] list. + * Called after structural mutations (register, unregister, merge, split, purge). + */ + fun rebuildIndexes() { + networkById.clear() + componentToNetwork.clear() + hiveById.clear() + for (network in networks) { + networkById[network.id] = network + for (component in network.components) { + componentToNetwork[component] = network + if (component is BeeHive) { + hiveById[component.id] = component + } + } + } + } + fun clear() { val size = networks.size networks.clear() + networkById.clear() + componentToNetwork.clear() + hiveById.clear() CreateBuzzyBeez.LOGGER.info("Cleared $size networks") } @@ -52,7 +79,7 @@ object ServerBeeNetworkManager { // always pass the spatial range check — they get picked up by // scanAndJoinNearbyComponents when a beehive registers. if (component.isAnchor()) { - val idNetwork = networks.find { it.id == component.networkId } + val idNetwork = networkById[component.networkId] if (idNetwork != null && !nearbyNetworks.contains(idNetwork)) { if (idNetwork.level == null || idNetwork.level == component.world) { nearbyNetworks.add(idNetwork) @@ -107,6 +134,8 @@ object ServerBeeNetworkManager { isScanning = false } } + + rebuildIndexes() } private fun scanAndJoinNearbyComponents(network: BeeNetwork, level: Level, pos: BlockPos, range: Double) { @@ -150,6 +179,7 @@ object ServerBeeNetworkManager { // If network is now empty, remove it if (network.components.isEmpty()) { networks.remove(network) + rebuildIndexes() return } @@ -172,6 +202,8 @@ object ServerBeeNetworkManager { } } } + + rebuildIndexes() } // Simplified delegating methods @@ -195,19 +227,23 @@ object ServerBeeNetworkManager { } fun getNetworkFor(component: INetworkComponent): BeeNetwork? { - return networks.find { it.components.contains(component) } + // Fast path: use index (up to date after rebuildIndexes) + // Fallback: linear search for correctness during mid-registration when indexes are stale + return componentToNetwork[component] + ?: networks.find { it.components.contains(component) } } fun getNetwork(id: UUID): BeeNetwork? { - return networks.find { it.id == id } + return networkById[id] } fun findHive(id: UUID): BeeHive? { - return networks.flatMap { it.hives }.find { it.id == id } + return hiveById[id] } fun getNetwork(id: UUID, level: Level): BeeNetwork? { - return networks.find { it.id == id && (it.level == null || it.level == level) } + val net = networkById[id] ?: return null + return if (net.level == null || net.level == level) net else null } fun findProviderFor(level: Level, stack: ItemStack, startPos: BlockPos): LogisticsPort? { @@ -216,7 +252,7 @@ object ServerBeeNetworkManager { } fun findPortableHive(playerId: UUID): PortableBeeHive? { - return networks.flatMap { it.hives }.filterIsInstance().find { it.player.uuid == playerId } + return hiveById.values.filterIsInstance().find { it.player.uuid == playerId } } /** @@ -250,6 +286,7 @@ object ServerBeeNetworkManager { } } blockNetwork.addComponent(hive) + rebuildIndexes() CreateBuzzyBeez.LOGGER.debug("Reconnected portable hive for ${hive.player.name.string} to block network ${blockNetwork.id}") } } else { @@ -262,11 +299,11 @@ object ServerBeeNetworkManager { } // Use a stable network ID derived from the player's UUID hive.networkId = stableNetworkId(hive.player.uuid) - registerComponent(hive) + registerComponent(hive) // rebuildIndexes() called inside CreateBuzzyBeez.LOGGER.debug("Detached portable hive for ${hive.player.name.string} into isolated network") } else if (currentNetwork == null) { hive.networkId = stableNetworkId(hive.player.uuid) - registerComponent(hive) + registerComponent(hive) // rebuildIndexes() called inside } // Otherwise already in isolated network — no-op } diff --git a/src/main/kotlin/de/devin/cbbees/content/domain/network/client/NetworkHighlightHandler.kt b/src/main/kotlin/de/devin/cbbees/content/domain/network/client/NetworkHighlightHandler.kt index 68cfddd..2ca79aa 100644 --- a/src/main/kotlin/de/devin/cbbees/content/domain/network/client/NetworkHighlightHandler.kt +++ b/src/main/kotlin/de/devin/cbbees/content/domain/network/client/NetworkHighlightHandler.kt @@ -1,5 +1,6 @@ package de.devin.cbbees.content.domain.network.client +import com.simibubi.create.AllItems import com.simibubi.create.foundation.utility.RaycastHelper import de.devin.cbbees.content.domain.network.ClientBeeNetworkManager import de.devin.cbbees.content.domain.network.INetworkComponent @@ -27,6 +28,10 @@ object NetworkHighlightHandler { val level = mc.level ?: return if (mc.screen != null) return + // Only show network indicators while holding a wrench + val holdingWrench = AllItems.WRENCH.isIn(player.mainHandItem) || AllItems.WRENCH.isIn(player.offhandItem) + if (!holdingWrench) return + // Raycast to see if we are looking at a Network Component val trace = RaycastHelper.rayTraceRange(level, player, 20.0) if (trace != null && trace.type == HitResult.Type.BLOCK) { diff --git a/src/main/kotlin/de/devin/cbbees/content/domain/task/BeeTask.kt b/src/main/kotlin/de/devin/cbbees/content/domain/task/BeeTask.kt index ea16b37..550013d 100644 --- a/src/main/kotlin/de/devin/cbbees/content/domain/task/BeeTask.kt +++ b/src/main/kotlin/de/devin/cbbees/content/domain/task/BeeTask.kt @@ -1,8 +1,8 @@ package de.devin.cbbees.content.domain.task -import de.devin.cbbees.content.bee.MechanicalBeeEntity import de.devin.cbbees.content.domain.action.BeeAction import de.devin.cbbees.content.domain.action.impl.DropOffItemsAction +import de.devin.cbbees.content.domain.action.impl.PickupItemsAction import de.devin.cbbees.content.domain.action.impl.PlaceBeltAction import de.devin.cbbees.content.domain.action.impl.PlaceBlockAction import de.devin.cbbees.content.domain.action.impl.RemoveBlockAction @@ -27,27 +27,17 @@ data class BeeTask( val priority: Int = 0, ) { var status: TaskStatus = TaskStatus.PENDING - var mechanicalBee: MechanicalBeeEntity? = null + var assignedBeeId: UUID? = null var assignedNetworkId: UUID? = null var requirement: (task: BeeTask) -> Boolean = { true } - /** - * The world position where the task should be performed. - */ val targetPos: BlockPos get() = action.pos - - /** - * The unique identifier for the job this task belongs to. - */ val jobId: UUID get() = job.jobId - /** - * Mark this task as in progress by a specific robot - */ - fun assignToRobot(mechanicalBeeEntity: MechanicalBeeEntity) { + fun assignToBee(beeId: UUID) { status = TaskStatus.IN_PROGRESS - mechanicalBee = mechanicalBeeEntity + assignedBeeId = beeId } /** @@ -63,7 +53,7 @@ data class BeeTask( */ fun fail() { status = TaskStatus.FAILED - mechanicalBee = null + assignedBeeId = null } /** @@ -71,7 +61,7 @@ data class BeeTask( */ fun release() { status = TaskStatus.PENDING - mechanicalBee = null + assignedBeeId = null } /** @@ -79,7 +69,7 @@ data class BeeTask( */ fun cancel() { status = TaskStatus.CANCELLED - mechanicalBee = null + assignedBeeId = null job.checkCompletion() } @@ -127,5 +117,9 @@ data class BeeTask( fun dropOff(fallbackPos: BlockPos, priority: Int = 0, job: BeeJob): BeeTask { return BeeTask(DropOffItemsAction(fallbackPos), job, priority) } + + fun pickup(pos: BlockPos, priority: Int = 0, job: BeeJob): BeeTask { + return BeeTask(PickupItemsAction(pos), job, priority) + } } } \ No newline at end of file diff --git a/src/main/kotlin/de/devin/cbbees/content/domain/task/TaskBatch.kt b/src/main/kotlin/de/devin/cbbees/content/domain/task/TaskBatch.kt index 09c272d..2779d15 100644 --- a/src/main/kotlin/de/devin/cbbees/content/domain/task/TaskBatch.kt +++ b/src/main/kotlin/de/devin/cbbees/content/domain/task/TaskBatch.kt @@ -1,6 +1,6 @@ package de.devin.cbbees.content.domain.task -import de.devin.cbbees.content.bee.MechanicalBeeEntity +import de.devin.cbbees.content.bee.server.BeeType import de.devin.cbbees.content.domain.job.BeeJob import net.minecraft.core.BlockPos import java.util.UUID @@ -10,7 +10,9 @@ class TaskBatch( val job: BeeJob, val targetPosition: BlockPos, /** Execution phase — all batches in phase N must complete before phase N+1 is dispatched. */ - val phase: Int = 0 + val phase: Int = 0, + /** Which bee type should handle this batch. Hive uses this to consume the right item. */ + val beeType: BeeType = BeeType.CONSTRUCTION, ) { companion object { const val MAX_RETRIES = 5 @@ -91,10 +93,10 @@ class TaskBatch( } } - fun assignToRobot(bee: MechanicalBeeEntity) { + fun assignToBee(beeId: UUID, gameTime: Long) { status = TaskStatus.IN_PROGRESS - assignedBeeId = bee.uuid - startedAtTick = bee.level().gameTime - tasks.forEach { it.assignToRobot(bee) } + assignedBeeId = beeId + startedAtTick = gameTime + tasks.forEach { it.assignToBee(beeId) } } } diff --git a/src/main/kotlin/de/devin/cbbees/content/domain/task/TransportTask.kt b/src/main/kotlin/de/devin/cbbees/content/domain/task/TransportTask.kt index 0671d5c..09ef11c 100644 --- a/src/main/kotlin/de/devin/cbbees/content/domain/task/TransportTask.kt +++ b/src/main/kotlin/de/devin/cbbees/content/domain/task/TransportTask.kt @@ -9,9 +9,12 @@ import net.minecraft.world.item.ItemStack * @property sourcePos The EXTRACT port position to pick up items from. * @property targetPos The INSERT port position to deliver items to. * @property items The items to transport. + * @property returningOverflow When true, the bee is returning overflow items to the + * original provider after the requester couldn't accept them all. */ data class TransportTask( val sourcePos: BlockPos, val targetPos: BlockPos, - val items: List + val items: List, + val returningOverflow: Boolean = false ) diff --git a/src/main/kotlin/de/devin/cbbees/content/drone/DroneViewManager.kt b/src/main/kotlin/de/devin/cbbees/content/drone/DroneViewManager.kt index 1c78285..c4f1c91 100644 --- a/src/main/kotlin/de/devin/cbbees/content/drone/DroneViewManager.kt +++ b/src/main/kotlin/de/devin/cbbees/content/drone/DroneViewManager.kt @@ -30,25 +30,15 @@ object DroneViewManager { } private fun activateDrone(player: ServerPlayer) { - val backpack = findBackpack(player) - if (backpack == null) { - player.displayClientMessage(Component.translatable("cbbees.drone_view.no_backpack"), true) - return - } + val backpack = findBackpack(player) ?: return val beehiveItem = backpack.item as PortableBeehiveItem // Check upgrade - if (beehiveItem.getUpgradeCount(backpack, UpgradeType.DRONE_VIEW) <= 0) { - player.displayClientMessage(Component.translatable("cbbees.drone_view.no_upgrade"), true) - return - } + if (beehiveItem.getUpgradeCount(backpack, UpgradeType.DRONE_VIEW) <= 0) return // Check bees - if (beehiveItem.getTotalRobotCount(backpack) <= 0) { - player.displayClientMessage(Component.translatable("cbbees.drone_view.no_bees"), true) - return - } + if (beehiveItem.getTotalRobotCount(backpack) <= 0) return // Consume bee beehiveItem.consumeBee(backpack) diff --git a/src/main/kotlin/de/devin/cbbees/content/drone/client/DroneRangeRenderer.kt b/src/main/kotlin/de/devin/cbbees/content/drone/client/DroneRangeRenderer.kt new file mode 100644 index 0000000..db017b8 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/drone/client/DroneRangeRenderer.kt @@ -0,0 +1,88 @@ +package de.devin.cbbees.content.drone.client + +import de.devin.cbbees.content.bee.MechanicalBeeEntity +import de.devin.cbbees.util.ClientSide +import net.createmod.catnip.outliner.Outliner +import net.minecraft.client.Minecraft +import net.minecraft.world.phys.Vec3 +import net.neoforged.bus.api.SubscribeEvent +import net.neoforged.neoforge.client.event.ClientTickEvent + +/** + * Renders the drone's max range as a circle on the ground, centered on the player. + * Uses Create's [Outliner] line segments to form a circle with [SEGMENTS] edges. + */ +@ClientSide +object DroneRangeRenderer { + + private const val SEGMENTS = 64 + private const val SLOT_PREFIX = "drone_range_" + private const val RANGE_COLOR = 0x9933FF // Purple to match drone theme + + private var activeSegments = 0 + + @SubscribeEvent + @JvmStatic + fun onClientTick(event: ClientTickEvent.Post) { + if (!DroneViewClientState.active) { + clearSegments() + return + } + + val mc = Minecraft.getInstance() + val player = mc.player ?: return + val drone = mc.level?.getEntity(DroneViewClientState.droneEntityId) as? MechanicalBeeEntity ?: return + + val maxRange = DroneViewClientState.maxRange.toDouble() + if (maxRange <= 0) { + clearSegments() + return + } + + val centerX = player.x + val centerZ = player.z + // Draw the circle at the drone's Y level (slightly above ground so it's visible from above) + val y = drone.y - MechanicalBeeEntity.DRONE_ALTITUDE + 1.0 + + val outliner = Outliner.getInstance() + val angleStep = 2.0 * Math.PI / SEGMENTS + + for (i in 0 until SEGMENTS) { + val angle1 = i * angleStep + val angle2 = (i + 1) * angleStep + + val start = Vec3( + centerX + maxRange * Math.cos(angle1), + y, + centerZ + maxRange * Math.sin(angle1) + ) + val end = Vec3( + centerX + maxRange * Math.cos(angle2), + y, + centerZ + maxRange * Math.sin(angle2) + ) + + outliner.showLine("$SLOT_PREFIX$i", start, end) + .colored(RANGE_COLOR) + .lineWidth(1 / 8f) + } + + // Clear any excess segments from a previous render with more segments + if (activeSegments > SEGMENTS) { + for (i in SEGMENTS until activeSegments) { + outliner.remove("$SLOT_PREFIX$i") + } + } + activeSegments = SEGMENTS + } + + private fun clearSegments() { + if (activeSegments > 0) { + val outliner = Outliner.getInstance() + for (i in 0 until activeSegments) { + outliner.remove("$SLOT_PREFIX$i") + } + activeSegments = 0 + } + } +} diff --git a/src/main/kotlin/de/devin/cbbees/content/drone/client/DroneViewClientEvents.kt b/src/main/kotlin/de/devin/cbbees/content/drone/client/DroneViewClientEvents.kt index c2dc140..e74a8e0 100644 --- a/src/main/kotlin/de/devin/cbbees/content/drone/client/DroneViewClientEvents.kt +++ b/src/main/kotlin/de/devin/cbbees/content/drone/client/DroneViewClientEvents.kt @@ -27,6 +27,12 @@ object DroneViewClientEvents { private var lastDroneBlockPos: BlockPos? = null + /** Tracks which WASD keys were down last tick, for nudge mode edge detection. */ + private var prevUp = false + private var prevDown = false + private var prevLeft = false + private var prevRight = false + @SubscribeEvent @JvmStatic fun onClientTick(event: ClientTickEvent.Post) { @@ -74,15 +80,42 @@ object DroneViewClientEvents { val mc = Minecraft.getInstance() val opts = mc.options + val up = opts.keyUp.isDown + val down = opts.keyDown.isDown + val left = opts.keyLeft.isDown + val right = opts.keyRight.isDown + + val shiftHeld = GLFW.glfwGetKey(mc.window.window, GLFW.GLFW_KEY_LEFT_SHIFT) == GLFW.GLFW_PRESS + || GLFW.glfwGetKey(mc.window.window, GLFW.GLFW_KEY_RIGHT_SHIFT) == GLFW.GLFW_PRESS + + if (shiftHeld) { + // Nudge mode: move exactly 1 block per key press + var dx = 0f + var dz = 0f + if (up && !prevUp) dz -= 1f + if (down && !prevDown) dz += 1f + if (left && !prevLeft) dx -= 1f + if (right && !prevRight) dx += 1f + + prevUp = up; prevDown = down; prevLeft = left; prevRight = right + + if (dx != 0f || dz != 0f) { + PacketDistributor.sendToServer(MoveDronePacket(dx, dz)) + } + return + } + + prevUp = up; prevDown = down; prevLeft = left; prevRight = right + var dx = 0f var dz = 0f // Fixed orientation: yaw=180 means north (-Z) is "up" on screen // W = north (-Z), S = south (+Z), A = west (-X), D = east (+X) - if (opts.keyUp.isDown) dz -= 1f - if (opts.keyDown.isDown) dz += 1f - if (opts.keyLeft.isDown) dx -= 1f - if (opts.keyRight.isDown) dx += 1f + if (up) dz -= 1f + if (down) dz += 1f + if (left) dx -= 1f + if (right) dx += 1f if (dx == 0f && dz == 0f) return @@ -141,7 +174,10 @@ object DroneViewClientEvents { handler.onMouseInput(GLFW.GLFW_MOUSE_BUTTON_RIGHT, true) } else if (!ConstructionPlannerItem.hasSchematic(planner)) { // Browsing state: enter group or select schematic - if (player.isShiftKeyDown) { + // Use raw GLFW check because onMovementInput clears shiftKeyDown during drone view + val shiftHeld = GLFW.glfwGetKey(mc.window.window, GLFW.GLFW_KEY_LEFT_SHIFT) == GLFW.GLFW_PRESS + || GLFW.glfwGetKey(mc.window.window, GLFW.GLFW_KEY_RIGHT_SHIFT) == GLFW.GLFW_PRESS + if (shiftHeld) { ConstructionPlannerHandler.instantConstruct() } else { ConstructionPlannerHandler.confirmSelection() @@ -200,5 +236,6 @@ object DroneViewClientEvents { fun onLogout(event: ClientPlayerNetworkEvent.LoggingOut) { DroneViewClientState.reset() lastDroneBlockPos = null + prevUp = false; prevDown = false; prevLeft = false; prevRight = false } } diff --git a/src/main/kotlin/de/devin/cbbees/content/logistics/transport/client/CargoPortLinkRenderer.kt b/src/main/kotlin/de/devin/cbbees/content/logistics/transport/client/CargoPortLinkRenderer.kt index 7a13cab..1dc88ac 100644 --- a/src/main/kotlin/de/devin/cbbees/content/logistics/transport/client/CargoPortLinkRenderer.kt +++ b/src/main/kotlin/de/devin/cbbees/content/logistics/transport/client/CargoPortLinkRenderer.kt @@ -1,5 +1,6 @@ package de.devin.cbbees.content.logistics.transport.client +import com.simibubi.create.AllItems import com.simibubi.create.content.equipment.goggles.GogglesItem import de.devin.cbbees.content.logistics.transport.TransportPortBlockEntity import de.devin.cbbees.util.ClientSide @@ -28,6 +29,10 @@ object CargoPortLinkRenderer { if (!GogglesItem.isWearingGoggles(player)) return + // Only show frequency links while holding a wrench + val holdingWrench = AllItems.WRENCH.isIn(player.mainHandItem) || AllItems.WRENCH.isIn(player.offhandItem) + if (!holdingWrench) return + val target = mc.hitResult if (target !is BlockHitResult) return diff --git a/src/main/kotlin/de/devin/cbbees/content/schematics/ConstructionPlannerItem.kt b/src/main/kotlin/de/devin/cbbees/content/schematics/ConstructionPlannerItem.kt index 0c4689d..91eda35 100644 --- a/src/main/kotlin/de/devin/cbbees/content/schematics/ConstructionPlannerItem.kt +++ b/src/main/kotlin/de/devin/cbbees/content/schematics/ConstructionPlannerItem.kt @@ -2,6 +2,7 @@ package de.devin.cbbees.content.schematics import com.simibubi.create.AllDataComponents import de.devin.cbbees.content.schematics.client.ConstructionPlannerHandler +import de.devin.cbbees.items.AllItems import net.minecraft.ChatFormatting import net.minecraft.network.chat.Component import net.minecraft.world.InteractionHand @@ -76,6 +77,22 @@ class ConstructionPlannerItem(properties: Properties) : Item(properties) { return stack.has(AllDataComponents.SCHEMATIC_FILE) } + /** + * Finds a Construction Planner in the player's inventory. + * Checks main hand first, then searches the full inventory. + * Returns [ItemStack.EMPTY] if not found. + */ + fun findPlanner(player: Player): ItemStack { + val mainHand = player.mainHandItem + if (AllItems.CONSTRUCTION_PLANNER.isIn(mainHand)) return mainHand + val inv = player.inventory + for (i in 0 until inv.containerSize) { + val stack = inv.getItem(i) + if (AllItems.CONSTRUCTION_PLANNER.isIn(stack)) return stack + } + return ItemStack.EMPTY + } + fun clearSchematic(stack: ItemStack) { stack.remove(AllDataComponents.SCHEMATIC_FILE) stack.remove(AllDataComponents.SCHEMATIC_OWNER) diff --git a/src/main/kotlin/de/devin/cbbees/content/schematics/DeconstructionPlannerItem.kt b/src/main/kotlin/de/devin/cbbees/content/schematics/DeconstructionPlannerItem.kt index 70c904c..984dd60 100644 --- a/src/main/kotlin/de/devin/cbbees/content/schematics/DeconstructionPlannerItem.kt +++ b/src/main/kotlin/de/devin/cbbees/content/schematics/DeconstructionPlannerItem.kt @@ -3,7 +3,7 @@ package de.devin.cbbees.content.schematics import net.minecraft.world.item.Item /** - * Deconstruction Planner - Tool for selecting areas to be dismantled by constructor robots. + * Deconstruction Planner - Tool for selecting areas to be dismantled by construction bees. * * This item works similarly to Create's Schematic and Quill: * 1. Hold the item in your main hand diff --git a/src/main/kotlin/de/devin/cbbees/content/schematics/PickupPlannerItem.kt b/src/main/kotlin/de/devin/cbbees/content/schematics/PickupPlannerItem.kt new file mode 100644 index 0000000..a80d3bf --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/schematics/PickupPlannerItem.kt @@ -0,0 +1,16 @@ +package de.devin.cbbees.content.schematics + +import net.minecraft.world.item.Item + +/** + * Pickup Planner — tool for selecting areas to scan for loose items. + * + * Uses the same two-corner selection flow as the Deconstruction Planner: + * 1. Hold the item in your main hand + * 2. Right-click to set the first corner + * 3. Right-click again to set the second corner + * 4. Press the action key to dispatch bumble bees for item collection + * + * Client-side logic handled by [de.devin.cbbees.content.schematics.client.PickupHandler]. + */ +class PickupPlannerItem(properties: Properties) : Item(properties) diff --git a/src/main/kotlin/de/devin/cbbees/content/schematics/SchematicCreateBridge.kt b/src/main/kotlin/de/devin/cbbees/content/schematics/SchematicCreateBridge.kt index 27394b6..5145ab9 100644 --- a/src/main/kotlin/de/devin/cbbees/content/schematics/SchematicCreateBridge.kt +++ b/src/main/kotlin/de/devin/cbbees/content/schematics/SchematicCreateBridge.kt @@ -11,10 +11,14 @@ import com.simibubi.create.content.kinetics.belt.BeltBlockEntity import com.simibubi.create.content.kinetics.belt.BeltPart import de.devin.cbbees.CreateBuzzyBeez import de.devin.cbbees.config.CBBeesConfig +import de.devin.cbbees.content.bee.server.BeeType import de.devin.cbbees.content.domain.job.BeeJob +import de.devin.cbbees.content.domain.job.JobCalculationProgress import de.devin.cbbees.content.domain.task.BeeTask import de.devin.cbbees.content.domain.task.TaskBatch +import de.devin.cbbees.util.ServerTickScheduler import net.minecraft.core.BlockPos +import net.minecraft.server.MinecraftServer import net.minecraft.core.Direction import net.minecraft.world.item.ItemStack import net.minecraft.world.level.Level @@ -105,7 +109,10 @@ class SchematicCreateBridge( * @param job The job to assign the tasks to * @return List of TaskBatches for building the schematic */ - fun generateBuildTasks(job: BeeJob): List { + /** + * Generates build tasks synchronously (blocking). Used for small schematics. + */ + fun generateBuildTasks(job: BeeJob, onProgress: ((processed: Int, total: Int) -> Unit)? = null): List { if (!isLoaded) { CreateBuzzyBeez.LOGGER.warn("No schematic loaded") return emptyList() @@ -113,31 +120,92 @@ class SchematicCreateBridge( handledPositions.clear() val batches = mutableListOf() + var processed = 0 - // Iterate through all blocks in the schematic using Create's three-pass printer - // (BLOCKS -> DEFERRED_BLOCKS -> ENTITIES) while (printer.isLoaded && !printer.isErrored && printer.advanceCurrentPos()) { - if (!printer.shouldPlaceCurrent(level)) continue + processed++ + processCurrentBlock(job, batches) + } - val requirement = printer.currentRequirement - val items = getItemsFromRequirement(requirement) + return batches + } - printer.handleCurrentTarget({ pos, state, blockEntity -> - if (state == null || state.isAir) return@handleCurrentTarget - if (handledPositions.contains(pos)) return@handleCurrentTarget - if (BlockPlacementClassifier.shouldSkipBlock(state)) return@handleCurrentTarget + /** + * Generates build tasks spread across multiple server ticks. + * + * Processes [blocksPerTick] blocks per tick, advances [progress] between chunks, + * and invokes [onComplete] with the final batch list when done. This prevents + * the server from freezing on large schematics. + * + * @param job the parent job + * @param server the Minecraft server (for scheduling) + * @param blocksPerTick how many blocks to process per tick (default 500) + * @param progress optional tracker advanced once per chunk; the caller is + * responsible for calling [JobCalculationProgress.Tracker.start] before + * and [JobCalculationProgress.Tracker.complete]/[JobCalculationProgress.Tracker.fail] + * after based on [onComplete] result + * @param onComplete called when all blocks are processed + */ + fun generateBuildTasksAsync( + job: BeeJob, + server: MinecraftServer, + blocksPerTick: Int = 500, + progress: JobCalculationProgress.Tracker? = null, + onComplete: (List) -> Unit, + ) { + if (!isLoaded) { + CreateBuzzyBeez.LOGGER.warn("No schematic loaded") + onComplete(emptyList()) + return + } - if (AllBlocks.BELT.has(state)) { - handleBeltPlacement(pos, state, blockEntity as? BeltBlockEntity, items, job, batches) - } else { - handleRegularBlock(pos, state, blockEntity, items, job, batches) + handledPositions.clear() + val batches = mutableListOf() + + fun processChunk() { + var blocksThisChunk = 0 + var hasMore = true + while (blocksThisChunk < blocksPerTick) { + if (!printer.isLoaded || printer.isErrored || !printer.advanceCurrentPos()) { + hasMore = false + break } - }, { _, _ -> - // TODO Add entity handling (armor stands, item frames, etc.) - }) + blocksThisChunk++ + processCurrentBlock(job, batches) + } + + progress?.advance(blocksThisChunk) + + if (hasMore) { + ServerTickScheduler.nextTick { processChunk() } + } else { + onComplete(batches) + } } - return batches + ServerTickScheduler.nextTick { processChunk() } + } + + /** Processes the printer's current block position into a task batch. */ + private fun processCurrentBlock(job: BeeJob, batches: MutableList) { + if (!printer.shouldPlaceCurrent(level)) return + + val requirement = printer.currentRequirement + val items = getItemsFromRequirement(requirement) + + printer.handleCurrentTarget({ pos, state, blockEntity -> + if (state == null || state.isAir) return@handleCurrentTarget + if (handledPositions.contains(pos)) return@handleCurrentTarget + if (BlockPlacementClassifier.shouldSkipBlock(state)) return@handleCurrentTarget + + if (AllBlocks.BELT.has(state)) { + handleBeltPlacement(pos, state, blockEntity as? BeltBlockEntity, items, job, batches) + } else { + handleRegularBlock(pos, state, blockEntity, items, job, batches) + } + }, { _, _ -> + // TODO Add entity handling (armor stands, item frames, etc.) + }) } /** @@ -155,43 +223,119 @@ class SchematicCreateBridge( * @param job The job to assign the tasks to * @return List of RobotTasks for removing blocks in the area */ - fun generateRemovalTasks(corner1: BlockPos, corner2: BlockPos, job: BeeJob): List { + fun generateRemovalTasks( + corner1: BlockPos, + corner2: BlockPos, + job: BeeJob, + onProgress: ((processed: Int, total: Int) -> Unit)? = null, + ): List { val batches = mutableListOf() + val positions = buildRemovalPositions(corner1, corner2) - val minX = minOf(corner1.x, corner2.x) - val minY = minOf(corner1.y, corner2.y) - val minZ = minOf(corner1.z, corner2.z) - val maxX = maxOf(corner1.x, corner2.x) - val maxY = maxOf(corner1.y, corner2.y) - val maxZ = maxOf(corner1.z, corner2.z) - - // Iterate from top to bottom for removal - for (y in maxY downTo minY) { - for (x in minX..maxX) { - for (z in minZ..maxZ) { - val pos = BlockPos(x, y, z) - val state = level.getBlockState(pos) - - // Skip air and unbreakable blocks - if (!state.isAir && state.getDestroySpeed(level, pos) >= 0) { - val priority = BlockPlacementClassifier.calculateRemovalPriority(pos, state, maxY) - val removeTask = BeeTask.remove(pos = pos, priority = priority, job = job) - val tasks = if (CBBeesConfig.beePickupItems.get()) { - val dropOffTask = BeeTask.dropOff(fallbackPos = pos, priority = priority, job = job) - listOf(removeTask, dropOffTask) - } else { - listOf(removeTask) - } - val phase = if (BlockPlacementClassifier.shouldDeferBlock(state)) 0 else 1 - batches.add(TaskBatch(tasks, job, pos, phase)) - } - } - } + positions.forEachIndexed { index, pos -> + onProgress?.let { if (index % 2_000 == 0) it(index, positions.size) } + processRemovalBlock(pos, corner1, corner2, job, batches) } return batches } + /** + * Generates removal tasks spread across multiple server ticks. + * + * @param progress optional tracker advanced once per chunk; the caller starts + * and completes/fails it. + */ + fun generateRemovalTasksAsync( + corner1: BlockPos, + corner2: BlockPos, + job: BeeJob, + server: MinecraftServer, + blocksPerTick: Int = 1000, + progress: JobCalculationProgress.Tracker? = null, + onComplete: (List) -> Unit, + ) { + val batches = mutableListOf() + val positions = buildRemovalPositions(corner1, corner2) + val total = positions.size + var index = 0 + + fun processChunk() { + val chunkStart = index + val end = (index + blocksPerTick).coerceAtMost(total) + while (index < end) { + processRemovalBlock(positions[index], corner1, corner2, job, batches) + index++ + } + progress?.advance(index - chunkStart) + + if (index < total) { + ServerTickScheduler.nextTick { processChunk() } + } else { + onComplete(batches) + } + } + + ServerTickScheduler.nextTick { processChunk() } + } + + // ── Pickup (item collection) ── + + /** + * Scans the area between [corner1] and [corner2] for loose [ItemEntity] objects + * and creates pickup + drop-off task batches grouped by position. + */ + data class PickupResult(val batches: List, val totalItems: Int) + + fun generatePickupBatches(corner1: BlockPos, corner2: BlockPos, job: BeeJob): PickupResult { + val minPos = BlockPos(minOf(corner1.x, corner2.x), minOf(corner1.y, corner2.y), minOf(corner1.z, corner2.z)) + val maxPos = BlockPos(maxOf(corner1.x, corner2.x), maxOf(corner1.y, corner2.y), maxOf(corner1.z, corner2.z)) + val bounds = net.minecraft.world.phys.AABB( + minPos.x.toDouble(), minPos.y.toDouble(), minPos.z.toDouble(), + maxPos.x + 1.0, maxPos.y + 1.0, maxPos.z + 1.0, + ) + val items = level.getEntitiesOfClass(net.minecraft.world.entity.item.ItemEntity::class.java, bounds) { it.isAlive } + if (items.isEmpty()) return PickupResult(emptyList(), 0) + + val totalItems = items.sumOf { it.item.count } + val piles = items.groupBy { BlockPos.containing(it.position()) } + val batches = piles.map { (pos, _) -> + val pickupTask = BeeTask.pickup(pos = pos, job = job) + val dropOffTask = BeeTask.dropOff(fallbackPos = pos, job = job) + TaskBatch(listOf(pickupTask, dropOffTask), job, pos, beeType = BeeType.TRANSPORT) + } + return PickupResult(batches, totalItems) + } + + private fun buildRemovalPositions(corner1: BlockPos, corner2: BlockPos): List { + val minX = minOf(corner1.x, corner2.x); val maxX = maxOf(corner1.x, corner2.x) + val minY = minOf(corner1.y, corner2.y); val maxY = maxOf(corner1.y, corner2.y) + val minZ = minOf(corner1.z, corner2.z); val maxZ = maxOf(corner1.z, corner2.z) + + return buildList { + for (y in maxY downTo minY) + for (x in minX..maxX) + for (z in minZ..maxZ) + add(BlockPos(x, y, z)) + } + } + + private fun processRemovalBlock(pos: BlockPos, corner1: BlockPos, corner2: BlockPos, job: BeeJob, batches: MutableList) { + val maxY = maxOf(corner1.y, corner2.y) + val state = level.getBlockState(pos) + if (!state.isAir && state.getDestroySpeed(level, pos) >= 0) { + val priority = BlockPlacementClassifier.calculateRemovalPriority(pos, state, maxY) + val removeTask = BeeTask.remove(pos = pos, priority = priority, job = job) + val tasks = if (CBBeesConfig.beePickupItems.get()) { + listOf(removeTask, BeeTask.dropOff(fallbackPos = pos, priority = priority, job = job)) + } else { + listOf(removeTask) + } + val phase = if (BlockPlacementClassifier.shouldDeferBlock(state)) 0 else 1 + batches.add(TaskBatch(tasks, job, pos, phase)) + } + } + /** * Get the anchor position of the loaded schematic */ diff --git a/src/main/kotlin/de/devin/cbbees/content/schematics/client/ConstructionPlannerHUD.kt b/src/main/kotlin/de/devin/cbbees/content/schematics/client/ConstructionPlannerHUD.kt index 33292dd..5fb130d 100644 --- a/src/main/kotlin/de/devin/cbbees/content/schematics/client/ConstructionPlannerHUD.kt +++ b/src/main/kotlin/de/devin/cbbees/content/schematics/client/ConstructionPlannerHUD.kt @@ -274,12 +274,7 @@ object ConstructionPlannerHUD { RenderSystem.disableBlend() } - private fun isAltDown(): Boolean { - return Minecraft.getInstance().window.let { window -> - GLFW.glfwGetKey(window.window, GLFW.GLFW_KEY_LEFT_ALT) == GLFW.GLFW_PRESS || - GLFW.glfwGetKey(window.window, GLFW.GLFW_KEY_RIGHT_ALT) == GLFW.GLFW_PRESS - } - } + private fun isAltDown(): Boolean = de.devin.cbbees.registry.AllKeys.SCHEMATIC_MODIFIER.isDown private fun entryDisplayComp(entry: ConstructionPlannerHandler.HudEntry): Component { val name = when (entry) { diff --git a/src/main/kotlin/de/devin/cbbees/content/schematics/client/ConstructionPlannerHandler.kt b/src/main/kotlin/de/devin/cbbees/content/schematics/client/ConstructionPlannerHandler.kt index ca7a5cc..a7e773d 100644 --- a/src/main/kotlin/de/devin/cbbees/content/schematics/client/ConstructionPlannerHandler.kt +++ b/src/main/kotlin/de/devin/cbbees/content/schematics/client/ConstructionPlannerHandler.kt @@ -415,11 +415,7 @@ object ConstructionPlannerHandler { InstantConstructionPacket(filename, anchor, rotation, mirror) ) - player.displayClientMessage( - Component.translatable("cbbees.construction.started_instant", filename.removeSuffix(".nbt")) - .withStyle { it.withColor(0x00FF00) }, - true - ) + // Feedback handled server-side via TaskProgressReporter } val owner = player.gameProfile.name @@ -441,11 +437,6 @@ object ConstructionPlannerHandler { /** Returns the currently selected entry, if any. */ fun getSelectedEntry(): HudEntry? = currentItems.getOrNull(selectedIndex) - private fun isAltDown(): Boolean { - return Minecraft.getInstance().window.let { window -> - GLFW.glfwGetKey(window.window, GLFW.GLFW_KEY_LEFT_ALT) == GLFW.GLFW_PRESS || - GLFW.glfwGetKey(window.window, GLFW.GLFW_KEY_RIGHT_ALT) == GLFW.GLFW_PRESS - } - } + private fun isAltDown(): Boolean = de.devin.cbbees.registry.AllKeys.SCHEMATIC_MODIFIER.isDown } diff --git a/src/main/kotlin/de/devin/cbbees/content/schematics/client/ConstructionToolState.kt b/src/main/kotlin/de/devin/cbbees/content/schematics/client/ConstructionToolState.kt index ff9d85e..3c5b143 100644 --- a/src/main/kotlin/de/devin/cbbees/content/schematics/client/ConstructionToolState.kt +++ b/src/main/kotlin/de/devin/cbbees/content/schematics/client/ConstructionToolState.kt @@ -12,7 +12,7 @@ import net.neoforged.api.distmarker.OnlyIn */ @OnlyIn(Dist.CLIENT) object ConstructionToolState { - enum class CustomTool { NONE, CONSTRUCT, UNSELECT } + enum class CustomTool { NONE, CONSTRUCT, UNSELECT, PROGRAM } @JvmStatic var activeTool = CustomTool.NONE diff --git a/src/main/kotlin/de/devin/cbbees/content/schematics/client/DeconstructionClientEvents.kt b/src/main/kotlin/de/devin/cbbees/content/schematics/client/DeconstructionClientEvents.kt index 908400a..70d40ce 100644 --- a/src/main/kotlin/de/devin/cbbees/content/schematics/client/DeconstructionClientEvents.kt +++ b/src/main/kotlin/de/devin/cbbees/content/schematics/client/DeconstructionClientEvents.kt @@ -25,42 +25,38 @@ object DeconstructionClientEvents { @JvmStatic fun onClientTick(event: ClientTickEvent.Post) { DeconstructionHandler.tick() + PickupHandler.tick() DeconstructionRenderer.update() } - /** - * Called when a mouse button is clicked. - * Handles right-click for setting selection corners and opening the prompt. - */ @SubscribeEvent @JvmStatic fun onMouseInput(event: InputEvent.MouseButton.Pre) { if (DeconstructionHandler.onMouseInput(event.button, event.action == org.lwjgl.glfw.GLFW.GLFW_PRESS)) { event.isCanceled = true + return + } + if (PickupHandler.onMouseInput(event.button, event.action == org.lwjgl.glfw.GLFW.GLFW_PRESS)) { + event.isCanceled = true } } - /** - * Called when the mouse scroll wheel is used. - * Handles scroll for resizing the selection area. - */ @SubscribeEvent @JvmStatic fun onMouseScroll(event: InputEvent.MouseScrollingEvent) { if (DeconstructionHandler.mouseScrolled(event.scrollDeltaY)) { event.isCanceled = true + return + } + if (PickupHandler.onScroll(event.scrollDeltaY)) { + event.isCanceled = true } } - /** - * Called when a key is pressed. - * Handles R key for starting deconstruction. - */ @SubscribeEvent @JvmStatic fun onKeyInput(event: InputEvent.Key) { - if (DeconstructionHandler.onKeyInput(event.key, event.action == org.lwjgl.glfw.GLFW.GLFW_PRESS)) { - // No cancel needed for key input usually - } + DeconstructionHandler.onKeyInput(event.key, event.action == org.lwjgl.glfw.GLFW.GLFW_PRESS) + PickupHandler.onKeyInput(event.key, event.action == org.lwjgl.glfw.GLFW.GLFW_PRESS) } } diff --git a/src/main/kotlin/de/devin/cbbees/content/schematics/client/DeconstructionHandler.kt b/src/main/kotlin/de/devin/cbbees/content/schematics/client/DeconstructionHandler.kt index 69913a2..b596ae9 100644 --- a/src/main/kotlin/de/devin/cbbees/content/schematics/client/DeconstructionHandler.kt +++ b/src/main/kotlin/de/devin/cbbees/content/schematics/client/DeconstructionHandler.kt @@ -3,6 +3,8 @@ package de.devin.cbbees.content.schematics.client import com.simibubi.create.foundation.utility.RaycastHelper import de.devin.cbbees.CreateBuzzyBeez import de.devin.cbbees.items.AllItems +import de.devin.cbbees.content.deployer.SchematicProgram +import de.devin.cbbees.network.ProgramSchematicPacket import de.devin.cbbees.network.StartDeconstructionPacket import de.devin.cbbees.network.StopTasksPacket import de.devin.cbbees.registry.AllKeys @@ -248,6 +250,21 @@ object DeconstructionHandler { return true } + if (AllKeys.PROGRAM_ACTION.matches(key, 0) && DeconstructionSelection.isComplete()) { + val first = DeconstructionSelection.firstPos!! + val second = DeconstructionSelection.secondPos!! + + val program = SchematicProgram.Deconstruction(first, second) + PacketDistributor.sendToServer(ProgramSchematicPacket(program)) + + Minecraft.getInstance().player?.displayClientMessage( + Component.translatable("cbbees.schematic.programmed") + .withStyle { it.withColor(0x88CCFF) }, + true + ) + return true + } + return false } diff --git a/src/main/kotlin/de/devin/cbbees/content/schematics/client/DeconstructionRenderer.kt b/src/main/kotlin/de/devin/cbbees/content/schematics/client/DeconstructionRenderer.kt index c1624b9..44a7555 100644 --- a/src/main/kotlin/de/devin/cbbees/content/schematics/client/DeconstructionRenderer.kt +++ b/src/main/kotlin/de/devin/cbbees/content/schematics/client/DeconstructionRenderer.kt @@ -102,7 +102,14 @@ object DeconstructionRenderer { } val hintWidth = mc.font.width(hintText) - val secondHint = Component.translatable("gui.cbbees.deconstruction.hint_scroll") + val secondHint = if (second != null) { + Component.translatable( + "gui.cbbees.deconstruction.hint_program", + AllKeys.PROGRAM_ACTION.translatedKeyMessage + ) + } else { + Component.translatable("gui.cbbees.deconstruction.hint_scroll") + } val secondHintWidth = mc.font.width(secondHint) // Compact hint (shown above panel when Alt not held) @@ -188,10 +195,5 @@ object DeconstructionRenderer { guiGraphics.pose().popPose() } - private fun isAltDown(): Boolean { - return Minecraft.getInstance().window.let { window -> - GLFW.glfwGetKey(window.window, GLFW.GLFW_KEY_LEFT_ALT) == GLFW.GLFW_PRESS || - GLFW.glfwGetKey(window.window, GLFW.GLFW_KEY_RIGHT_ALT) == GLFW.GLFW_PRESS - } - } + private fun isAltDown(): Boolean = de.devin.cbbees.registry.AllKeys.SCHEMATIC_MODIFIER.isDown } diff --git a/src/main/kotlin/de/devin/cbbees/content/schematics/client/PickupHandler.kt b/src/main/kotlin/de/devin/cbbees/content/schematics/client/PickupHandler.kt new file mode 100644 index 0000000..9a07e5d --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/schematics/client/PickupHandler.kt @@ -0,0 +1,198 @@ +package de.devin.cbbees.content.schematics.client + +import com.simibubi.create.foundation.utility.RaycastHelper +import de.devin.cbbees.content.deployer.SchematicProgram +import de.devin.cbbees.items.AllItems +import de.devin.cbbees.network.ProgramSchematicPacket +import de.devin.cbbees.network.StartPickupPacket +import de.devin.cbbees.registry.AllKeys +import net.createmod.catnip.math.VecHelper +import net.minecraft.client.Minecraft +import net.minecraft.core.Direction +import net.minecraft.network.chat.Component +import net.minecraft.world.phys.AABB +import net.minecraft.world.phys.Vec3 +import net.neoforged.neoforge.network.PacketDistributor +import org.lwjgl.glfw.GLFW + +/** + * Client-side handler for the Pickup Planner tool. + * + * Reuses [DeconstructionSelection] for the two-corner selection state and + * [DeconstructionRenderer] for the outline rendering (with a green tint). + * Sends [StartPickupPacket] on confirm or [ProgramSchematicPacket] with + * [SchematicProgram.Pickup] for deployer programming. + */ +object PickupHandler { + + private var selectedFace: Direction? = null + + fun onScroll(delta: Double): Boolean { + if (!isActive()) return false + if (!isCtrlDown()) return false + if (!DeconstructionSelection.isComplete()) return false + + val first = DeconstructionSelection.firstPos ?: return false + val second = DeconstructionSelection.secondPos ?: return false + val face = selectedFace ?: return false + + val axisDirection = face.axisDirection + val x = face.stepX + val y = face.stepY + val z = face.stepZ + val step = if (delta > 0) 1 else -1 + + var bb = AABB(Vec3.atLowerCornerOf(first), Vec3.atLowerCornerOf(second)) + .expandTowards(1.0, 1.0, 1.0) + + val maxX = maxOf(bb.maxX + x * step * axisDirection.step, bb.minX) + val maxY = maxOf(bb.maxY + y * step * axisDirection.step, bb.minY) + val maxZ = maxOf(bb.maxZ + z * step * axisDirection.step, bb.minZ) + + bb = AABB(bb.minX, bb.minY, bb.minZ, maxX, maxY, maxZ) + + DeconstructionSelection.firstPos = net.minecraft.core.BlockPos.containing(bb.minX, bb.minY, bb.minZ) + DeconstructionSelection.secondPos = net.minecraft.core.BlockPos.containing(bb.maxX, bb.maxY, bb.maxZ) + + val player = Minecraft.getInstance().player ?: return true + val sizeX = (bb.xsize + 1).toInt() + val sizeY = (bb.ysize + 1).toInt() + val sizeZ = (bb.zsize + 1).toInt() + player.displayClientMessage( + Component.translatable("cbbees.deconstruction.dimensions", sizeX, sizeY, sizeZ), + true + ) + return true + } + + fun onMouseInput(button: Int, pressed: Boolean): Boolean { + if (!pressed || button != GLFW.GLFW_MOUSE_BUTTON_RIGHT) return false + if (!isActive()) return false + + val player = Minecraft.getInstance().player ?: return false + + if (player.isShiftKeyDown) { + discard() + return true + } + + if (DeconstructionSelection.secondPos != null) return true + + if (DeconstructionSelection.selectedPos == null) { + player.displayClientMessage(Component.translatable("cbbees.deconstruction.no_target"), true) + return true + } + + if (DeconstructionSelection.firstPos != null) { + DeconstructionSelection.secondPos = DeconstructionSelection.selectedPos + player.displayClientMessage( + Component.translatable("cbbees.deconstruction.second_pos", AllKeys.START_ACTION.translatedKeyMessage), + true + ) + return true + } + + DeconstructionSelection.firstPos = DeconstructionSelection.selectedPos + player.displayClientMessage(Component.translatable("cbbees.deconstruction.first_pos"), true) + return true + } + + fun discard() { + DeconstructionSelection.discard() + Minecraft.getInstance().player?.displayClientMessage( + Component.translatable("cbbees.deconstruction.abort"), true + ) + } + + fun tick() { + if (!isActive()) return + + val player = Minecraft.getInstance().player ?: return + + // Raycast for block selection + if (DeconstructionSelection.secondPos == null) { + val result = RaycastHelper.rayTraceRange(player.level(), player, 75.0) + if (result.type == net.minecraft.world.phys.HitResult.Type.BLOCK) { + DeconstructionSelection.selectedPos = net.minecraft.core.BlockPos.containing(result.location) + } else { + DeconstructionSelection.selectedPos = null + } + } + + // Update selected face for resizing + selectedFace = null + val first = DeconstructionSelection.firstPos + val second = DeconstructionSelection.secondPos + if (first != null && second != null) { + var bb = AABB(Vec3.atLowerCornerOf(first), Vec3.atLowerCornerOf(second)) + .expandTowards(1.0, 1.0, 1.0) + .inflate(0.45) + val projectedView = Minecraft.getInstance().gameRenderer.mainCamera.position + val inside = bb.contains(projectedView) + val result = RaycastHelper.rayTraceUntil(player, 70.0) { pos -> + inside xor bb.contains(VecHelper.getCenterOf(pos)) + } + selectedFace = when { + result.missed() -> null + inside -> result.facing.opposite + else -> result.facing + } + } + + // Render with green outline + DeconstructionRenderer.renderWorldOutline(selectedFace) + } + + fun onKeyInput(key: Int, pressed: Boolean): Boolean { + if (!pressed || !isActive()) return false + + if (AllKeys.START_ACTION.matches(key, 0) && DeconstructionSelection.isComplete()) { + val first = DeconstructionSelection.firstPos!! + val second = DeconstructionSelection.secondPos!! + + PacketDistributor.sendToServer(StartPickupPacket(first, second)) + + Minecraft.getInstance().player?.displayClientMessage( + Component.translatable("message.cbbees.planner.started") + .withStyle { it.withColor(0x00FF00) }, + true + ) + discard() + return true + } + + if (AllKeys.STOP_ACTION.matches(key, 0)) { + PacketDistributor.sendToServer(de.devin.cbbees.network.StopTasksPacket.INSTANCE) + return true + } + + if (AllKeys.PROGRAM_ACTION.matches(key, 0) && DeconstructionSelection.isComplete()) { + val first = DeconstructionSelection.firstPos!! + val second = DeconstructionSelection.secondPos!! + + val program = SchematicProgram.Pickup(first, second) + PacketDistributor.sendToServer(ProgramSchematicPacket(program)) + + Minecraft.getInstance().player?.displayClientMessage( + Component.translatable("cbbees.schematic.programmed") + .withStyle { it.withColor(0x88CCFF) }, + true + ) + return true + } + + return false + } + + fun isActive(): Boolean { + val player = Minecraft.getInstance().player ?: return false + return AllItems.PICKUP_PLANNER.isIn(player.mainHandItem) + } + + private fun isCtrlDown(): Boolean { + return Minecraft.getInstance().window?.let { window -> + GLFW.glfwGetKey(window.window, GLFW.GLFW_KEY_LEFT_CONTROL) == GLFW.GLFW_PRESS || + GLFW.glfwGetKey(window.window, GLFW.GLFW_KEY_RIGHT_CONTROL) == GLFW.GLFW_PRESS + } ?: false + } +} diff --git a/src/main/kotlin/de/devin/cbbees/content/schematics/client/SchematicHoverPreview.kt b/src/main/kotlin/de/devin/cbbees/content/schematics/client/SchematicHoverPreview.kt index 2f061eb..db93c80 100644 --- a/src/main/kotlin/de/devin/cbbees/content/schematics/client/SchematicHoverPreview.kt +++ b/src/main/kotlin/de/devin/cbbees/content/schematics/client/SchematicHoverPreview.kt @@ -315,10 +315,15 @@ object SchematicHoverPreview { blockEntity.setLevel(schematicLevel) } + // Build geometry on the render thread to avoid native memory corruption + // in ByteBufferBuilder/jemalloc from concurrent vertex buffer access val renderer = GhostSchematicRenderer(schematicLevel) - renderer.prebuildGeometry() - Minecraft.getInstance().execute { + try { + renderer.prebuildGeometry() + } catch (e: Exception) { + de.devin.cbbees.CreateBuzzyBeez.LOGGER.warn("Schematic preview build failed: ${e.message}") + } if (gen == buildGeneration) { renderers[index] = renderer } diff --git a/src/main/kotlin/de/devin/cbbees/content/upgrades/BeeContext.kt b/src/main/kotlin/de/devin/cbbees/content/upgrades/BeeContext.kt index fac9d0f..f1321a8 100644 --- a/src/main/kotlin/de/devin/cbbees/content/upgrades/BeeContext.kt +++ b/src/main/kotlin/de/devin/cbbees/content/upgrades/BeeContext.kt @@ -3,19 +3,19 @@ package de.devin.cbbees.content.upgrades import de.devin.cbbees.config.CBBeesConfig /** - * Data class representing the calculated stats for constructor robots + * Data class representing the calculated stats for construction bees * based on the installed upgrades in a backpack. */ data class BeeContext( var speedMultiplier: Double = 1.0, var carryCapacity: Int = 1, var workRange: Double = CBBeesConfig.defaultWorkRange.get(), - var maxActiveRobots: Int = CBBeesConfig.defaultMaxActiveRobots.get(), + var maxActiveBees: Int = CBBeesConfig.defaultMaxActiveBees.get(), var silkTouchEnabled: Boolean = false, var dropItemsEnabled: Boolean = false, var breakSpeedMultiplier: Double = 1.0, /** Maximum number of bees this source can contribute to a single job */ - var maxContributedBees: Int = CBBeesConfig.defaultMaxActiveRobots.get(), + var maxContributedBees: Int = CBBeesConfig.defaultMaxActiveBees.get(), var fuelConsumptionMultiplier: Double = 1.0, /** Higher RPM → tighter wound spring → less drain per action */ var springEfficiency: Double = 1.0, diff --git a/src/main/kotlin/de/devin/cbbees/content/upgrades/BeeUpgradeItem.kt b/src/main/kotlin/de/devin/cbbees/content/upgrades/BeeUpgradeItem.kt index 5de9c29..19bd159 100644 --- a/src/main/kotlin/de/devin/cbbees/content/upgrades/BeeUpgradeItem.kt +++ b/src/main/kotlin/de/devin/cbbees/content/upgrades/BeeUpgradeItem.kt @@ -17,7 +17,7 @@ enum class UpgradeType( ctx.speedMultiplier += count * CBBeesConfig.rapidWingsSpeedBonus.get() }), SWARM_INTELLIGENCE(3, "tooltip.cbbees.upgrade.swarm_intelligence", UpgradeShape.T_SHAPE, IUpgrade { ctx, count -> - ctx.maxActiveRobots += count * CBBeesConfig.swarmIntelligenceBeeBonus.get() + ctx.maxActiveBees += count * CBBeesConfig.swarmIntelligenceBeeBonus.get() }), HONEY_EFFICIENCY(2, "tooltip.cbbees.upgrade.honey_efficiency", UpgradeShape.BAR_2, IUpgrade { ctx, count -> ctx.breakSpeedMultiplier -= count * CBBeesConfig.honeyEfficiencyBreakSpeedReduction.get() diff --git a/src/main/kotlin/de/devin/cbbees/items/AllItems.kt b/src/main/kotlin/de/devin/cbbees/items/AllItems.kt index a066461..f5063c7 100644 --- a/src/main/kotlin/de/devin/cbbees/items/AllItems.kt +++ b/src/main/kotlin/de/devin/cbbees/items/AllItems.kt @@ -9,8 +9,10 @@ import de.devin.cbbees.CreateBuzzyBeez import de.devin.cbbees.content.backpack.PortableBeehiveItem import de.devin.cbbees.content.bee.MechanicalBeeItem import de.devin.cbbees.content.bee.MechanicalBumbleBeeItem +import de.devin.cbbees.content.deployer.ProgrammedSchematicItem import de.devin.cbbees.content.schematics.ConstructionPlannerItem import de.devin.cbbees.content.schematics.DeconstructionPlannerItem +import de.devin.cbbees.content.schematics.PickupPlannerItem import de.devin.cbbees.content.upgrades.* import de.devin.cbbees.items.AllItems.UPGRADE_TEMPLATE import net.minecraft.data.recipes.RecipeCategory @@ -43,7 +45,7 @@ object AllItems { .properties { it.stacksTo(1).rarity(Rarity.UNCOMMON) } .register() - // Mechanical Bee - goes in beehive/backpack robot slots (stackable, consumed on deploy) + // Mechanical Bee - goes in beehive/backpack bee slots (stackable, consumed on deploy) val MECHANICAL_BEE: ItemEntry = CreateBuzzyBeez.REGISTRATE .item("mechanical_bee") { props -> MechanicalBeeItem(props) @@ -88,6 +90,13 @@ object AllItems { .properties { it.stacksTo(1).rarity(Rarity.UNCOMMON) } .register() + // Pickup Planner - select areas for item collection by bumble bees + val PICKUP_PLANNER: ItemEntry = CreateBuzzyBeez.REGISTRATE + .item("pickup_planner") { props -> PickupPlannerItem(props) } + .model { _, _ -> } + .properties { it.stacksTo(1).rarity(Rarity.UNCOMMON) } + .register() + // Stinger Planner - select areas for removal (alternative to schematic-based deconstruction) val DECONSTRUCTION_PLANNER: ItemEntry = CreateBuzzyBeez.REGISTRATE .item("deconstruction_planner") { props -> @@ -109,6 +118,15 @@ object AllItems { .properties { it.stacksTo(1).rarity(Rarity.UNCOMMON) } .register() + // Programmed Schematic - stores a schematic program for automated deployment + val PROGRAMMED_SCHEMATIC: ItemEntry = CreateBuzzyBeez.REGISTRATE + .item("programmed_schematic") { props -> + ProgrammedSchematicItem(props) + } + .model { _, _ -> } // Hand-written model in resources + .properties { it.stacksTo(1).rarity(Rarity.RARE) } + .register() + // ===== Backpack Upgrades ===== val UPGRADE_TEMPLATE: ItemEntry = CreateBuzzyBeez.REGISTRATE diff --git a/src/main/kotlin/de/devin/cbbees/network/AllPackets.kt b/src/main/kotlin/de/devin/cbbees/network/AllPackets.kt index a5d4020..323c810 100644 --- a/src/main/kotlin/de/devin/cbbees/network/AllPackets.kt +++ b/src/main/kotlin/de/devin/cbbees/network/AllPackets.kt @@ -21,7 +21,7 @@ object AllPackets { StartDeconstructionPacket.Companion::handle ) - // Stop packet - cancels all active robot tasks + // Stop packet - cancels all active bee tasks registrar.playToServer( StopTasksPacket.TYPE, StopTasksPacket.STREAM_CODEC, @@ -68,6 +68,11 @@ object AllPackets { InstantConstructionPacket.STREAM_CODEC, InstantConstructionPacket.Companion::handle ) + registrar.playToServer( + ProgramSchematicPacket.TYPE, + ProgramSchematicPacket.STREAM_CODEC, + ProgramSchematicPacket.Companion::handle + ) registrar.playToServer( PlannerUploadPacket.TYPE, PlannerUploadPacket.STREAM_CODEC, @@ -79,6 +84,13 @@ object AllPackets { RequestPlayerJobsPacket.Companion::handle ) + // Pickup packet - scans area for items and dispatches bumble bees + registrar.playToServer( + StartPickupPacket.TYPE, + StartPickupPacket.STREAM_CODEC, + StartPickupPacket.Companion::handle + ) + // Grid upgrade packets registrar.playToServer( GridPlaceUpgradePacket.TYPE, @@ -91,6 +103,37 @@ object AllPackets { GridRemoveUpgradePacket.Companion::handle ) + // Deployer settings packet + registrar.playToServer( + DeployerSettingsPacket.TYPE, + DeployerSettingsPacket.STREAM_CODEC, + DeployerSettingsPacket.Companion::handle + ) + + // Job calculation progress (tick-counted, cached for late joiners) + registrar.playToClient( + JobProgressPacket.TYPE, + JobProgressPacket.STREAM_CODEC, + JobProgressPacket.Companion::handle + ) + + // Checkpoint-based bee flight plans + registrar.playToClient( + BeeFlightPlanPacket.TYPE, + BeeFlightPlanPacket.STREAM_CODEC, + BeeFlightPlanPacket.Companion::handle + ) + registrar.playToClient( + BeeRemovePacket.TYPE, + BeeRemovePacket.STREAM_CODEC, + BeeRemovePacket.Companion::handle + ) + registrar.playToClient( + BeeCheckpointConfirmPacket.TYPE, + BeeCheckpointConfirmPacket.STREAM_CODEC, + BeeCheckpointConfirmPacket.Companion::handle + ) + // Drone view packets registrar.playToServer( ToggleDroneViewPacket.TYPE, diff --git a/src/main/kotlin/de/devin/cbbees/network/BeeCheckpointConfirmPacket.kt b/src/main/kotlin/de/devin/cbbees/network/BeeCheckpointConfirmPacket.kt new file mode 100644 index 0000000..1ed921a --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/network/BeeCheckpointConfirmPacket.kt @@ -0,0 +1,60 @@ +package de.devin.cbbees.network + +import de.devin.cbbees.CreateBuzzyBeez +import de.devin.cbbees.content.bee.client.BeeClientTracker +import net.minecraft.network.RegistryFriendlyByteBuf +import net.minecraft.network.codec.StreamCodec +import net.minecraft.network.protocol.common.custom.CustomPacketPayload +import net.neoforged.neoforge.network.handling.IPayloadContext +import java.util.UUID + +/** + * Batched server → client notification that one or more bee checkpoint actions completed + * this tick. Sent once per tick after [de.devin.cbbees.content.bee.server.ServerBeeManager.tickAll] + * finishes processing all checkpoint arrivals. + * + * Batching avoids per-checkpoint packet overhead: with 30 confirmations/tick, this is + * one ~500-byte packet instead of 30 individual ~30-byte packets (saving TCP framing, + * NeoForge dispatch, and `enqueueWork` per entry). + * + * @see de.devin.cbbees.content.bee.flight.ClientBeeFlightData.confirmCheckpoint + */ +class BeeCheckpointConfirmPacket( + val entries: List, +) : CustomPacketPayload { + + data class Entry(val beeId: UUID, val checkpointIndex: Int) + + companion object { + val TYPE = CustomPacketPayload.Type( + CreateBuzzyBeez.asResource("bee_checkpoint_confirm") + ) + + val STREAM_CODEC: StreamCodec = + object : StreamCodec { + override fun decode(buf: RegistryFriendlyByteBuf) = BeeCheckpointConfirmPacket( + entries = (0 until buf.readVarInt()).map { + Entry(buf.readUUID(), buf.readVarInt()) + } + ) + + override fun encode(buf: RegistryFriendlyByteBuf, packet: BeeCheckpointConfirmPacket) { + buf.writeVarInt(packet.entries.size) + packet.entries.forEach { entry -> + buf.writeUUID(entry.beeId) + buf.writeVarInt(entry.checkpointIndex) + } + } + } + + fun handle(packet: BeeCheckpointConfirmPacket, context: IPayloadContext) { + context.enqueueWork { + packet.entries.forEach { entry -> + BeeClientTracker.confirmCheckpoint(entry.beeId, entry.checkpointIndex) + } + } + } + } + + override fun type(): CustomPacketPayload.Type = TYPE +} diff --git a/src/main/kotlin/de/devin/cbbees/network/BeeFlightPlanPacket.kt b/src/main/kotlin/de/devin/cbbees/network/BeeFlightPlanPacket.kt new file mode 100644 index 0000000..83240e4 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/network/BeeFlightPlanPacket.kt @@ -0,0 +1,86 @@ +package de.devin.cbbees.network + +import de.devin.cbbees.CreateBuzzyBeez +import de.devin.cbbees.content.bee.client.BeeClientTracker +import de.devin.cbbees.content.bee.flight.ClientBeeFlightData +import de.devin.cbbees.content.bee.flight.ClientCheckpoint +import de.devin.cbbees.content.bee.server.BeeType +import net.minecraft.network.RegistryFriendlyByteBuf +import net.minecraft.network.codec.StreamCodec +import net.minecraft.network.protocol.common.custom.CustomPacketPayload +import net.neoforged.neoforge.network.handling.IPayloadContext +import java.util.UUID + +/** + * Sends a bee's full flight plan to the client — **once per bee per mission**. + * + * Includes [elapsedTicks] so the client can fast-forward to match the server's + * current position along the checkpoint path. This prevents the visual desync + * caused by async plan computation + network latency. + * + * @see ClientBeeFlightData + * @see de.devin.cbbees.content.bee.flight.FlightPlan + */ +class BeeFlightPlanPacket( + val beeId: UUID, + val type: BeeType, + val speed: Float, + val checkpoints: List, + val startIndex: Int, + /** How many ticks the server has been running this plan. Client fast-forwards by this amount. */ + val elapsedTicks: Long = 0, +) : CustomPacketPayload { + + companion object { + val TYPE = CustomPacketPayload.Type(CreateBuzzyBeez.asResource("bee_flight_plan")) + + val STREAM_CODEC: StreamCodec = + object : StreamCodec { + override fun decode(buf: RegistryFriendlyByteBuf) = BeeFlightPlanPacket( + beeId = buf.readUUID(), + type = BeeType.entries[buf.readByte().toInt()], + speed = buf.readFloat(), + checkpoints = (0 until buf.readVarInt()).map { + ClientCheckpoint( + pos = buf.readBlockPos(), + pauseTicks = buf.readVarInt(), + awaitConfirm = buf.readBoolean(), + ) + }, + startIndex = buf.readVarInt(), + elapsedTicks = buf.readVarLong(), + ) + + override fun encode(buf: RegistryFriendlyByteBuf, packet: BeeFlightPlanPacket) { + buf.writeUUID(packet.beeId) + buf.writeByte(packet.type.ordinal) + buf.writeFloat(packet.speed) + buf.writeVarInt(packet.checkpoints.size) + packet.checkpoints.forEach { cp -> + buf.writeBlockPos(cp.pos) + buf.writeVarInt(cp.pauseTicks) + buf.writeBoolean(cp.awaitConfirm) + } + buf.writeVarInt(packet.startIndex) + buf.writeVarLong(packet.elapsedTicks) + } + } + + fun handle(packet: BeeFlightPlanPacket, context: IPayloadContext) { + context.enqueueWork { + BeeClientTracker.applyFlightPlan( + ClientBeeFlightData( + id = packet.beeId, + type = packet.type, + speed = packet.speed, + checkpoints = packet.checkpoints, + startIndex = packet.startIndex, + elapsedNanoOffset = packet.elapsedTicks * 50_000_000L, // ticks → nanos + ) + ) + } + } + } + + override fun type(): CustomPacketPayload.Type = TYPE +} diff --git a/src/main/kotlin/de/devin/cbbees/network/BeeRemovePacket.kt b/src/main/kotlin/de/devin/cbbees/network/BeeRemovePacket.kt new file mode 100644 index 0000000..e975eb3 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/network/BeeRemovePacket.kt @@ -0,0 +1,32 @@ +package de.devin.cbbees.network + +import de.devin.cbbees.CreateBuzzyBeez +import de.devin.cbbees.content.bee.client.BeeClientTracker +import net.minecraft.network.RegistryFriendlyByteBuf +import net.minecraft.network.codec.StreamCodec +import net.minecraft.network.protocol.common.custom.CustomPacketPayload +import net.neoforged.neoforge.network.handling.IPayloadContext +import java.util.UUID + +/** + * Removes a bee from the client immediately (bee entered hive, dropped as item, etc.). + * Sent as a targeted notification — no polling or staleness timeout needed. + */ +class BeeRemovePacket(val beeId: UUID) : CustomPacketPayload { + + companion object { + val TYPE = CustomPacketPayload.Type(CreateBuzzyBeez.asResource("bee_remove")) + + val STREAM_CODEC: StreamCodec = + object : StreamCodec { + override fun decode(buf: RegistryFriendlyByteBuf) = BeeRemovePacket(buf.readUUID()) + override fun encode(buf: RegistryFriendlyByteBuf, packet: BeeRemovePacket) { buf.writeUUID(packet.beeId) } + } + + fun handle(packet: BeeRemovePacket, context: IPayloadContext) { + context.enqueueWork { BeeClientTracker.removeFlightData(packet.beeId) } + } + } + + override fun type(): CustomPacketPayload.Type = TYPE +} diff --git a/src/main/kotlin/de/devin/cbbees/network/CCRServerEvents.kt b/src/main/kotlin/de/devin/cbbees/network/CCRServerEvents.kt index f7e9ec3..8e39e6e 100644 --- a/src/main/kotlin/de/devin/cbbees/network/CCRServerEvents.kt +++ b/src/main/kotlin/de/devin/cbbees/network/CCRServerEvents.kt @@ -1,10 +1,13 @@ package de.devin.cbbees.network import de.devin.cbbees.content.bee.debug.BeeDebug +import de.devin.cbbees.content.bee.server.ServerBeeManager import de.devin.cbbees.content.domain.GlobalJobPool import de.devin.cbbees.content.domain.TransportDispatcher +import de.devin.cbbees.content.domain.job.JobCalculationProgress import de.devin.cbbees.content.domain.network.ServerBeeNetworkManager import de.devin.cbbees.content.drone.DroneViewManager +import de.devin.cbbees.util.ServerTickScheduler import net.minecraft.server.level.ServerPlayer import net.neoforged.bus.api.SubscribeEvent import net.neoforged.neoforge.event.entity.living.LivingDeathEvent @@ -21,32 +24,49 @@ object CCRServerEvents { private var tickCounter = 0 private var syncCounter = 0 + private var beeSyncCounter = 0 - /** - * Called every server tick. - */ @SubscribeEvent @JvmStatic fun onServerTick(event: ServerTickEvent.Post) { - tickCounter++ + val server = net.neoforged.neoforge.server.ServerLifecycleHooks.getCurrentServer() ?: return + val overworld = server.overworld() + val gameTime = overworld.gameTime + + val profiler = overworld.profiler + + // Drain deferred callbacks (e.g. async task generation chunks) — exactly one + // batch per tick. Anything scheduled inside a callback runs next tick. + ServerTickScheduler.runScheduled() - // Core logic every 10 ticks (0.5 seconds) + // ── Tick non-entity bees EVERY tick ── + profiler.push("beeManager") + ServerBeeManager.init(overworld) + ServerBeeManager.tickAll(overworld, gameTime) + profiler.pop() + + JobCalculationProgress.tickEvictions(server.tickCount) + + // No per-tick sync needed — bees use checkpoint-based flight plans + + // ── Core logic every 10 ticks (0.5 seconds) ── + tickCounter++ if (tickCounter < 10) return tickCounter = 0 - val server = net.neoforged.neoforge.server.ServerLifecycleHooks.getCurrentServer() ?: return - val gameTime = server.overworld().gameTime + ServerBeeNetworkManager.getNetworks().forEach { it.purgeStaleComponents(gameTime) } + ServerBeeNetworkManager.rebuildIndexes() GlobalJobPool.tick(gameTime) TransportDispatcher.tick(gameTime) ServerBeeNetworkManager.getNetworks().forEach { it.cleanupReservations(gameTime) } DroneViewManager.validateDrones() - // Sync packets every 40 ticks (2 seconds) to reduce network and serialization overhead + // Sync packets every 40 ticks (2 seconds) syncCounter++ if (syncCounter >= 4) { syncCounter = 0 - for (player in server.playerList.players) { + server.playerList.players.forEach { player -> HiveJobsSyncPacket.sendPlayerSnapshotTo(player) NetworkSyncPacket.sendTo(player) } @@ -83,8 +103,10 @@ object CCRServerEvents { ServerBeeNetworkManager.clear() GlobalJobPool.clear() TransportDispatcher.clear() + ServerBeeManager.clear() BeeDebug.clear() PlannerUploadPacket.shutdown() DroneViewManager.clear() + ServerTickScheduler.clear() } } diff --git a/src/main/kotlin/de/devin/cbbees/network/DeployerSettingsPacket.kt b/src/main/kotlin/de/devin/cbbees/network/DeployerSettingsPacket.kt new file mode 100644 index 0000000..935c649 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/network/DeployerSettingsPacket.kt @@ -0,0 +1,72 @@ +package de.devin.cbbees.network + +import de.devin.cbbees.CreateBuzzyBeez +import de.devin.cbbees.content.deployer.DeployMode +import de.devin.cbbees.content.deployer.SchematicDeployerBlockEntity +import net.minecraft.core.BlockPos +import net.minecraft.network.RegistryFriendlyByteBuf +import net.minecraft.network.codec.StreamCodec +import net.minecraft.network.protocol.common.custom.CustomPacketPayload +import net.minecraft.server.level.ServerPlayer +import net.minecraft.world.level.block.Mirror +import net.minecraft.world.level.block.Rotation +import net.neoforged.neoforge.network.handling.IPayloadContext + +/** + * Client -> Server packet sent when the player changes deploy mode, relative offset, + * or rotation/mirror overrides in the Schematic Deployer GUI. + */ +class DeployerSettingsPacket( + val deployerPos: BlockPos, + val mode: DeployMode, + val relativeOffset: BlockPos, + val relativeRotation: Rotation, + val relativeMirror: Mirror +) : CustomPacketPayload { + + companion object { + val TYPE = CustomPacketPayload.Type( + CreateBuzzyBeez.asResource("deployer_settings") + ) + + val STREAM_CODEC: StreamCodec = StreamCodec.of( + { buf, pkt -> + buf.writeBlockPos(pkt.deployerPos) + buf.writeEnum(pkt.mode) + buf.writeBlockPos(pkt.relativeOffset) + buf.writeEnum(pkt.relativeRotation) + buf.writeEnum(pkt.relativeMirror) + }, + { buf -> + DeployerSettingsPacket( + buf.readBlockPos(), + buf.readEnum(DeployMode::class.java), + buf.readBlockPos(), + buf.readEnum(Rotation::class.java), + buf.readEnum(Mirror::class.java) + ) + } + ) + + fun handle(payload: DeployerSettingsPacket, context: IPayloadContext) { + context.enqueueWork { + val player = context.player() as? ServerPlayer ?: return@enqueueWork + val level = player.serverLevel() + + if (player.blockPosition().distSqr(payload.deployerPos) > 64.0) return@enqueueWork + + val be = level.getBlockEntity(payload.deployerPos) as? SchematicDeployerBlockEntity + ?: return@enqueueWork + + be.deployMode = payload.mode + be.relativeOffset = payload.relativeOffset + be.relativeRotation = payload.relativeRotation + be.relativeMirror = payload.relativeMirror + be.setChanged() + be.sendData() + } + } + } + + override fun type(): CustomPacketPayload.Type = TYPE +} diff --git a/src/main/kotlin/de/devin/cbbees/network/HiveJobsSyncPacket.kt b/src/main/kotlin/de/devin/cbbees/network/HiveJobsSyncPacket.kt index 57f38a3..69b68f4 100644 --- a/src/main/kotlin/de/devin/cbbees/network/HiveJobsSyncPacket.kt +++ b/src/main/kotlin/de/devin/cbbees/network/HiveJobsSyncPacket.kt @@ -228,7 +228,7 @@ class HiveJobsSyncPacket( val hiveList = net.hives val statsActive = hiveList.sumOf { it.getActiveBeeCount() } val statsStored = hiveList.sumOf { it.getAvailableBeeCount() } - val statsMax = hiveList.sumOf { it.getBeeContext().maxActiveRobots } + val statsMax = hiveList.sumOf { it.getBeeContext().maxActiveBees } val jobs = GlobalJobPool.getAllJobs() .filter { job -> @@ -243,17 +243,29 @@ class HiveJobsSyncPacket( val reason = StuckReasonResolver.firstReasonOrNull(net, job) - // Only send ghost blocks if no schematic placement is available - // (client can derive ghosts from the placement data itself) + // For jobs with schematic placement, skip per-batch detail entirely — + // the client renders ghosts from the schematic file. Only send a single + // summary batch to keep the packet small. val hasPlacement = job.schematicPlacement != null - val batches = job.batches.map { b -> - ClientBatchInfo( - status = b.status.name, - target = b.targetPosition, + val batches = if (hasPlacement) { + // Single summary entry — no per-batch overhead for large schematics + listOf(ClientBatchInfo( + status = "SUMMARY", + target = job.centerPos, required = emptyList(), assignedBeeIds = emptyList(), - ghostBlocks = if (hasPlacement) emptyMap() else collectGhostBlocks(b) - ) + ghostBlocks = emptyMap() + )) + } else { + job.batches.map { b -> + ClientBatchInfo( + status = b.status.name, + target = b.targetPosition, + required = emptyList(), + assignedBeeIds = emptyList(), + ghostBlocks = collectGhostBlocks(b) + ) + } } ClientJobInfo( jobId = job.jobId, diff --git a/src/main/kotlin/de/devin/cbbees/network/InstantConstructionPacket.kt b/src/main/kotlin/de/devin/cbbees/network/InstantConstructionPacket.kt index 2750d52..9816145 100644 --- a/src/main/kotlin/de/devin/cbbees/network/InstantConstructionPacket.kt +++ b/src/main/kotlin/de/devin/cbbees/network/InstantConstructionPacket.kt @@ -2,14 +2,15 @@ package de.devin.cbbees.network import com.simibubi.create.AllDataComponents import de.devin.cbbees.CreateBuzzyBeez +import de.devin.cbbees.config.CBBeesConfig import de.devin.cbbees.content.domain.GlobalJobPool -import de.devin.cbbees.content.domain.network.ServerBeeNetworkManager import de.devin.cbbees.content.domain.job.BeeJob +import de.devin.cbbees.content.domain.job.JobCalculationProgress import de.devin.cbbees.content.domain.job.SchematicPlacement +import de.devin.cbbees.content.domain.network.ServerBeeNetworkManager import de.devin.cbbees.content.schematics.ConstructionPlannerItem import de.devin.cbbees.content.schematics.SchematicCreateBridge import de.devin.cbbees.content.schematics.SchematicJobKey -import de.devin.cbbees.items.AllItems import de.devin.cbbees.util.ServerSide import net.minecraft.core.BlockPos import net.minecraft.network.RegistryFriendlyByteBuf @@ -60,9 +61,9 @@ class InstantConstructionPacket( fun handle(payload: InstantConstructionPacket, context: IPayloadContext) { context.enqueueWork { val player = context.player() as? ServerPlayer ?: return@enqueueWork - val mainHand = player.mainHandItem + val mainHand = ConstructionPlannerItem.findPlanner(player) - if (!AllItems.CONSTRUCTION_PLANNER.isIn(mainHand)) { + if (mainHand.isEmpty) { player.displayClientMessage( Component.translatable("cbbees.construction.requires_planner"), true ) @@ -118,26 +119,31 @@ class InstantConstructionPacket( ) } - val batches = bridge.generateBuildTasks(job) - if (batches.isNotEmpty()) { - job.centerPos = bridge.getAnchor() ?: batches[0].targetPosition - job.batches.addAll(batches) + val server = player.server ?: return@enqueueWork + val bounds = mainHand.get(AllDataComponents.SCHEMATIC_BOUNDS) + val expectedBlocks = bounds?.let { it.x * it.y * it.z } ?: 0 + val blocksPerTick = CBBeesConfig.taskGenerationBlocksPerTick.get() + val tracker = JobCalculationProgress.newTracker( + jobId, player.uuid, "cbbees.progress.processing_schematic", expectedBlocks, server, + ) + tracker.start() - ServerBeeNetworkManager.findPortableHive(player.uuid)?.let { - ServerBeeNetworkManager.reconnectPortableHive(it) - } - GlobalJobPool.dispatchNewJob(job) - ConstructionPlannerItem.clearSchematic(mainHand) - HiveJobsSyncPacket.sendPlayerSnapshotTo(player) + ConstructionPlannerItem.clearSchematic(mainHand) - player.displayClientMessage( - Component.translatable("cbbees.construction.started", batches.size), true - ) - } else { - ConstructionPlannerItem.clearSchematic(mainHand) - player.displayClientMessage( - Component.translatable("cbbees.construction.no_tasks"), true - ) + bridge.generateBuildTasksAsync(job, server, blocksPerTick, tracker) { batches -> + if (batches.isNotEmpty()) { + job.centerPos = bridge.getAnchor() ?: batches[0].targetPosition + job.batches.addAll(batches) + + ServerBeeNetworkManager.findPortableHive(player.uuid)?.let { + ServerBeeNetworkManager.reconnectPortableHive(it) + } + GlobalJobPool.dispatchNewJob(job) + + tracker.complete("cbbees.construction.started", batches.size) + } else { + tracker.fail() + } } } } diff --git a/src/main/kotlin/de/devin/cbbees/network/JobProgressPacket.kt b/src/main/kotlin/de/devin/cbbees/network/JobProgressPacket.kt new file mode 100644 index 0000000..4aeb9f8 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/network/JobProgressPacket.kt @@ -0,0 +1,67 @@ +package de.devin.cbbees.network + +import de.devin.cbbees.CreateBuzzyBeez +import de.devin.cbbees.content.domain.job.JobCalculationProgress +import de.devin.cbbees.content.domain.job.client.JobProgressClient +import net.minecraft.network.RegistryFriendlyByteBuf +import net.minecraft.network.codec.StreamCodec +import net.minecraft.network.protocol.common.custom.CustomPacketPayload +import net.neoforged.neoforge.network.handling.IPayloadContext +import java.util.UUID + +/** + * Server → client packet broadcasting the latest progress snapshot for a job's + * calculation phase (schematic build/removal task generation). + * + * Sent on each completed calculation tick, on start, and on completion/failure. + * Cached server-side via [JobCalculationProgress] so that re-joining players are + * immediately resynced to the latest known state. + * + * @see JobCalculationProgress + * @see JobProgressClient + */ +class JobProgressPacket( + val jobId: UUID, + val phase: JobCalculationProgress.Phase, + val labelKey: String, + val processedBlocks: Int, + val expectedBlocks: Int, + /** Translation key for the completion message (only meaningful when phase is COMPLETED). */ + val resultKey: String = "", + /** Argument for the completion translation (e.g. task count). */ + val resultCount: Int = 0, +) : CustomPacketPayload { + + companion object { + val TYPE = CustomPacketPayload.Type(CreateBuzzyBeez.asResource("job_progress")) + + val STREAM_CODEC: StreamCodec = + object : StreamCodec { + override fun decode(buf: RegistryFriendlyByteBuf) = JobProgressPacket( + jobId = buf.readUUID(), + phase = JobCalculationProgress.Phase.entries[buf.readByte().toInt()], + labelKey = buf.readUtf(), + processedBlocks = buf.readVarInt(), + expectedBlocks = buf.readVarInt(), + resultKey = buf.readUtf(), + resultCount = buf.readVarInt(), + ) + + override fun encode(buf: RegistryFriendlyByteBuf, packet: JobProgressPacket) { + buf.writeUUID(packet.jobId) + buf.writeByte(packet.phase.ordinal) + buf.writeUtf(packet.labelKey) + buf.writeVarInt(packet.processedBlocks) + buf.writeVarInt(packet.expectedBlocks) + buf.writeUtf(packet.resultKey) + buf.writeVarInt(packet.resultCount) + } + } + + fun handle(packet: JobProgressPacket, context: IPayloadContext) { + context.enqueueWork { JobProgressClient.apply(packet) } + } + } + + override fun type(): CustomPacketPayload.Type = TYPE +} diff --git a/src/main/kotlin/de/devin/cbbees/network/ProgramSchematicPacket.kt b/src/main/kotlin/de/devin/cbbees/network/ProgramSchematicPacket.kt new file mode 100644 index 0000000..7bdf12c --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/network/ProgramSchematicPacket.kt @@ -0,0 +1,65 @@ +package de.devin.cbbees.network + +import de.devin.cbbees.CreateBuzzyBeez +import de.devin.cbbees.content.deployer.SchematicProgram +import de.devin.cbbees.items.AllItems +import de.devin.cbbees.registry.AllDataComponents +import net.minecraft.core.BlockPos +import net.minecraft.network.RegistryFriendlyByteBuf +import net.minecraft.network.chat.Component +import net.minecraft.network.codec.StreamCodec +import net.minecraft.network.protocol.common.custom.CustomPacketPayload +import net.minecraft.server.level.ServerPlayer +import net.minecraft.world.item.ItemStack +import net.minecraft.world.level.block.Mirror +import net.minecraft.world.level.block.Rotation +import net.neoforged.neoforge.network.handling.IPayloadContext + +/** + * Client -> Server packet sent when the player clicks "Program" in the + * Construction or Deconstruction Planner HUD. Creates a [ProgrammedSchematicItem] + * with the appropriate [SchematicProgram] data component and gives it to the player. + */ +class ProgramSchematicPacket( + val program: SchematicProgram +) : CustomPacketPayload { + + companion object { + val TYPE = CustomPacketPayload.Type( + CreateBuzzyBeez.asResource("program_schematic") + ) + + val STREAM_CODEC: StreamCodec = StreamCodec.of( + { buf, pkt -> SchematicProgram.STREAM_CODEC.encode(buf, pkt.program) }, + { buf -> ProgramSchematicPacket(SchematicProgram.STREAM_CODEC.decode(buf)) } + ) + + fun handle(payload: ProgramSchematicPacket, context: IPayloadContext) { + context.enqueueWork { + val player = context.player() as? ServerPlayer ?: return@enqueueWork + + // Ensure schematic file is uploaded for construction programs + val program = payload.program + if (program is SchematicProgram.Construction) { + ensureSchematicUploaded(program.owner, program.schematicName) + } + + // Create the programmed schematic item + val stack = ItemStack(AllItems.PROGRAMMED_SCHEMATIC.get()) + stack.set(AllDataComponents.SCHEMATIC_PROGRAM, program) + + // Give to player or drop at feet + if (!player.inventory.add(stack)) { + player.drop(stack, false) + } + + player.displayClientMessage( + Component.translatable("cbbees.schematic.programmed"), + true + ) + } + } + } + + override fun type(): CustomPacketPayload.Type = TYPE +} diff --git a/src/main/kotlin/de/devin/cbbees/network/SelectSchematicPacket.kt b/src/main/kotlin/de/devin/cbbees/network/SelectSchematicPacket.kt index 3e7e395..eac9a01 100644 --- a/src/main/kotlin/de/devin/cbbees/network/SelectSchematicPacket.kt +++ b/src/main/kotlin/de/devin/cbbees/network/SelectSchematicPacket.kt @@ -4,7 +4,7 @@ import com.simibubi.create.AllDataComponents import com.simibubi.create.content.schematics.SchematicItem import com.simibubi.create.foundation.utility.CreatePaths import de.devin.cbbees.CreateBuzzyBeez -import de.devin.cbbees.items.AllItems +import de.devin.cbbees.content.schematics.ConstructionPlannerItem import net.minecraft.core.BlockPos import net.minecraft.network.RegistryFriendlyByteBuf import net.minecraft.network.codec.StreamCodec @@ -53,9 +53,9 @@ class SelectSchematicPacket( fun handle(payload: SelectSchematicPacket, ctx: IPayloadContext) { ctx.enqueueWork { val player = ctx.player() as? ServerPlayer ?: return@enqueueWork - val stack = player.mainHandItem + val stack = ConstructionPlannerItem.findPlanner(player) - if (!AllItems.CONSTRUCTION_PLANNER.isIn(stack)) return@enqueueWork + if (stack.isEmpty) return@enqueueWork // Sanitize filename val name = payload.schematicName diff --git a/src/main/kotlin/de/devin/cbbees/network/StartConstructionPacket.kt b/src/main/kotlin/de/devin/cbbees/network/StartConstructionPacket.kt index aaafcea..6005961 100644 --- a/src/main/kotlin/de/devin/cbbees/network/StartConstructionPacket.kt +++ b/src/main/kotlin/de/devin/cbbees/network/StartConstructionPacket.kt @@ -2,13 +2,14 @@ package de.devin.cbbees.network import com.simibubi.create.AllDataComponents import de.devin.cbbees.CreateBuzzyBeez +import de.devin.cbbees.config.CBBeesConfig import de.devin.cbbees.content.domain.GlobalJobPool import de.devin.cbbees.content.domain.job.BeeJob +import de.devin.cbbees.content.domain.job.JobCalculationProgress import de.devin.cbbees.content.domain.job.SchematicPlacement import de.devin.cbbees.content.schematics.ConstructionPlannerItem import de.devin.cbbees.content.schematics.SchematicCreateBridge import de.devin.cbbees.content.schematics.SchematicJobKey -import de.devin.cbbees.items.AllItems import de.devin.cbbees.content.domain.network.ServerBeeNetworkManager import de.devin.cbbees.util.ServerSide import net.minecraft.core.BlockPos @@ -56,9 +57,9 @@ class StartConstructionPacket( context.enqueueWork { val player = context.player() as? ServerPlayer ?: return@enqueueWork - // Construction can only be started from the Construction Planner - val mainHand = player.mainHandItem - if (!AllItems.CONSTRUCTION_PLANNER.isIn(mainHand)) { + // Find the Construction Planner (main hand first, then inventory for drone view) + val mainHand = ConstructionPlannerItem.findPlanner(player) + if (mainHand.isEmpty) { player.displayClientMessage( Component.translatable("cbbees.construction.requires_planner"), true ) @@ -108,31 +109,35 @@ class StartConstructionPacket( } } - val batches = bridge.generateBuildTasks(job) - if (batches.isNotEmpty()) { - job.centerPos = bridge.getAnchor() ?: batches[0].targetPosition - job.batches.addAll(batches) + ConstructionPlannerItem.clearSchematic(schematicStack) - ServerBeeNetworkManager.findPortableHive(player.uuid)?.let { - ServerBeeNetworkManager.reconnectPortableHive(it) - } - GlobalJobPool.dispatchNewJob(job) + val server = player.server ?: return@enqueueWork + val bounds = mainHand.get(AllDataComponents.SCHEMATIC_BOUNDS) + val expectedBlocks = bounds?.let { it.x * it.y * it.z } ?: 0 + val blocksPerTick = CBBeesConfig.taskGenerationBlocksPerTick.get() + val tracker = JobCalculationProgress.newTracker( + jobId, player.uuid, "cbbees.progress.processing_schematic", expectedBlocks, server, + ) + tracker.start() - // Construction Planner is reusable — clear all schematic data - ConstructionPlannerItem.clearSchematic(schematicStack) + bridge.generateBuildTasksAsync(job, server, blocksPerTick, tracker) { batches -> + if (batches.isNotEmpty()) { + job.centerPos = bridge.getAnchor() ?: batches[0].targetPosition + job.batches.addAll(batches) - // Requirement 2: Immediate sync for ghosts - HiveJobsSyncPacket.sendPlayerSnapshotTo(player) + ServerBeeNetworkManager.findPortableHive(player.uuid)?.let { + ServerBeeNetworkManager.reconnectPortableHive(it) + } + GlobalJobPool.dispatchNewJob(job) - player.displayClientMessage( - Component.translatable("cbbees.construction.started", batches.size), - true - ) - } else { - player.displayClientMessage(Component.translatable("cbbees.construction.no_tasks"), true) + tracker.complete("cbbees.construction.started", batches.size) + } else { + tracker.fail() + } } } } + } override fun type(): CustomPacketPayload.Type = TYPE diff --git a/src/main/kotlin/de/devin/cbbees/network/StartDeconstructionPacket.kt b/src/main/kotlin/de/devin/cbbees/network/StartDeconstructionPacket.kt index 14e41d2..33484f0 100644 --- a/src/main/kotlin/de/devin/cbbees/network/StartDeconstructionPacket.kt +++ b/src/main/kotlin/de/devin/cbbees/network/StartDeconstructionPacket.kt @@ -1,15 +1,17 @@ package de.devin.cbbees.network import de.devin.cbbees.CreateBuzzyBeez +import de.devin.cbbees.config.CBBeesConfig import de.devin.cbbees.content.domain.GlobalJobPool -import de.devin.cbbees.content.domain.network.ServerBeeNetworkManager import de.devin.cbbees.content.domain.job.BeeJob +import de.devin.cbbees.content.domain.job.JobCalculationProgress +import de.devin.cbbees.content.domain.network.ServerBeeNetworkManager import de.devin.cbbees.content.schematics.SchematicCreateBridge import de.devin.cbbees.content.schematics.SchematicJobKey import java.util.* +import kotlin.math.abs import net.minecraft.core.BlockPos import net.minecraft.network.RegistryFriendlyByteBuf -import net.minecraft.network.chat.Component import net.minecraft.network.codec.StreamCodec import net.minecraft.network.protocol.common.custom.CustomPacketPayload import net.minecraft.server.level.ServerPlayer @@ -21,7 +23,7 @@ import net.neoforged.neoforge.network.handling.IPayloadContext * When received, the server will: * 1. Validate the selection positions * 2. Generate removal tasks for all blocks within the selected area - * 3. Spawn robots to perform the deconstruction + * 3. Spawn bees to perform the deconstruction * * @param pos1 First corner of the selection area * @param pos2 Second corner of the selection area @@ -54,30 +56,41 @@ class StartDeconstructionPacket( SchematicJobKey(player.uuid, "deconstruct_area", payload.pos1.x, payload.pos1.y, payload.pos1.z) } - val tasks = SchematicCreateBridge(player.level()).generateRemovalTasks(payload.pos1, payload.pos2, job) - if (tasks.isNotEmpty()) { - job.centerPos = BlockPos( - (payload.pos1.x + payload.pos2.x) / 2, - (payload.pos1.y + payload.pos2.y) / 2, - (payload.pos1.z + payload.pos2.z) / 2 - ) - job.addBatches(tasks) + val server = player.server ?: return@enqueueWork + val expectedBlocks = volumeOf(payload.pos1, payload.pos2) + val blocksPerTick = CBBeesConfig.taskGenerationBlocksPerTick.get() + val tracker = JobCalculationProgress.newTracker( + jobId, player.uuid, "cbbees.progress.processing_area", expectedBlocks, server, + ) + tracker.start() - val portableHive = ServerBeeNetworkManager.findPortableHive(player.uuid) - if (portableHive != null) { - ServerBeeNetworkManager.reconnectPortableHive(portableHive) - } + val bridge = SchematicCreateBridge(player.level()) + bridge.generateRemovalTasksAsync( + payload.pos1, payload.pos2, job, server, blocksPerTick, tracker, + ) { tasks -> + if (tasks.isNotEmpty()) { + job.centerPos = BlockPos( + (payload.pos1.x + payload.pos2.x) / 2, + (payload.pos1.y + payload.pos2.y) / 2, + (payload.pos1.z + payload.pos2.z) / 2 + ) + job.addBatches(tasks) - GlobalJobPool.dispatchNewJob(job) - player.displayClientMessage( - Component.translatable("cbbees.deconstruction.started", tasks.size), - true - ) - } else { - player.displayClientMessage(Component.translatable("cbbees.deconstruction.no_blocks"), true) + ServerBeeNetworkManager.findPortableHive(player.uuid)?.let { + ServerBeeNetworkManager.reconnectPortableHive(it) + } + + GlobalJobPool.dispatchNewJob(job) + tracker.complete("cbbees.deconstruction.started", tasks.size) + } else { + tracker.fail() + } } } } + + private fun volumeOf(a: BlockPos, b: BlockPos): Int = + (abs(a.x - b.x) + 1) * (abs(a.y - b.y) + 1) * (abs(a.z - b.z) + 1) } override fun type(): CustomPacketPayload.Type = TYPE diff --git a/src/main/kotlin/de/devin/cbbees/network/StartPickupPacket.kt b/src/main/kotlin/de/devin/cbbees/network/StartPickupPacket.kt new file mode 100644 index 0000000..807fb88 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/network/StartPickupPacket.kt @@ -0,0 +1,83 @@ +package de.devin.cbbees.network + +import de.devin.cbbees.CreateBuzzyBeez +import de.devin.cbbees.content.domain.GlobalJobPool +import de.devin.cbbees.content.domain.job.BeeJob +import de.devin.cbbees.content.domain.job.JobCalculationProgress +import de.devin.cbbees.content.domain.network.ServerBeeNetworkManager +import de.devin.cbbees.content.schematics.SchematicCreateBridge +import de.devin.cbbees.content.schematics.SchematicJobKey +import net.minecraft.core.BlockPos +import net.minecraft.network.RegistryFriendlyByteBuf +import net.minecraft.network.codec.StreamCodec +import net.minecraft.network.protocol.common.custom.CustomPacketPayload +import net.minecraft.server.level.ServerPlayer +import net.neoforged.neoforge.network.handling.IPayloadContext +import java.util.UUID + +/** + * Client → server packet to start item pickup within a selected area. + * Scans for loose [net.minecraft.world.entity.item.ItemEntity] objects and + * dispatches bumble bees to collect them. + */ +class StartPickupPacket( + val pos1: BlockPos, + val pos2: BlockPos, +) : CustomPacketPayload { + + companion object { + val TYPE = CustomPacketPayload.Type(CreateBuzzyBeez.asResource("start_pickup")) + + val STREAM_CODEC: StreamCodec = StreamCodec.composite( + BlockPos.STREAM_CODEC, StartPickupPacket::pos1, + BlockPos.STREAM_CODEC, StartPickupPacket::pos2, + ::StartPickupPacket + ) + + fun handle(payload: StartPickupPacket, context: IPayloadContext) { + context.enqueueWork { + val player = context.player() as? ServerPlayer ?: return@enqueueWork + + val jobId = UUID.randomUUID() + val job = BeeJob(jobId, BlockPos.ZERO, player.level()).apply { + ownerId = player.uuid + uniquenessKey = SchematicJobKey( + player.uuid, "pickup_area", + payload.pos1.x, payload.pos1.y, payload.pos1.z, + ) + } + + val server = player.server ?: return@enqueueWork + + val bridge = SchematicCreateBridge(player.level()) + val result = bridge.generatePickupBatches(payload.pos1, payload.pos2, job) + + // Item scan is instant — just show the completion toast with item count + val tracker = JobCalculationProgress.newTracker( + jobId, player.uuid, "cbbees.progress.scanning_items", result.totalItems.coerceAtLeast(1), server, + ) + tracker.start() + + if (result.batches.isNotEmpty()) { + job.centerPos = BlockPos( + (payload.pos1.x + payload.pos2.x) / 2, + (payload.pos1.y + payload.pos2.y) / 2, + (payload.pos1.z + payload.pos2.z) / 2, + ) + job.addBatches(result.batches) + + ServerBeeNetworkManager.findPortableHive(player.uuid)?.let { + ServerBeeNetworkManager.reconnectPortableHive(it) + } + + GlobalJobPool.dispatchNewJob(job) + tracker.complete("cbbees.pickup.started", result.batches.size) + } else { + tracker.fail() + } + } + } + } + + override fun type(): CustomPacketPayload.Type = TYPE +} diff --git a/src/main/kotlin/de/devin/cbbees/network/StopTasksPacket.kt b/src/main/kotlin/de/devin/cbbees/network/StopTasksPacket.kt index 771a4b1..c84e100 100644 --- a/src/main/kotlin/de/devin/cbbees/network/StopTasksPacket.kt +++ b/src/main/kotlin/de/devin/cbbees/network/StopTasksPacket.kt @@ -9,7 +9,7 @@ import net.minecraft.server.level.ServerPlayer import net.neoforged.neoforge.network.handling.IPayloadContext /** - * Packet sent from client to server to stop all ongoing robot tasks for the player. + * Packet sent from client to server to stop all ongoing bee tasks for the player. * Used when the player wants to cancel construction or deconstruction. */ class StopTasksPacket private constructor() : CustomPacketPayload { diff --git a/src/main/kotlin/de/devin/cbbees/network/UnselectSchematicPacket.kt b/src/main/kotlin/de/devin/cbbees/network/UnselectSchematicPacket.kt index 7aaa1ee..0b7f41f 100644 --- a/src/main/kotlin/de/devin/cbbees/network/UnselectSchematicPacket.kt +++ b/src/main/kotlin/de/devin/cbbees/network/UnselectSchematicPacket.kt @@ -2,7 +2,6 @@ package de.devin.cbbees.network import de.devin.cbbees.CreateBuzzyBeez import de.devin.cbbees.content.schematics.ConstructionPlannerItem -import de.devin.cbbees.items.AllItems import net.minecraft.network.RegistryFriendlyByteBuf import net.minecraft.network.codec.StreamCodec import net.minecraft.network.protocol.common.custom.CustomPacketPayload @@ -28,8 +27,8 @@ class UnselectSchematicPacket private constructor() : CustomPacketPayload { fun handle(payload: UnselectSchematicPacket, ctx: IPayloadContext) { ctx.enqueueWork { val player = ctx.player() as? ServerPlayer ?: return@enqueueWork - val stack = player.mainHandItem - if (!AllItems.CONSTRUCTION_PLANNER.isIn(stack)) return@enqueueWork + val stack = ConstructionPlannerItem.findPlanner(player) + if (stack.isEmpty) return@enqueueWork ConstructionPlannerItem.clearSchematic(stack) } } diff --git a/src/main/kotlin/de/devin/cbbees/ponder/CBBPonderPlugin.kt b/src/main/kotlin/de/devin/cbbees/ponder/CBBPonderPlugin.kt index 3d9751c..be37b89 100644 --- a/src/main/kotlin/de/devin/cbbees/ponder/CBBPonderPlugin.kt +++ b/src/main/kotlin/de/devin/cbbees/ponder/CBBPonderPlugin.kt @@ -36,6 +36,13 @@ class CBBPonderPlugin : PonderPlugin { helper.forComponents(AllItems.MECHANICAL_BEE.id).addStoryBoard("base", beeStoryBoard) helper.forComponents(AllItems.MECHANICAL_BUMBLE_BEE.id).addStoryBoard("base", beeStoryBoard) + helper.forComponents(AllBlocks.SCHEMATIC_DEPLOYER.id) + .addStoryBoard("schematic_deployer/intro", { scene, util -> DeployerScenes.schematicDeployerIntro(scene, util) }) + .addStoryBoard("schematic_deployer/automation", { scene, util -> DeployerScenes.selfPopulatingBases(scene, util) }) + + helper.forComponents(AllItems.PROGRAMMED_SCHEMATIC.id) + .addStoryBoard("schematic_deployer/intro", { scene, util -> DeployerScenes.schematicDeployerIntro(scene, util) }) + } override fun registerTags(helper: PonderTagRegistrationHelper) { diff --git a/src/main/kotlin/de/devin/cbbees/ponder/DeployerScenes.kt b/src/main/kotlin/de/devin/cbbees/ponder/DeployerScenes.kt new file mode 100644 index 0000000..c1dfd80 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/ponder/DeployerScenes.kt @@ -0,0 +1,203 @@ +package de.devin.cbbees.ponder + +import com.simibubi.create.foundation.ponder.CreateSceneBuilder +import de.devin.cbbees.content.deployer.SchematicDeployerBlock +import de.devin.cbbees.items.AllItems +import net.createmod.catnip.math.Pointing +import net.createmod.ponder.api.PonderPalette +import net.createmod.ponder.api.scene.SceneBuilder +import net.createmod.ponder.api.scene.SceneBuildingUtil +import net.minecraft.core.Direction +import net.minecraft.world.item.ItemStack +import net.minecraft.world.level.block.state.BlockState +import net.minecraft.world.phys.AABB + +object DeployerScenes { + + /** + * Ponder scene: Schematic Deployer introduction. + * + * NBT structure required at `ponder/schematic_deployer/intro.nbt`: + * - 5x5x5 base plate + * - (2,1,2): Schematic Deployer (facing north) + * - (3,1,2): Lever (attached to side of deployer) + * - (4,1,2): Mechanical Beehive + * - (4,1,3): Shaft (providing rotation) + */ + fun schematicDeployerIntro(builder: SceneBuilder, util: SceneBuildingUtil) { + val scene = CreateSceneBuilder(builder) + + scene.title("schematic_deployer", "The Schematic Deployer") + scene.configureBasePlate(0, 0, 5) + scene.showBasePlate() + scene.idle(5) + + val deployerPos = util.grid().at(2, 1, 2) + val leverPos = util.grid().at(3, 1, 2) + val hivePos = util.grid().at(4, 1, 2) + val shaftPos = util.grid().at(4, 1, 3) + + // Show the deployer + scene.world().showSection(util.select().position(deployerPos), Direction.DOWN) + scene.idle(10) + + scene.overlay().showText(80) + .text("The Schematic Deployer automates construction and deconstruction jobs using programmed schematics.") + .placeNearTarget() + .pointAt(util.vector().blockSurface(deployerPos, Direction.WEST)) + + scene.idle(100) + + // Show inserting a programmed schematic + val programmedSchematic = ItemStack(AllItems.PROGRAMMED_SCHEMATIC.get()) + scene.overlay().showControls(util.vector().blockSurface(deployerPos, Direction.UP), Pointing.DOWN, 60) + .withItem(programmedSchematic) + .rightClick() + + scene.overlay().showText(80) + .text("Right-click with a Programmed Schematic to insert it. Create one using the Program tool in the Construction or Deconstruction Planner.") + .placeNearTarget() + .attachKeyFrame() + .pointAt(util.vector().blockSurface(deployerPos, Direction.WEST)) + + scene.idle(100) + + // Show the lever + scene.world().showSection(util.select().position(leverPos), Direction.DOWN) + scene.idle(10) + + scene.overlay().showText(80) + .text("Apply a redstone signal to deploy the programmed job to nearby beehives.") + .placeNearTarget() + .attachKeyFrame() + .pointAt(util.vector().blockSurface(leverPos, Direction.WEST)) + + scene.idle(60) + + // Activate — show powered state + scene.world().modifyBlock(deployerPos, { state -> + state.setValue(SchematicDeployerBlock.POWERED, true) as BlockState + }, false) + + scene.idle(20) + + // Show beehive + shaft + scene.world().showSection(util.select().position(hivePos), Direction.DOWN) + scene.world().showSection(util.select().position(shaftPos), Direction.DOWN) + scene.world().setKineticSpeed(util.select().position(hivePos), 64f) + scene.world().setKineticSpeed(util.select().position(shaftPos), 64f) + + scene.idle(10) + + scene.overlay().showText(80) + .text("Nearby Mechanical Beehives will pick up the job and dispatch bees to complete it.") + .placeNearTarget() + .attachKeyFrame() + .pointAt(util.vector().blockSurface(hivePos, Direction.WEST)) + + scene.idle(100) + + // Comparator output + scene.overlay().showText(80) + .text("A comparator reads the deployer's state: empty (0), loaded (1), job active (8), or just deployed (15).") + .placeNearTarget() + .attachKeyFrame() + .pointAt(util.vector().blockSurface(deployerPos, Direction.SOUTH)) + + scene.idle(100) + + // Automation + scene.overlay().showText(80) + .text("Shift+right-click to extract the schematic. Hoppers and pipes can also insert and extract for full automation.") + .placeNearTarget() + .attachKeyFrame() + .pointAt(util.vector().blockSurface(deployerPos, Direction.UP)) + + scene.idle(100) + + scene.markAsFinished() + } + + /** + * Ponder scene: Deploy modes — Absolute vs Relative. + * + * NBT structure required at `ponder/schematic_deployer/automation.nbt`: + * - 5x3x5 base plate + * - (1,1,2): Schematic Deployer (facing north) — "absolute" example + * - (3,1,2): Schematic Deployer (facing north) — "relative" example + */ + fun selfPopulatingBases(builder: SceneBuilder, util: SceneBuildingUtil) { + val scene = CreateSceneBuilder(builder) + + scene.title("schematic_deployer_automation", "Deploy Modes") + scene.configureBasePlate(0, 0, 5) + scene.showBasePlate() + scene.idle(5) + + val absolutePos = util.grid().at(1, 1, 2) + val relativePos = util.grid().at(3, 1, 2) + + // Show both deployers + scene.world().showSection(util.select().position(absolutePos), Direction.DOWN) + scene.world().showSection(util.select().position(relativePos), Direction.DOWN) + scene.idle(10) + + scene.overlay().showText(80) + .text("The Schematic Deployer supports two deploy modes: Absolute and Relative. Right-click to open the settings GUI.") + .placeNearTarget() + .pointAt(util.vector().blockSurface(absolutePos, Direction.UP)) + + scene.idle(100) + + // Absolute mode + scene.overlay().showOutlineWithText(util.select().position(absolutePos), 100) + .text("In Absolute mode, the schematic is built at the exact coordinates stored when it was programmed.") + .placeNearTarget() + .attachKeyFrame() + .colored(PonderPalette.BLUE) + .pointAt(util.vector().blockSurface(absolutePos, Direction.WEST)) + + scene.idle(120) + + val absoluteTarget = AABB(-1.0, 1.0, 0.0, 1.0, 3.0, 2.0) + scene.overlay().chaseBoundingBoxOutline(PonderPalette.BLUE, "abs", absoluteTarget, 100) + + scene.overlay().showText(80) + .text("The build always happens at the same world position, no matter where the deployer is placed.") + .placeNearTarget() + .pointAt(util.vector().blockSurface(absolutePos, Direction.WEST)) + + scene.idle(100) + + // Relative mode + scene.overlay().showOutlineWithText(util.select().position(relativePos), 100) + .text("In Relative mode, the build target is calculated as an offset from the deployer's position.") + .placeNearTarget() + .attachKeyFrame() + .colored(PonderPalette.GREEN) + .pointAt(util.vector().blockSurface(relativePos, Direction.EAST)) + + scene.idle(120) + + val relativeTarget = AABB(3.0, 1.0, 3.0, 5.0, 3.0, 5.0) + scene.overlay().chaseBoundingBoxOutline(PonderPalette.GREEN, "rel", relativeTarget, 100) + + scene.overlay().showText(80) + .text("Move the deployer and the build follows — great for repeatable, portable setups.") + .placeNearTarget() + .pointAt(util.vector().blockSurface(relativePos, Direction.EAST)) + + scene.idle(100) + + // Rotation/Mirror + scene.overlay().showText(80) + .text("Relative mode also lets you override rotation and mirror settings from the GUI, without reprogramming the schematic.") + .placeNearTarget() + .attachKeyFrame() + .pointAt(util.vector().blockSurface(relativePos, Direction.UP)) + + scene.idle(100) + + scene.markAsFinished() + } +} diff --git a/src/main/kotlin/de/devin/cbbees/registry/AllBlockEntityTypes.kt b/src/main/kotlin/de/devin/cbbees/registry/AllBlockEntityTypes.kt index 810abfe..87a85ae 100644 --- a/src/main/kotlin/de/devin/cbbees/registry/AllBlockEntityTypes.kt +++ b/src/main/kotlin/de/devin/cbbees/registry/AllBlockEntityTypes.kt @@ -8,6 +8,8 @@ import de.devin.cbbees.CreateBuzzyBeez import de.devin.cbbees.blocks.AllBlocks import de.devin.cbbees.content.beehive.MechanicalBeehiveBlockEntity import de.devin.cbbees.content.beehive.MechanicalBeehiveRenderer +import de.devin.cbbees.content.deployer.SchematicDeployerBlockEntity +import de.devin.cbbees.content.deployer.client.SchematicDeployerRenderer import de.devin.cbbees.content.logistics.ports.LogisticPortBlockEntity import de.devin.cbbees.content.logistics.ports.LogisticsPortRenderer import de.devin.cbbees.content.logistics.transport.TransportPortBlockEntity @@ -28,6 +30,12 @@ object AllBlockEntityTypes { .renderer { NonNullFunction { LogisticsPortRenderer(it) } } .register() + val SCHEMATIC_DEPLOYER: BlockEntityEntry = CreateBuzzyBeez.REGISTRATE + .blockEntity("schematic_deployer", ::SchematicDeployerBlockEntity) + .validBlocks(AllBlocks.SCHEMATIC_DEPLOYER) + .renderer { NonNullFunction { SchematicDeployerRenderer(it) } } + .register() + val TRANSPORT_PORT: BlockEntityEntry = CreateBuzzyBeez.REGISTRATE .blockEntity("transport_port", ::TransportPortBlockEntity) .validBlocks(AllBlocks.CARGO_PORT) diff --git a/src/main/kotlin/de/devin/cbbees/registry/AllDataComponents.kt b/src/main/kotlin/de/devin/cbbees/registry/AllDataComponents.kt index 305c203..290ea12 100644 --- a/src/main/kotlin/de/devin/cbbees/registry/AllDataComponents.kt +++ b/src/main/kotlin/de/devin/cbbees/registry/AllDataComponents.kt @@ -1,6 +1,7 @@ package de.devin.cbbees.registry import de.devin.cbbees.CreateBuzzyBeez +import de.devin.cbbees.content.deployer.SchematicProgram import de.devin.cbbees.content.upgrades.UpgradeGrid import net.minecraft.core.component.DataComponentType import net.minecraft.core.registries.Registries @@ -28,6 +29,12 @@ object AllDataComponents { .networkSynchronized(UpgradeGrid.STREAM_CODEC) } + val SCHEMATIC_PROGRAM: DeferredHolder, DataComponentType> = + REGISTER.registerComponentType("schematic_program") { builder -> + builder.persistent(SchematicProgram.CODEC) + .networkSynchronized(SchematicProgram.STREAM_CODEC) + } + fun register(bus: IEventBus) { REGISTER.register(bus) } diff --git a/src/main/kotlin/de/devin/cbbees/registry/AllEntityTypes.kt b/src/main/kotlin/de/devin/cbbees/registry/AllEntityTypes.kt index 45d04f3..e2a11db 100644 --- a/src/main/kotlin/de/devin/cbbees/registry/AllEntityTypes.kt +++ b/src/main/kotlin/de/devin/cbbees/registry/AllEntityTypes.kt @@ -13,14 +13,14 @@ object AllEntityTypes { val MECHANICAL_BEE: EntityEntry = CreateBuzzyBeez.REGISTRATE .entity("mechanical_bee", ::MechanicalBeeEntity, MobCategory.MISC) - .properties { b -> b.sized(0.5f, 0.5f).fireImmune() } + .properties { b -> b.sized(0.5f, 0.5f).fireImmune().clientTrackingRange(8).updateInterval(5) } .attributes { MechanicalBeeEntity.createAttributes() } .renderer { NonNullFunction { MechanicalBeeRenderer(it) } } .register() val MECHANICAL_BUMBLE_BEE: EntityEntry = CreateBuzzyBeez.REGISTRATE .entity("mechanical_bumble_bee", ::MechanicalBumbleBeeEntity, MobCategory.MISC) - .properties { b -> b.sized(0.5f, 0.5f).fireImmune() } + .properties { b -> b.sized(0.5f, 0.5f).fireImmune().clientTrackingRange(8).updateInterval(5) } .attributes { MechanicalBumbleBeeEntity.createAttributes() } .renderer { NonNullFunction { MechanicalBumbleBeeRenderer(it) } } .register() diff --git a/src/main/kotlin/de/devin/cbbees/registry/AllKeys.kt b/src/main/kotlin/de/devin/cbbees/registry/AllKeys.kt index 54b3d34..254d582 100644 --- a/src/main/kotlin/de/devin/cbbees/registry/AllKeys.kt +++ b/src/main/kotlin/de/devin/cbbees/registry/AllKeys.kt @@ -23,7 +23,7 @@ object AllKeys { ) /** - * Keybinding to stop all robot tasks. + * Keybinding to stop all bee tasks. * Default key: BACKSPACE */ val STOP_ACTION: KeyMapping = KeyMapping( @@ -66,6 +66,29 @@ object AllKeys { "key.categories.${CreateBuzzyBeez.ID}" ) + /** + * Keybinding to program a schematic into a Programmed Schematic item. + * Default key: P + */ + val PROGRAM_ACTION: KeyMapping = KeyMapping( + "key.${CreateBuzzyBeez.ID}.program_action", + InputConstants.Type.KEYSYM, + GLFW.GLFW_KEY_P, + "key.categories.${CreateBuzzyBeez.ID}" + ) + + /** + * Modifier key for schematic browsing — hold to scroll schematics in the HUD, + * toggle deconstruction mode, etc. Rebindable in Controls settings. + * Default key: LEFT ALT + */ + val SCHEMATIC_MODIFIER: KeyMapping = KeyMapping( + "key.${CreateBuzzyBeez.ID}.schematic_modifier", + InputConstants.Type.KEYSYM, + GLFW.GLFW_KEY_LEFT_ALT, + "key.categories.${CreateBuzzyBeez.ID}" + ) + /** * Keybinding to toggle drone view. * Default key: V @@ -88,6 +111,8 @@ object AllKeys { event.register(ROTATE_PREVIEW) event.register(MIRROR_PREVIEW) event.register(DRONE_VIEW) + event.register(PROGRAM_ACTION) + event.register(SCHEMATIC_MODIFIER) } } diff --git a/src/main/kotlin/de/devin/cbbees/util/ServerTickScheduler.kt b/src/main/kotlin/de/devin/cbbees/util/ServerTickScheduler.kt new file mode 100644 index 0000000..2d2fd05 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/util/ServerTickScheduler.kt @@ -0,0 +1,48 @@ +package de.devin.cbbees.util + +import de.devin.cbbees.CreateBuzzyBeez + +/** + * Defers callbacks to the *next* server tick, unlike [net.minecraft.server.MinecraftServer.execute] + * which drains its queue inside the current tick (so chained `execute { execute { ... } }` calls + * all run back-to-back without yielding). + * + * Used to spread long-running calculations across server ticks without blocking. Each call to + * [nextTick] adds a single continuation; [runScheduled] (called once per server tick by + * `CCRServerEvents.onServerTick`) snapshots and drains the current list. Anything scheduled + * *during* a callback lands in a fresh list and runs on the following tick. + */ +@ServerSide +object ServerTickScheduler { + + private val pending = mutableListOf<() -> Unit>() + + /** Schedule [callback] to run on the next server tick. Thread-safe. */ + fun nextTick(callback: () -> Unit) { + synchronized(pending) { + pending.add(callback) + } + } + + /** Drains all currently-pending callbacks exactly once. Called from the server tick event. */ + fun runScheduled() { + val toRun = synchronized(pending) { + if (pending.isEmpty()) return + val copy = pending.toList() + pending.clear() + copy + } + for (runnable in toRun) { + try { + runnable.invoke() + } catch (t: Throwable) { + CreateBuzzyBeez.LOGGER.error("ServerTickScheduler task failed", t) + } + } + } + + /** Clears all pending tasks. Called on server stop. */ + fun clear() { + synchronized(pending) { pending.clear() } + } +} diff --git a/src/main/resources/assets/cbbees/blockstates/schematic_deployer.json b/src/main/resources/assets/cbbees/blockstates/schematic_deployer.json new file mode 100644 index 0000000..37f331f --- /dev/null +++ b/src/main/resources/assets/cbbees/blockstates/schematic_deployer.json @@ -0,0 +1,12 @@ +{ + "variants": { + "facing=north,powered=false": { "model": "cbbees:block/schematic_deployer" }, + "facing=south,powered=false": { "model": "cbbees:block/schematic_deployer", "y": 180 }, + "facing=west,powered=false": { "model": "cbbees:block/schematic_deployer", "y": 270 }, + "facing=east,powered=false": { "model": "cbbees:block/schematic_deployer", "y": 90 }, + "facing=north,powered=true": { "model": "cbbees:block/schematic_deployer_powered" }, + "facing=south,powered=true": { "model": "cbbees:block/schematic_deployer_powered", "y": 180 }, + "facing=west,powered=true": { "model": "cbbees:block/schematic_deployer_powered", "y": 270 }, + "facing=east,powered=true": { "model": "cbbees:block/schematic_deployer_powered", "y": 90 } + } +} diff --git a/src/main/resources/assets/cbbees/geo/mechanical_bee.geo.json b/src/main/resources/assets/cbbees/geo/mechanical_bee.geo.json index b75d2fe..c41eab7 100644 --- a/src/main/resources/assets/cbbees/geo/mechanical_bee.geo.json +++ b/src/main/resources/assets/cbbees/geo/mechanical_bee.geo.json @@ -3,158 +3,47 @@ "minecraft:geometry": [ { "description": { - "identifier": "geometry.cogwheel - Converted", + "identifier": "geometry.unknown", "texture_width": 64, "texture_height": 64, - "visible_bounds_width": 2, + "visible_bounds_width": 3, "visible_bounds_height": 2.5, "visible_bounds_offset": [0, 0.75, 0] }, "bones": [ { - "name": "bee", - "pivot": [0, 8, 0] - }, - { - "name": "gear2", - "parent": "bee", - "pivot": [0.6, 6.83433, 0.13248], + "name": "bb_main", + "pivot": [0, 0, 0], "cubes": [ - { - "origin": [-2.472, 6.09193, -2.93952], - "size": [6.144, 1.4848, 6.144], - "pivot": [0.6, 6.83433, 0.13248], - "rotation": [0, 0, 90], - "uv": { - "north": {"uv": [0, 12], "uv_size": [12, 3]}, - "east": {"uv": [0, 12], "uv_size": [12, 3]}, - "south": {"uv": [0, 12], "uv_size": [12, 3]}, - "west": {"uv": [0, 12], "uv_size": [12, 3]}, - "up": {"uv": [20, 12], "uv_size": [-12, -12]}, - "down": {"uv": [20, 12], "uv_size": [-12, -12]} - } - }, - { - "origin": [-1.448, 5.81033, -1.91552], - "size": [4.096, 2.048, 4.096], - "pivot": [0.6, 6.83433, 0.13248], - "rotation": [0, 0, 90], - "uv": { - "north": {"uv": [0, 8], "uv_size": [8, 4]}, - "east": {"uv": [0, 8], "uv_size": [8, 4]}, - "south": {"uv": [0, 8], "uv_size": [8, 4]}, - "west": {"uv": [0, 8], "uv_size": [8, 4]}, - "up": {"uv": [8, 8], "uv_size": [-8, -8]}, - "down": {"uv": [8, 8], "uv_size": [-8, -8]} - } - }, - { - "origin": [-4.008, 6.06633, -0.63552], - "size": [9.216, 1.536, 1.536], - "pivot": [0.6, 6.83433, 0.13248], - "rotation": [0, 0, 90], - "uv": { - "north": {"uv": [14, 16], "uv_size": [18, 3]}, - "east": {"uv": [10, 16], "uv_size": [3, 3]}, - "south": {"uv": [14, 16], "uv_size": [18, 3]}, - "west": {"uv": [10, 16], "uv_size": [3, 3]}, - "up": {"uv": [32, 15], "uv_size": [-18, -3]}, - "down": {"uv": [32, 15], "uv_size": [-18, -3]} - } - }, - { - "origin": [-4.008, 6.06633, -0.63552], - "size": [9.216, 1.536, 1.536], - "pivot": [0.6, 6.83433, 0.13248], - "rotation": [0, -45, 90], - "uv": { - "north": {"uv": [14, 16], "uv_size": [18, 3]}, - "east": {"uv": [10, 16], "uv_size": [3, 3]}, - "south": {"uv": [14, 16], "uv_size": [18, 3]}, - "west": {"uv": [10, 16], "uv_size": [3, 3]}, - "up": {"uv": [32, 15], "uv_size": [-18, -3]}, - "down": {"uv": [32, 15], "uv_size": [-18, -3]} - } - }, - { - "origin": [-4.008, 6.06633, -0.63552], - "size": [9.216, 1.536, 1.536], - "pivot": [0.6, 6.83433, 0.13248], - "rotation": [0, 45, 90], - "uv": { - "north": {"uv": [14, 16], "uv_size": [18, 3]}, - "east": {"uv": [10, 16], "uv_size": [3, 3]}, - "south": {"uv": [14, 16], "uv_size": [18, 3]}, - "west": {"uv": [10, 16], "uv_size": [3, 3]}, - "up": {"uv": [32, 15], "uv_size": [-18, -3]}, - "down": {"uv": [32, 15], "uv_size": [-18, -3]} - } - }, - { - "origin": [-0.168, 6.06633, -4.47552], - "size": [1.536, 1.536, 9.216], - "pivot": [0.6, 6.83433, 0.13248], - "rotation": [0, 0, 90], - "uv": { - "north": {"uv": [10, 16], "uv_size": [3, 3]}, - "east": {"uv": [14, 16], "uv_size": [18, 3]}, - "south": {"uv": [10, 16], "uv_size": [3, 3]}, - "west": {"uv": [14, 16], "uv_size": [18, 3]}, - "up": {"uv": [32, 15], "uv_size": [-18, -3], "uv_rotation": 90}, - "down": {"uv": [32, 15], "uv_size": [-18, -3], "uv_rotation": 90} - } - } + {"origin": [-4, 3, -5.1], "size": [8, 3, 1], "uv": [0, 37]}, + {"origin": [-1.5, 7, -8], "size": [0, 2, 3], "uv": [30, 26]}, + {"origin": [1.5, 7, -8], "size": [0, 2, 3], "uv": [30, 21]}, + {"origin": [-3.5, 2, -5], "size": [7, 7, 10], "uv": [0, 0]}, + {"origin": [-3.5, 0, -2], "size": [7, 2, 0], "uv": [30, 19]}, + {"origin": [-3.5, 0, 0], "size": [7, 2, 0], "uv": [30, 17]}, + {"origin": [-3.5, 0, 2], "size": [7, 2, 0], "uv": [16, 29]} ] }, { - "name": "bone", - "parent": "bee", - "pivot": [8, 0, -8], + "name": "rwing", + "pivot": [-3, 7, -2], "cubes": [ - {"origin": [-3, 2, -5], "size": [7, 7, 10], "uv": [0, 19]}, - {"origin": [0, 5, 5], "size": [1, 1, 2], "pivot": [2, 5, 5], "rotation": [22.5, 0, 0], "uv": [0, 16]} + {"origin": [-11, 9, -3], "size": [9, 0, 6], "uv": [0, 23]} ] }, { - "name": "leftwing_bone", - "parent": "bee", - "pivot": [2, 9, -1], + "name": "lwing", + "pivot": [-3, 7, -2], "cubes": [ - { - "origin": [2.3, 9.05, -4], - "size": [6, 0.1, 5], - "pivot": [2.3, 8.15, -1], - "rotation": [0, 0, -22.5], - "uv": { - "north": {"uv": [25, 28], "uv_size": [6, 0]}, - "east": {"uv": [20, 28], "uv_size": [5, 0]}, - "south": {"uv": [36, 28], "uv_size": [6, 0]}, - "west": {"uv": [31, 28], "uv_size": [5, 0]}, - "up": {"uv": [25, 23], "uv_size": [4, 6]}, - "down": {"uv": [25, 29], "uv_size": [4, -6]} - } - } + {"origin": [2, 9, -3], "size": [9, 0, 6], "uv": [0, 17]} ] }, { - "name": "rightwing_bone", - "parent": "bee", - "pivot": [-1, 9, -1], + "name": "lights", + "pivot": [0, 0, 0], "cubes": [ - { - "origin": [-7, 9, -4], - "size": [6, 0.1, 5], - "pivot": [-1, 9.1, -1.5], - "rotation": [0, 0, 22.5], - "uv": { - "north": {"uv": [26.75, 26.5], "uv_size": [-1.5, -1]}, - "east": {"uv": [26, 26.5], "uv_size": [-1.25, 1]}, - "south": {"uv": [26, 26.5], "uv_size": [-1.5, 1]}, - "west": {"uv": [27.25, 25.5], "uv_size": [-1.25, 1]}, - "up": {"uv": [29, 23], "uv_size": [-4, 6], "uv_rotation": 270}, - "down": {"uv": [29, 29], "uv_size": [-4, -6], "uv_rotation": 90} - } - } + {"origin": [0, 3, 5], "size": [0, 5, 4], "pivot": [0, 5.5, 7], "rotation": [0, 0, 90], "uv": [0, 28]}, + {"origin": [0, 3, 5], "size": [0, 5, 4], "uv": [0, 28]} ] } ] diff --git a/src/main/resources/assets/cbbees/geo/mechanical_bumble_bee.geo.json b/src/main/resources/assets/cbbees/geo/mechanical_bumble_bee.geo.json index 63dbe32..c0867dc 100644 --- a/src/main/resources/assets/cbbees/geo/mechanical_bumble_bee.geo.json +++ b/src/main/resources/assets/cbbees/geo/mechanical_bumble_bee.geo.json @@ -3,179 +3,46 @@ "minecraft:geometry": [ { "description": { - "identifier": "geometry.cogwheel - Converted", + "identifier": "geometry.unknown", "texture_width": 64, "texture_height": 64, - "visible_bounds_width": 2, + "visible_bounds_width": 3, "visible_bounds_height": 2.5, "visible_bounds_offset": [0, 0.75, 0] }, "bones": [ { - "name": "bee", - "pivot": [0, 8, 0] - }, - { - "name": "gear2", - "parent": "bee", - "pivot": [0.6, 6.83433, 0.13248], - "cubes": [ - { - "origin": [-2.472, 6.09193, -2.93952], - "size": [6.144, 1.4848, 6.144], - "pivot": [0.6, 6.83433, 0.13248], - "rotation": [0, 0, 90], - "uv": { - "north": {"uv": [0, 12], "uv_size": [12, 3]}, - "east": {"uv": [0, 12], "uv_size": [12, 3]}, - "south": {"uv": [0, 12], "uv_size": [12, 3]}, - "west": {"uv": [0, 12], "uv_size": [12, 3]}, - "up": {"uv": [20, 12], "uv_size": [-12, -12]}, - "down": {"uv": [20, 12], "uv_size": [-12, -12]} - } - }, - { - "origin": [-1.448, 5.81033, -1.91552], - "size": [4.096, 2.048, 4.096], - "pivot": [0.6, 6.83433, 0.13248], - "rotation": [0, 0, 90], - "uv": { - "north": {"uv": [0, 8], "uv_size": [8, 4]}, - "east": {"uv": [0, 8], "uv_size": [8, 4]}, - "south": {"uv": [0, 8], "uv_size": [8, 4]}, - "west": {"uv": [0, 8], "uv_size": [8, 4]}, - "up": {"uv": [8, 8], "uv_size": [-8, -8]}, - "down": {"uv": [8, 8], "uv_size": [-8, -8]} - } - }, - { - "origin": [-4.008, 6.06633, -0.63552], - "size": [9.216, 1.536, 1.536], - "pivot": [0.6, 6.83433, 0.13248], - "rotation": [0, 0, 90], - "uv": { - "north": {"uv": [14, 16], "uv_size": [18, 3]}, - "east": {"uv": [10, 16], "uv_size": [3, 3]}, - "south": {"uv": [14, 16], "uv_size": [18, 3]}, - "west": {"uv": [10, 16], "uv_size": [3, 3]}, - "up": {"uv": [32, 15], "uv_size": [-18, -3]}, - "down": {"uv": [32, 15], "uv_size": [-18, -3]} - } - }, - { - "origin": [-4.008, 6.06633, -0.63552], - "size": [9.216, 1.536, 1.536], - "pivot": [0.6, 6.83433, 0.13248], - "rotation": [0, -45, 90], - "uv": { - "north": {"uv": [14, 16], "uv_size": [18, 3]}, - "east": {"uv": [10, 16], "uv_size": [3, 3]}, - "south": {"uv": [14, 16], "uv_size": [18, 3]}, - "west": {"uv": [10, 16], "uv_size": [3, 3]}, - "up": {"uv": [32, 15], "uv_size": [-18, -3]}, - "down": {"uv": [32, 15], "uv_size": [-18, -3]} - } - }, - { - "origin": [-4.008, 6.06633, -0.63552], - "size": [9.216, 1.536, 1.536], - "pivot": [0.6, 6.83433, 0.13248], - "rotation": [0, 45, 90], - "uv": { - "north": {"uv": [14, 16], "uv_size": [18, 3]}, - "east": {"uv": [10, 16], "uv_size": [3, 3]}, - "south": {"uv": [14, 16], "uv_size": [18, 3]}, - "west": {"uv": [10, 16], "uv_size": [3, 3]}, - "up": {"uv": [32, 15], "uv_size": [-18, -3]}, - "down": {"uv": [32, 15], "uv_size": [-18, -3]} - } - }, - { - "origin": [-0.168, 6.06633, -4.47552], - "size": [1.536, 1.536, 9.216], - "pivot": [0.6, 6.83433, 0.13248], - "rotation": [0, 0, 90], - "uv": { - "north": {"uv": [10, 16], "uv_size": [3, 3]}, - "east": {"uv": [14, 16], "uv_size": [18, 3]}, - "south": {"uv": [10, 16], "uv_size": [3, 3]}, - "west": {"uv": [14, 16], "uv_size": [18, 3]}, - "up": {"uv": [32, 15], "uv_size": [-18, -3], "uv_rotation": 90}, - "down": {"uv": [32, 15], "uv_size": [-18, -3], "uv_rotation": 90} - } - } - ] - }, - { - "name": "bone", - "parent": "bee", - "pivot": [8, 0, -8], + "name": "bb_main", + "pivot": [0, 0, 0], "cubes": [ - { - "origin": [-3, 2, -5], - "size": [7, 7, 10], - "uv": { - "north": {"uv": [10, 29], "uv_size": [7, 7]}, - "east": {"uv": [0, 29], "uv_size": [10, 7]}, - "south": {"uv": [27, 29], "uv_size": [7, 7]}, - "west": {"uv": [17, 29], "uv_size": [10, 7]}, - "up": {"uv": [10, 19], "uv_size": [7, 10]}, - "down": {"uv": [17, 29], "uv_size": [7, -10]} - } - }, - {"origin": [0, 5, 5], "size": [1, 1, 2], "pivot": [2, 5, 5], "rotation": [22.5, 0, 0], "uv": [0, 16]} + {"origin": [-1.5, 7, -8], "size": [0, 2, 3], "uv": [24, -3]}, + {"origin": [1.5, 7, -8], "size": [0, 2, 3], "uv": [24, -3]}, + {"origin": [-3.5, 2, -5], "size": [7, 7, 10], "uv": [0, 0]}, + {"origin": [-3.5, 0, -2], "size": [7, 2, 0], "uv": [30, 28]}, + {"origin": [-3.5, 0, 0], "size": [7, 2, 0], "uv": [30, 30]}, + {"origin": [-3.5, 0, 2], "size": [7, 2, 0], "uv": [30, 32]}, + {"origin": [-4, 3, -5.1], "size": [8, 3, 1], "uv": [0, 29]} ] }, { - "name": "leftwing_bone", - "parent": "bee", - "pivot": [2, 9, -1], + "name": "light", + "pivot": [4, 2, 6], "cubes": [ - { - "origin": [2.3, 9.05, -3], - "size": [5, 0.1, 5], - "pivot": [2.3, 8.15, 0], - "rotation": [0, 0, -22.5], - "uv": { - "north": {"uv": [25, 28], "uv_size": [6, 0]}, - "east": {"uv": [20, 28], "uv_size": [5, 0]}, - "south": {"uv": [36, 28], "uv_size": [6, 0]}, - "west": {"uv": [31, 28], "uv_size": [5, 0]}, - "up": {"uv": [25, 23], "uv_size": [4, 6]}, - "down": {"uv": [25, 29], "uv_size": [4, -6]} - } - } + {"origin": [-2, 3.5, 5], "size": [4, 4, 4], "uv": [15, 17]} ] }, { - "name": "rightwing_bone", - "parent": "bee", - "pivot": [-1, 9, -1], + "name": "rwing", + "pivot": [-3, 7, -2], "cubes": [ - { - "origin": [-6, 9, -3], - "size": [5, 0.1, 5], - "pivot": [-1, 9.1, -0.5], - "rotation": [0, 0, 22.5], - "uv": { - "north": {"uv": [26.75, 26.5], "uv_size": [-1.5, -1]}, - "east": {"uv": [26, 26.5], "uv_size": [-1.25, 1]}, - "south": {"uv": [26, 26.5], "uv_size": [-1.5, 1]}, - "west": {"uv": [27.25, 25.5], "uv_size": [-1.25, 1]}, - "up": {"uv": [29, 23], "uv_size": [-4, 6], "uv_rotation": 270}, - "down": {"uv": [29, 29], "uv_size": [-4, -6], "uv_rotation": 90} - } - } + {"origin": [-11, 9, -3], "size": [9, 0, 6], "uv": [4, 35], "mirror": true} ] }, { - "name": "legs", - "pivot": [3.75, 0.5, 3.25], + "name": "lwing", + "pivot": [-3, 7, -2], "cubes": [ - {"origin": [2.75, 1, -3], "size": [1, 2, 1], "pivot": [2.75, 1, -2], "rotation": [37.5, 0, 0], "uv": [0, 15]}, - {"origin": [-2.75, 1, -3], "size": [1, 2, 1], "pivot": [-2.75, 1, -2], "rotation": [37.5, 0, 0], "uv": [0, 15]}, - {"origin": [-2.75, 0.5, 2.25], "size": [1, 2, 1], "pivot": [-2.75, 0.5, 3.25], "rotation": [-37.5, 0, 0], "uv": [0, 15]}, - {"origin": [2.75, 0.5, 2.25], "size": [1, 2, 1], "pivot": [3.75, 0.5, 3.25], "rotation": [-37.5, 0, 0], "uv": [0, 15]} + {"origin": [2, 9, -3], "size": [9, 0, 6], "uv": [4, 35]} ] } ] diff --git a/src/main/resources/assets/cbbees/lang/en_us.json b/src/main/resources/assets/cbbees/lang/en_us.json index 5602f66..0bf2de0 100644 --- a/src/main/resources/assets/cbbees/lang/en_us.json +++ b/src/main/resources/assets/cbbees/lang/en_us.json @@ -102,9 +102,11 @@ "cbbees.construction.no_bees": "No bees in hive!", "cbbees.construction.no_air": "Swarm stalled: Not enough honey!", "cbbees.construction.missing_items": "Swarm stalled: Missing required items!", - "cbbees.construction.started": "Swarm deployed with %d tasks!", + "cbbees.construction.started": "Construction started", "cbbees.construction.no_tasks": "No blocks to place in schematic!", "cbbees.construction.load_failed": "Failed to load schematic!", + "cbbees.progress.processing_schematic": "Processing schematic...", + "cbbees.progress.processing_area": "Processing area...", "cbbees.deconstruction.title": "Stinger Planner", "cbbees.deconstruction.start": "Start Deconstruction", "cbbees.deconstruction.area_info": "Area: %dx%dx%d (%d blocks)", @@ -115,8 +117,12 @@ "cbbees.deconstruction.no_target": "No block targeted", "cbbees.deconstruction.abort": "Selection cancelled", "cbbees.deconstruction.already_active": "This area is already being deconstructed!", - "cbbees.deconstruction.started": "Deconstruction swarm deployed with %d blocks!", + "cbbees.deconstruction.started": "Deconstruction started", "cbbees.deconstruction.no_blocks": "No blocks to remove in selected area!", + "cbbees.pickup.started": "Item pickup started", + "cbbees.pickup.no_items": "No loose items found in selected area!", + "cbbees.progress.scanning_items": "Scanning for items...", + "cbbees.program.pickup": "Item Pickup", "key.categories.cbbees": "Buzzy Bees", "key.cbbees.start_action": "Deconstruct", "key.cbbees.stop_action": "Recall All Bees", @@ -221,6 +227,7 @@ "gui.cbbees.deconstruction.hint_select": "[RMB] Set corner | [Shift+RMB] Cancel", "gui.cbbees.deconstruction.hint_ready": "[%s] Deploy | [Shift+RMB] Cancel", "gui.cbbees.deconstruction.hint_scroll": "[Ctrl+Scroll] Resize selection", + "gui.cbbees.deconstruction.hint_program": "[%s] Program into item", "tooltip.cbbees.beehive.honey": "Honey: %d/%d", "cbbees.beehive.honey_loaded": "Loaded honey (%d/%d)", "cbbees.beehive.honey_full": "Honey tank is full!", @@ -335,10 +342,51 @@ "item.cbbees.drone_range": "Drone Range", "tooltip.cbbees.upgrade.drone_range": "+16 blocks drone camera range", "cbbees.drone_view.hud.range": "Range: %s / %s blocks", - "cbbees.drone_view.hud.controls": "WASD to fly | V to exit", + "cbbees.drone_view.hud.controls": "WASD to fly | Shift+WASD to nudge | V to exit", "item.cbbees.drone_range.tooltip": "Drone Range Upgrade", "item.cbbees.drone_range.tooltip.summary": "Extends the maximum range of the _Drone View_ camera by _16 blocks_ per upgrade. Allows scouting further from the player.", "item.cbbees.drone_range.tooltip.condition1": "Max Stack", - "item.cbbees.drone_range.tooltip.behaviour1": "Up to _3_ can be installed in a single beehive." + "item.cbbees.drone_range.tooltip.behaviour1": "Up to _3_ can be installed in a single beehive.", + + "item.cbbees.programmed_schematic": "Programmed Schematic", + "block.cbbees.schematic_deployer": "Schematic Deployer", + "cbbees.program.construction": "Construction: %s", + "cbbees.program.deconstruction": "Deconstruction", + "cbbees.program.anchor": "Anchor: %d, %d, %d", + "cbbees.program.rotation": "Rotation: %s", + "cbbees.program.mirror": "Mirror: %s", + "cbbees.program.corner1": "Corner 1: %d, %d, %d", + "cbbees.program.corner2": "Corner 2: %d, %d, %d", + "cbbees.program.dimensions": "Size: %dx%dx%d", + "cbbees.schematic.programmed": "Schematic programmed!", + "cbbees.deployer.deployed": "Job deployed!", + "cbbees.deployer.empty": "No programmed schematic", + "gui.cbbees.tool.program": "Program", + "gui.cbbees.tool.program.desc": "Right-click to program schematic into a reusable item", + "key.cbbees.program_action": "Program Schematic", + "key.cbbees.schematic_modifier": "Schematic Modifier", + + "gui.cbbees.deployer.mode.absolute": "Absolute Mode", + "gui.cbbees.deployer.mode.relative": "Relative Mode", + "gui.cbbees.deployer.confirm": "Confirm", + "gui.cbbees.deployer.offset.x": "X Offset", + "gui.cbbees.deployer.offset.y": "Y Offset", + "gui.cbbees.deployer.offset.z": "Z Offset", + "gui.cbbees.deployer.rotate": "Rotate", + "gui.cbbees.deployer.mirror": "Mirror", + + "cbbees.ponder.schematic_deployer.header": "The Schematic Deployer", + "cbbees.ponder.schematic_deployer.text_1": "The Schematic Deployer automates construction and deconstruction jobs using programmed schematics.", + "cbbees.ponder.schematic_deployer.text_2": "Right-click with a Programmed Schematic to insert it. Create one using the Program tool in the Construction or Deconstruction Planner.", + "cbbees.ponder.schematic_deployer.text_3": "Apply a redstone signal to deploy the programmed job to nearby beehives.", + "cbbees.ponder.schematic_deployer.text_4": "Nearby Mechanical Beehives will pick up the job and dispatch bees to complete it.", + "cbbees.ponder.schematic_deployer.text_5": "A comparator reads the deployer's state: empty (0), loaded (1), job active (8), or just deployed (15).", + "cbbees.ponder.schematic_deployer.text_6": "Shift+right-click to extract the schematic. Hoppers and pipes can also insert and extract for full automation.", + "cbbees.ponder.schematic_deployer_automation.header": "Deploy Modes", + "cbbees.ponder.schematic_deployer_automation.text_1": "The Schematic Deployer supports two deploy modes: Absolute and Relative. Right-click to open the settings GUI.", + "cbbees.ponder.schematic_deployer_automation.text_2": "In Absolute mode, the schematic is built at the exact coordinates stored when it was programmed.", + "cbbees.ponder.schematic_deployer_automation.text_3": "In Relative mode, the build target is calculated as an offset from the deployer's position.", + "cbbees.ponder.schematic_deployer_automation.text_4": "Move the deployer and the build follows — great for repeatable, portable setups.", + "cbbees.ponder.schematic_deployer_automation.text_5": "Relative mode also lets you override rotation and mirror settings from the GUI, without reprogramming the schematic." } diff --git a/src/main/resources/assets/cbbees/models/block/schematic_deployer.json b/src/main/resources/assets/cbbees/models/block/schematic_deployer.json new file mode 100644 index 0000000..d72f5df --- /dev/null +++ b/src/main/resources/assets/cbbees/models/block/schematic_deployer.json @@ -0,0 +1,1062 @@ +{ + "format_version": "1.9.0", + "credit": "Made with Blockbench", + "texture_size": [ + 128, + 128 + ], + "render_type": "minecraft:cutout", + "textures": { + "0": "cbbees:block/schematic_deployer", + "particle": "cbbees:block/schematic_deployer" + }, + "elements": [ + { + "from": [ + 0, + 0, + 0 + ], + "to": [ + 16, + 2, + 16 + ], + "faces": { + "north": { + "uv": [ + 6, + 1.25, + 8, + 1.5 + ], + "texture": "#0" + }, + "east": { + "uv": [ + 6, + 1.5, + 8, + 1.75 + ], + "texture": "#0" + }, + "south": { + "uv": [ + 6, + 1.75, + 8, + 2 + ], + "texture": "#0" + }, + "west": { + "uv": [ + 3.5, + 6.375, + 5.5, + 6.625 + ], + "texture": "#0" + }, + "up": { + "uv": [ + 2, + 2, + 0, + 0 + ], + "texture": "#0" + }, + "down": { + "uv": [ + 2, + 2, + 0, + 4 + ], + "texture": "#0" + } + } + }, + { + "from": [ + 0, + 7, + 0 + ], + "to": [ + 16, + 9, + 16 + ], + "rotation": { + "angle": 0, + "axis": "y", + "origin": [ + 0, + 7, + 0 + ] + }, + "faces": { + "north": { + "uv": [ + 5.5, + 6.375, + 7.5, + 6.625 + ], + "texture": "#0" + }, + "east": { + "uv": [ + 0, + 6.625, + 2, + 6.875 + ], + "texture": "#0" + }, + "south": { + "uv": [ + 2, + 6.625, + 4, + 6.875 + ], + "texture": "#0" + }, + "west": { + "uv": [ + 4, + 6.625, + 6, + 6.875 + ], + "texture": "#0" + }, + "up": { + "uv": [ + 4, + 2, + 2, + 0 + ], + "texture": "#0" + }, + "down": { + "uv": [ + 4, + 2, + 2, + 4 + ], + "texture": "#0" + } + } + }, + { + "from": [ + 0, + 16, + 0 + ], + "to": [ + 16, + 18, + 16 + ], + "rotation": { + "angle": 0, + "axis": "y", + "origin": [ + 0, + 16, + 0 + ] + }, + "faces": { + "north": { + "uv": [ + 6, + 6.625, + 8, + 6.875 + ], + "texture": "#0" + }, + "east": { + "uv": [ + 6.75, + 3.75, + 8.75, + 4 + ], + "texture": "#0" + }, + "south": { + "uv": [ + 6.75, + 4, + 8.75, + 4.25 + ], + "texture": "#0" + }, + "west": { + "uv": [ + 6.75, + 4.25, + 8.75, + 4.5 + ], + "texture": "#0" + }, + "up": { + "uv": [ + 2, + 6, + 0, + 4 + ], + "texture": "#0" + }, + "down": { + "uv": [ + 6, + 0, + 4, + 2 + ], + "texture": "#0" + } + } + }, + { + "from": [ + 1, + 2, + 1 + ], + "to": [ + 15, + 7, + 15 + ], + "rotation": { + "angle": 0, + "axis": "y", + "origin": [ + 0, + 6, + 0 + ] + }, + "faces": { + "north": { + "uv": [ + 3.5, + 5.75, + 5.25, + 6.375 + ], + "texture": "#0" + }, + "east": { + "uv": [ + 0, + 6, + 1.75, + 6.625 + ], + "texture": "#0" + }, + "south": { + "uv": [ + 6, + 0, + 7.75, + 0.625 + ], + "texture": "#0" + }, + "west": { + "uv": [ + 6, + 0.625, + 7.75, + 1.25 + ], + "texture": "#0" + }, + "up": { + "uv": [ + 3.75, + 5.75, + 2, + 4 + ], + "texture": "#0" + }, + "down": { + "uv": [ + 5.75, + 2, + 4, + 3.75 + ], + "texture": "#0" + } + } + }, + { + "from": [ + 0, + 9, + 0 + ], + "to": [ + 2, + 16, + 2 + ], + "rotation": { + "angle": 0, + "axis": "y", + "origin": [ + 0, + 11, + 18 + ] + }, + "faces": { + "north": { + "uv": [ + 6.75, + 4.5, + 7, + 5.375 + ], + "texture": "#0" + }, + "east": { + "uv": [ + 6.75, + 5.375, + 7, + 6.25 + ], + "texture": "#0" + }, + "south": { + "uv": [ + 0, + 6.875, + 0.25, + 7.75 + ], + "texture": "#0" + }, + "west": { + "uv": [ + 0.25, + 6.875, + 0.5, + 7.75 + ], + "texture": "#0" + }, + "up": { + "uv": [ + 4.25, + 4, + 4, + 3.75 + ], + "texture": "#0" + }, + "down": { + "uv": [ + 4.5, + 3.75, + 4.25, + 4 + ], + "texture": "#0" + } + } + }, + { + "from": [ + 14, + 9, + 14 + ], + "to": [ + 16, + 16, + 16 + ], + "rotation": { + "angle": 0, + "axis": "y", + "origin": [ + 14, + 11, + 18 + ] + }, + "faces": { + "north": { + "uv": [ + 0.5, + 6.875, + 0.75, + 7.75 + ], + "texture": "#0" + }, + "east": { + "uv": [ + 0.75, + 6.875, + 1, + 7.75 + ], + "texture": "#0" + }, + "south": { + "uv": [ + 1, + 6.875, + 1.25, + 7.75 + ], + "texture": "#0" + }, + "west": { + "uv": [ + 1.25, + 6.875, + 1.5, + 7.75 + ], + "texture": "#0" + }, + "up": { + "uv": [ + 4.75, + 4, + 4.5, + 3.75 + ], + "texture": "#0" + }, + "down": { + "uv": [ + 5, + 3.75, + 4.75, + 4 + ], + "texture": "#0" + } + } + }, + { + "from": [ + 0, + 9, + 14 + ], + "to": [ + 2, + 16, + 16 + ], + "rotation": { + "angle": 0, + "axis": "y", + "origin": [ + 0, + 11, + 18 + ] + }, + "faces": { + "north": { + "uv": [ + 1.5, + 6.875, + 1.75, + 7.75 + ], + "texture": "#0" + }, + "east": { + "uv": [ + 1.75, + 6.875, + 2, + 7.75 + ], + "texture": "#0" + }, + "south": { + "uv": [ + 2, + 6.875, + 2.25, + 7.75 + ], + "texture": "#0" + }, + "west": { + "uv": [ + 2.25, + 6.875, + 2.5, + 7.75 + ], + "texture": "#0" + }, + "up": { + "uv": [ + 5.25, + 4, + 5, + 3.75 + ], + "texture": "#0" + }, + "down": { + "uv": [ + 2, + 6, + 1.75, + 6.25 + ], + "texture": "#0" + } + } + }, + { + "from": [ + 14, + 9, + 0 + ], + "to": [ + 16, + 16, + 2 + ], + "rotation": { + "angle": 0, + "axis": "y", + "origin": [ + 0, + 11, + 4 + ] + }, + "faces": { + "north": { + "uv": [ + 2.5, + 6.875, + 2.75, + 7.75 + ], + "texture": "#0" + }, + "east": { + "uv": [ + 2.75, + 6.875, + 3, + 7.75 + ], + "texture": "#0" + }, + "south": { + "uv": [ + 3, + 6.875, + 3.25, + 7.75 + ], + "texture": "#0" + }, + "west": { + "uv": [ + 3.25, + 6.875, + 3.5, + 7.75 + ], + "texture": "#0" + }, + "up": { + "uv": [ + 2, + 6.5, + 1.75, + 6.25 + ], + "texture": "#0" + }, + "down": { + "uv": [ + 6.5, + 7, + 6.25, + 7.25 + ], + "texture": "#0" + } + } + }, + { + "from": [ + 0, + 9, + 2 + ], + "to": [ + 1, + 16, + 14 + ], + "rotation": { + "angle": 0, + "axis": "y", + "origin": [ + -13, + 11, + 13 + ] + }, + "faces": { + "north": { + "uv": [ + 7, + 4.5, + 7.125, + 5.375 + ], + "texture": "#0" + }, + "east": { + "uv": [ + 3.75, + 4, + 5.25, + 4.875 + ], + "texture": "#0" + }, + "south": { + "uv": [ + 7, + 5.375, + 7.125, + 6.25 + ], + "texture": "#0" + }, + "west": { + "uv": [ + 3.75, + 4.875, + 5.25, + 5.75 + ], + "texture": "#0" + }, + "up": { + "uv": [ + 3.625, + 8.375, + 3.5, + 6.875 + ], + "texture": "#0" + }, + "down": { + "uv": [ + 3.75, + 6.875, + 3.625, + 8.375 + ], + "texture": "#0" + } + } + }, + { + "from": [ + 2, + 9, + 15 + ], + "to": [ + 14, + 16, + 16 + ], + "rotation": { + "angle": 0, + "axis": "y", + "origin": [ + 2, + 11, + 18 + ] + }, + "faces": { + "north": { + "uv": [ + 5.25, + 3.75, + 6.75, + 4.625 + ], + "texture": "#0" + }, + "east": { + "uv": [ + 5.5, + 7, + 5.625, + 7.875 + ], + "texture": "#0" + }, + "south": { + "uv": [ + 5.25, + 4.625, + 6.75, + 5.5 + ], + "texture": "#0" + }, + "west": { + "uv": [ + 5.625, + 7, + 5.75, + 7.875 + ], + "texture": "#0" + }, + "up": { + "uv": [ + 8.25, + 6.375, + 6.75, + 6.25 + ], + "texture": "#0" + }, + "down": { + "uv": [ + 5.25, + 6.875, + 3.75, + 7 + ], + "texture": "#0" + } + } + }, + { + "from": [ + 15, + 9, + 2 + ], + "to": [ + 16, + 16, + 14 + ], + "rotation": { + "angle": 0, + "axis": "y", + "origin": [ + 2, + 11, + 13 + ] + }, + "faces": { + "north": { + "uv": [ + 5.75, + 7, + 5.875, + 7.875 + ], + "texture": "#0" + }, + "east": { + "uv": [ + 5.25, + 5.5, + 6.75, + 6.375 + ], + "texture": "#0" + }, + "south": { + "uv": [ + 5.875, + 7, + 6, + 7.875 + ], + "texture": "#0" + }, + "west": { + "uv": [ + 2, + 5.75, + 3.5, + 6.625 + ], + "texture": "#0" + }, + "up": { + "uv": [ + 5.375, + 8.375, + 5.25, + 6.875 + ], + "texture": "#0" + }, + "down": { + "uv": [ + 5.5, + 6.875, + 5.375, + 8.375 + ], + "texture": "#0" + } + } + }, + { + "from": [ + 2, + 9, + 0 + ], + "to": [ + 14, + 16, + 1 + ], + "rotation": { + "angle": 0, + "axis": "y", + "origin": [ + -2, + 11, + 11 + ] + }, + "faces": { + "north": { + "uv": [ + 5.75, + 2, + 7.25, + 2.875 + ], + "texture": "#0" + }, + "east": { + "uv": [ + 6, + 7, + 6.125, + 7.875 + ], + "texture": "#0" + }, + "south": { + "uv": [ + 5.75, + 2.875, + 7.25, + 3.75 + ], + "texture": "#0" + }, + "west": { + "uv": [ + 6.125, + 7, + 6.25, + 7.875 + ], + "texture": "#0" + }, + "up": { + "uv": [ + 7, + 7, + 5.5, + 6.875 + ], + "texture": "#0" + }, + "down": { + "uv": [ + 5.25, + 7, + 3.75, + 7.125 + ], + "texture": "#0" + } + } + } + ], + "display": { + "thirdperson_righthand": { + "rotation": [ + 75, + 45, + 0 + ], + "translation": [ + 0, + 2.5, + 0 + ], + "scale": [ + 0.375, + 0.375, + 0.375 + ] + }, + "thirdperson_lefthand": { + "rotation": [ + 75, + 45, + 0 + ], + "translation": [ + 0, + 2.5, + 0 + ], + "scale": [ + 0.375, + 0.375, + 0.375 + ] + }, + "firstperson_righthand": { + "rotation": [ + 0, + 45, + 0 + ], + "scale": [ + 0.4, + 0.4, + 0.4 + ] + }, + "firstperson_lefthand": { + "rotation": [ + 0, + -135, + 0 + ], + "scale": [ + 0.4, + 0.4, + 0.4 + ] + }, + "ground": { + "translation": [ + 0, + 3, + 0 + ], + "scale": [ + 0.25, + 0.25, + 0.25 + ] + }, + "gui": { + "rotation": [ + 30, + -135, + 0 + ], + "translation": [ + 0, + -0.25, + 0 + ], + "scale": [ + 0.46875, + 0.46875, + 0.46875 + ] + }, + "fixed": { + "scale": [ + 0.5, + 0.5, + 0.5 + ] + } + }, + "groups": [ + 0, + 1, + 2, + 3, + { + "name": "group", + "origin": [ + 0, + 10, + 0 + ], + "color": 0, + "children": [ + 4, + 5, + 6, + 7, + { + "name": "group", + "origin": [ + -13, + 11, + 13 + ], + "color": 0, + "children": [ + 8, + 9, + 10, + 11 + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/assets/cbbees/models/block/schematic_deployer_powered.json b/src/main/resources/assets/cbbees/models/block/schematic_deployer_powered.json new file mode 100644 index 0000000..96c4150 --- /dev/null +++ b/src/main/resources/assets/cbbees/models/block/schematic_deployer_powered.json @@ -0,0 +1,7 @@ +{ + "parent": "cbbees:block/schematic_deployer", + "textures": { + "0": "cbbees:block/schematic_deployer_powered", + "particle": "cbbees:block/schematic_deployer_powered" + } +} diff --git a/src/main/resources/assets/cbbees/models/item/programmed_schematic.json b/src/main/resources/assets/cbbees/models/item/programmed_schematic.json new file mode 100644 index 0000000..28b4711 --- /dev/null +++ b/src/main/resources/assets/cbbees/models/item/programmed_schematic.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "create:item/schematic" + } +} diff --git a/src/main/resources/assets/cbbees/models/item/schematic_deployer.json b/src/main/resources/assets/cbbees/models/item/schematic_deployer.json new file mode 100644 index 0000000..436d684 --- /dev/null +++ b/src/main/resources/assets/cbbees/models/item/schematic_deployer.json @@ -0,0 +1,3 @@ +{ + "parent": "cbbees:block/schematic_deployer" +} diff --git a/src/main/resources/assets/cbbees/ponder/schematic_deployer/automation.nbt b/src/main/resources/assets/cbbees/ponder/schematic_deployer/automation.nbt new file mode 100644 index 0000000000000000000000000000000000000000..f81248170b35c8a7cbf74cce3fdbc968ebcc93a2 GIT binary patch literal 327 zcmb2|=3oE;rvF=~9P~SEz|(fuLN?a%5tq}R6X$j}39zpVo>i6WGCid8qRG5@WtSNa zTK(MQ5%}oE_x$I#XJv~kbb-`$nr>YURdeDeeV~yTPDa~GJILB z@T8MhSjNaNb5s1R9H+%Bb{Zcprq^r|woMnFpVF{ds345#oY#R!PK(72##}IB zvsl43rgKpTBDpQHA6B3HcO^)5!TKExPqTJx4|Sa)c5>Sm!#8hME!)R!-No^=Xn!r2 zsqDJiVvCBUUoIN@Bhb~m2$sW*@9(U0%76SkRM`^i=$ueAN>g|HhzOQb$+eJRxYHeR21 Xwt3e{R{nz754i76?of+hWncgRYJ-|Z literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/cbbees/ponder/schematic_deployer/intro.nbt b/src/main/resources/assets/cbbees/ponder/schematic_deployer/intro.nbt new file mode 100644 index 0000000000000000000000000000000000000000..75829991c76465ffc580e406a2c7c88d6409c700 GIT binary patch literal 583 zcmV-N0=WGjiwFP!00002|CN@isJGgT{C8Ar3zD z79Vd<&t8MZbMWyUe29Y&y~W3O;Oska_8mC;4xD`l&cuN;ao|iGI1>lX#DOz);7lDj zQwPq}firdB>|dXu%`86#QVdN9z8JwnJ{%(IuI4G`%m_XhVVTJVDgyDz^6zUo!g|>U z^}t)y)675v#_c$=r|oSGty>BpI4coHb=u)EH)4h_5nst^vCNC1Wy@YMBS6pW{Zus& zLC`ap*Y}ZizT3ABdg=4799e&_4Xsm|W3Te1KfXS{xKcm=oIgJP{;2c$TLkesXLY9? z^a^d%)DFv{9{U$ojyz?B;4B;1I<=GA5cSc_rJB?!zdk&B^84P^i|OUz)5+zRPtX8i zbAN+f2>Vu3w{o8=v{hG&yv%Sg5e3@Kt77hyEJI_k>bn&J?Xw)=L2dzb{=>C-t8Rj?5ptQ#Q^agLn4Jp^-Xetd+{#oYlVl@^K>LIl V_8yhCrv#v*;V)+H&6qd`007Nl8O#6x literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/cbbees/textures/block/schematic_deployer.png b/src/main/resources/assets/cbbees/textures/block/schematic_deployer.png new file mode 100644 index 0000000000000000000000000000000000000000..521f5af267d9298ec8e8cb656fde6f29894b38d5 GIT binary patch literal 4901 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 literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/cbbees/textures/block/schematic_deployer_powered.png b/src/main/resources/assets/cbbees/textures/block/schematic_deployer_powered.png new file mode 100644 index 0000000000000000000000000000000000000000..4db670d45ab6d703a8d199be00b017f55c833a42 GIT binary patch literal 4870 zcmds*)mIb>*T(5#kdTlVX_4+2q;o(RN*bg=K|%>B=^7*zI4B_@-8GbOXc&5^p}VAW zBnOz+v%df0yLk3qd*A%l+Ur^S;)&7IQ70n-k>KFqkZHVB)&JW={}VCM-`XQ@JB)+# z7)L`@$J&>9(rMv@pt691!0mJrJ6{m!6?9>i74wCJ(Xz+~++dtfQEGJNQLEapgl zf*$>G;df+tGEy*Fwi8gWDHeddc{_`iU&XY|eyFg%pX+CA)$3eZ!jr#18GIHOXJ5N| zmNV8PPOAD8bh6jYVh0b`^cSPWY5KoQJ7NJ>c3@}Y5v@t7B7_VdZUSHO3$ioD zunO)Via4*es2TioBv)R1Ui`59xj4rk5lU2HD4j5N-^F6P6VVZ`$4$^AqUMd?p8m zVFTXLxLMlL+;M{6f5TWNNF?MWcSzX6uF+BP=s)r& zXY`|%XZxZ?Ib$aqCUd+L>;A%GLeb!@nJK{x>ab^f%FAhaJUnc5A#u62J=S4H&9c*X z8hzX_yK*h6mKeXj7WcvaDlzlx@Rl`adZv*G2;a(93Flk_FG4NjffeGVAs+0S`0H85A04PE<1glB&BA?d0N;1Sd;iuaFYi z(#rS5a|fnnq;)o~e0o)0ME}J$UZ+1b!%8~}t<_s(K&*yG95tGO@QtCB_VS^RCTBJ@ zXB7}rNEsZ!z}CONv3V7*NVS2c($D74yZzu^&bt#6N% z+MIjbgUzZQQBbj);Qi1j}!xos-*;4nf1dP;eWhDQ~I}5NqmqrEFFz!G{4hZC=VAKpf?!IcJNErupUpHI01K4 zE9$%S%gze=wC_UJFf~eJ=~PvTT(Yk^MZHYokWgAgF%|I;zkuWhH#gmILi1&!FhX5) zqk0znFp@5|aS3sk2FHy`E*CshmH6SPs#?jsQO|P9L=I{&O5K8)o3X)M=`Ys?YVEVZ zz%ud}k46!#_;`vv^v(o7@4szq+`(Hq*`;(x2m##a4B}VP(>Yw39#fIAc2Ne0rh>vhHo=hDU7UPj@ZuD| zv%Rr3UwJI!U0&I{UY-HWWC&L?j4Q>TVCyd5@R=sJ@9Gr5CYDulX*vN8K+cK>A7Va~%p(cL2L@_1c?%)q zcZ~U>8-$8p2A+_^l~8t2+m~ zM|U?iS~y$q{)GhW-GRK8coZXZ?MZUfizk zZj%430g-_w*MXxwmRL*WQ`r|UcHQt?=j*mX(2_z2$anju&6 zX?*m=Psx=%=h*49Z=l?ujF0GZtq3#48Ie=#6H=q+p-r5e{{Xpe*giWc?c}oV#QgKM z_>aN{exSeK(3g(IVS6H0Ld&z*S(TkA0lc`G5^+1O#)%WlWT-z{Y!U*_@^2Fj z&FZb#6O9}F1U$c~^GClOA#0DRNc3jgHa=9qugn_>lNay%Xh+EE!9GqiY7IVD322YcYpJUvt8F7PVLU??( zJamua`cM^Iz~)9TU>eO6T_v;@**b0Fr2AG7v-Up8v_}+6T^2Q&UYp0m%aGP@`xu;* z8xN9i({+OMqcOd>k^KunjjpA<*#m)QB^0XNm*mLoE>Gb#gK24-YYz8zvoJ?_Df4Nl(ccZqS`E|=qp6sjM z*`K}7e_I*Z6zivD;Ih0h6#Xn8T z91yZo*tGV<6n|o??&0qEud1Hhq65C&qOnIy11(uRtC+4u)`@FC75F3$h(G94m)m=0 zd_J%+F>7HEB9F9P-pFRUKOIClK6`fpYS*hDC`6z16bUKXn2n-|#P+0YLTQFLM86A#w=w2+rMIt0_TqiV(ME7y0 z<4pDu9sw8EhhI7_7RwYu+BUA_B}3`5L!Po=_piL0-#syPleoOXR_MK>2$2 zH;y)P3Bit$q|$~7B7djimefjDm}Ai8kU}x(td2|HN@Ie3^i=1>0#>c2YBtAuB~!P# zdN=?LY|)<1n`CI4Vb`e^iwzf7CUKg>EqoFn zF}t1-q1_jq2{uwmtSu+5h&db5`4ENB57_(7^2;lh+c8NIG5PXabWXs}^ZGryj#EO% z(_WrKOn(P%3ZnkhHD*fZj~RUpE@xJ0{8>xaB>;7l z0yR~8HwDW`it8`U=PT!nJcBAKErv=)PG9ck`yFiYM1sjfqt@YGUc`g0TfW5Z0T;J0 zA`S=6>;X;vmsKn*vM~?sRQWKx$>oD(pq@%-G*l<0A$ z*6^IH)x)vJ-BB2ucD$(Rl8bMnp#Fq$`L8-qmu44&x-l=ka9K^QAA$0|InK@OG>#6td0p1gW2nKZlG-RTU$_DQ%|>*c@2v*jz1KLTcXB%Kp)K$5HW2EK zQ+BI{2KI$n9@cnS|eG7_{WqDC|Y$exa8k1=@Qrd`X zAWKXl1!nlZqjeZ^^rH@i`Sf&Rd5ZlYSntVO(aC{xw{`S&`00|s7X}#F0^9JpyTl+ee&e~ zPjSdAJ1pUJ&LqE7>_dPgP{S>riCLL9Nu>m7GD)(usrQmYzEHtoExnlH9p2rrqr#!y zK!0NV8nSZvGZ@?{BEYy1f`=kKYVzKQ`tU%2-}?T0I{(bIow>Y|y4yKpBEjjcuFmGe za;C6cCSoteK3V#5Jte0F;Qfl_;U*Jsc1?}HKR@WCAcn=k!K?l+3#f&l;#h;Xg{)^> zPNl==Y0k>$dl+PdYj7DR@NNvvfyu*NyR}fwN4=X21)l!m;OOs>EX1*Wf(%W_Y&Dce z(eTHu2GU2Gnr155QW*Q%=7*|mZ(?|^a&4b7MVUS5?N1vVxn=D_CS{1*+Z%{ZW5v{H zEKPm06leR|?YZ7!ANQuVAzY!eaw|4i21pq*v>>PkkuL_;tk?*A?%D>uk3%oXnh`Jx zKdz+Uu>~CP6F@%yI9oFNVVr3|x-L|CwO;a^$XtoRNGe#@dQO$sj0XHjC(R;OZ7=WL zQOAl+BbaEKbJTl1574=A^Rj+d7ajm3hO)l3l`kZ>8r&hV)0*G+@18!q8${CilG@5f zUtF^JTQ87mS5g+#nd4VTmN<9Rn+`Eh>IAhF@?+!Zexv637w_3RZ>4JsdX#B9n!Pw; zp`q~v7TtDjujQdZlGbV!cQz%>1mh%J$TpdnmBfoh^)(|?56X`vRWG2W$G>1r^$_>7 zje5^=7_7LqX28&WLa0L)Tx=mRE9vOECdZ~Fn@t!H_Dg(Alra5F>5akMAZhm~WkdWf zsOryXAu8s3Wg}QTdoZ$3@IIisf`!NvRQG{da?-0nt&dQnm^IABl*3-T(jq literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/cbbees/textures/entity/mechanical_bee.png b/src/main/resources/assets/cbbees/textures/entity/mechanical_bee.png index a5e6a608ca4fc5c2cc0129ded155ea1549fc94e5..f8b0757a69095049d59e5871d329cd13dd290335 100644 GIT binary patch delta 1643 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 diff --git a/src/main/resources/assets/cbbees/textures/entity/mechanical_bumble_bee.png b/src/main/resources/assets/cbbees/textures/entity/mechanical_bumble_bee.png index e3d7df3cd1bbac478ad5e5e43fd01be05751e0d4..ebe22ef01086fab774d57de3434ecd75edafd4ef 100644 GIT binary patch delta 1560 zcmV+z2Iu*)45C`KS8DpVAt#3&j< z&Q4gmSU^j zwzTW~ZhJS=ySv$0ciP!*w&~e(zVDnl_nvd_-JPAa#pB=p{eSzS&(}N?C-6uzpF4f? z<2$0AHBUs{T+p`X29%X9k+RZqxvlcnn1Cnwo3Ghuy>erN7lbp79`5fIgc}<$pZ91mJObA7?mXnBz)9UF zN6%Hr@vfU<0>)=fY__m{@Zh`FbJZ_d=+?F$tzUodw9qF`d}1{nJ8Yp_zB*#r=gCA45%=FPVpvBixu%RMt!+cT4P?qv0~0i#0`GJ0`LB7+4oIuvzeI?l$n zWYmC_4^_3Td2EXk$BuS<&|+yV5B*OfwwK|9U4z#d3{fr@X!9qJNo| zj2gf>=Zq^Uod}zgHT@vXTrG(PYhu>6*LRSjAzFpDpC zb#=9RK8H5Ph2z;B+VuH>S@nkZUw^g8g$85Krr0f~zm^wXe@i}XYK$8f4j(abP9F$! z)lKZf5Rxi~eb=V-Qng`|Y`k$zTr3tlMQ@Y z4WMX*$5~sq?^KVq!0tkH;T#{14g%+n$tK%#11OZD5msIoRa;jds|QXO9wW2d@Z5mT zsUJ0{haEtnx?ngY(NA1v3~|{co*Qtw{rlLw*$s&bb3fkZ>?eweCCL)Qa|2j$z4bW; zV6z4|ibgp5U^6D$u;&Iaaeuw_v6Bf!Bgy>P#;4VQY@56v^T{{Bk1o#T9QhqEmsih^ zx#!#tNVh5Z2Bh1uT#L`|fLvSAbnD6QfOI>SYw`IVkZUWNZaw)OkZ#9vEk3^ka&1M^ zttY<&((PET#pidxTwl>l=l>r700960pWTd_00006Nkld;I80000< KMNUMnLSTaQZxR6j delta 1568 zcmV+*2H*Lq46zK5B!A{fL_t(|oa|Z6Zxm%1|IO^o?Ci4b*20#S(u86~qX{*Hzo3a1 zO-O122XCJ6V(>s9)q|KA6NQ5z9OUH93(}c=3c{D7V1|%%I4msv)27iy^^Kr;Macn2=^KX7u z-hb`3U- zhSte~j$<{hno4d(5-T28J}!^vqrJ`R*8hT6YhKgRQ^YA>8lsagL0B%2Kv7f#fsciSa~(-|=fu&FJ;e5aFTRc5>3Lq}fVk&Gq11#7 zA?Z<^EcreTgQWWg>G%o`*0Dkjv>?Zj|9sYJVsr-T(i_GT)bm5YjGQ{H!Mnu;ook zkbD;79Vj5JpN2%(v-@58^uw@TcgTHB3>WFeXmu3<yUomYBpglxyg zuQ!)aeegFzC5O;=@%;2&9Gf zkr8measB%B)Z~Sp2`HEavaAZ#Fom*=fMUJdny%}Zot+h>_SC6U7#|;J0+1H;Oh7;u zW$T4*9g4y4z%=$f+^gQj{=Jng*Tg&>^MCX6n46oUUL%Jzp=SafK3a~9%L-BR#4fHh zti9curfH~Ft0+==G^tE-#Yhu+PJ$}A__5go@LU^y-9}K~z>%4KFv#NDt+DkD!@!Lj zH?X+42-kIyCiLt924(mCl~J6WKPY0p`tZI`u2?9*UiY_La~ww`znPgCl*?t26MxbK zg*!Z#+?H#0ylip>4f&$b+S2N@O8Qdko>;tl%DV59)p$IVyWQm2rOI<3uWhR;R>u z`_7-DapBr6$``pbCoz6VcIByqqvX0iR#q&0|KnPe>FMET4wW%h8AhR?_b;OXDI+PU zDm~!h?MG0xyy)OmJrAu=f?gV?Ykw2J-CPz*43$h$a1CEynS)s{@ZFcM;r88iD*FNY zm7$WSsN@wnI)1-X6EYGbV2t8Zq1RxA%aa<_R(Yc{H%Sa0Jb=bzl_z(hj1Kx&3?W_ym{s+ESCkwkceryVs6@3Xhbg)Ea z&Y`^yROgAGVVT`R~1QjEnx?oJHr&dIz4a#+$GeH|GX zHuiJ>Nn{1eo%3{Y45^5FJ7=#Kv!h7sc`4rn!&@wq9OgN0`O5lcZL7csL8c_-GNp|4 zWJM(|GqHbr8rPO>o7Si@K~Er}^T;vjM|UL!%yvef`8iAf&Rw64GUsxPr5PQz-kZ@d zVa|(k>%fq;=4$QixnHVmYv&3GugiZY5c;~;bM?;l>+Rl0mZtVhDlA}|Fst?1o_UVH z|A?KvW;ab`>bluZ8?(h`#Ts*SuXK~)nf%h#Nz64yM`2#(kxYU6@#_q{j!D$kE)LRr z`Crh;ob52n)FRHsDLsq3fA5dpeC4T5&|DK+|I_bp?pb2l=S&#D#M)2=I-%)M$A&m=w3Z0ot{ z2i%tU0>w87W(L2m;mmQlb%jEy2R44Ef@@MH^0b}&-#1xkQ^uFVONX*5-&SN7GtZaL z@2LO&=IaiLDdAk+D#dxgX;%8)?LXmoik^i kBGdkUkD_Jw-@4fUtmWU-zE5QxFs2ziUHx3vIVCg!09kzXga7~l literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/cbbees/textures/item/upgrades/drone_range.png b/src/main/resources/assets/cbbees/textures/item/upgrades/drone_range.png new file mode 100644 index 0000000000000000000000000000000000000000..64692d7d6cd2cdeadd78de08d987564524b7b580 GIT binary patch literal 754 zcmVrwf6LT&vIoLKZ5CHRt?jt0YK7WTIqO3KZn0906!OY~pOs5*B|)!K}&) z(6L4?^0sWkua80PF_Q*5hlrN19nvTg1v#VK0k+&c6du@t@n$Zl#i~W%f0mgN@;qcc zx6H#q=dv90$s`n39O_$vbD~}A_Ef;)Es%mFfhiBgo1X)lo#~)5H!2wvRFOj0F^0R< z47nS)3L|IzCjA!723pz`Bj2W@khiG2>RDx2?K`R%I*%|=$xUrmG(Jq|95b?jsaL}& zyB)DmnTzn;GF%=jo;2j@1Vz&Mj-g|ip?!$qnxEnFKw8HJUpIr;?EulG(6;CKvinG4$7Q zyu5V~LxXLAX@FQbQt<>te~EWwHAh!aXN8;sz~r!aobE@SQw@M9)~&;AiiKIp?l?^C4Z6=zB__AdYc0RR6Y kv{O6)000I_L_t&o0E#9WM9JECF#rGn07*qoM6N<$f6~-jW$RqN@1FCW@Avz@-^WprwLV^Uuy4IP z*pCN$SfJU>CN9}opoy|+pNj?Cxm|U$sTL>8f*^-LQ3(pmOX1nF+05Bi=0bU;2c`Qw zC@!I3=^Q*vf=OPnqV6~xUN;og3)0^DV+0t zKudd3MF@5gZFRtWVsLRgJzSc_{=RmDJaFA1jFylnTWTBJ1%^^Ja9Kk0<8TF?^lpNfNsaDEVi&Zv(?6s6k8i4g+ z_j>{nTR)mVJ;nGuIG$RT;7}LMi`zB100HhfMnI|2*@nHP}_i$drW$(f;vb5|`v_fZ0sbE`Hhnja;k z6g{$x+1F#Jyc@Bof-1bY2A9W*r%m}XL68XDU1J2DqXako1lNYs@^0i!D-^pOP$U%6 zSKW&%^OGS^mlXr&oib5$^S_O}YeOm;Lh$i(?C)Dq_(XI`0KTjaf;_6iTWH6y&x4mO zE)1R}7;2z+b>|R9M>+sqgJPjb#S_RZlVD~xOy?$+LAWYAHC~A*VRgD;aTh>O#G%K+ zm>qh81N|{x+@LTCPQO50QB^DiBl!9`j9&|DSo{n8oDboPe;hLp2QfYH3~ByG45;D% q0RRC1|NbifsQ>@~21!IgR09CQgBnCg$(LvV0000`k`3L*PaR$Q?#syupjyn6HUXXprAi0 z2ntFb$4g>cJEzwTW98+nS0LnedpXWGXz=3yGASf+EdJa z3~ghcmK-*A!oobwDw}M#G4CmEojGixbrs8^V10u z6scgz6?|j_#(Bl^qxG=5a-a}`E3cC1eoJwDFoK5j0W@?4U{wt$t#ZQCY|W@-1QbPq zIJI~J{#l>wpuI*%M{N>k>*NwkVGXgU211lnfO{!0(cxlS57i;5Y53*+f}rm=<|A?0 z0b>l8h9TGUKbHWFVdEAXJWZD7fl@WtcHi7Iz zfZO^0g^74at(b+5tD`Hs(i4o&gY#8`A6BveEf$meP7{TY5Fi{wIk#(X5}l73>=iXI zxC(efTtMeTapjp|vQSV15qN2WEQHeJ%OezyTvg`GYxCuuQ4>gcPehrW4uii%*c=9# zNIO9mal)&Mm|9pYZqP#kOg(*#%HB|_WN;w{o72cEm?S`u&g|AgZBHNEtcMtP(sww?=|aKcQ%QK+$>+XzcaDec+~ykGyC?BDyRn(lP>{rh@A9SO8*> z!xwWUSbUAyHu~%srtaKD^^Hj8Th0i$A0aA!qHo?1W3aU%%NZ<6xBUyLYE^&Twdt zoy+gL_k4eT-|su;Qmk4ZE;z*3uI=Lc{oNwa=oX{r?IO^?#eam)C4#LIm)v5!$thNq zU=0DG4EZHR@NC|g&DmP)LP?niMSDFcDC7|7HTB36jLD9rHAms_xy^w)8`ymb&mgKdCmfLJ(E@dQ>@mSA!!%%?{e zz~oeRp7~2u3#-!&i#rcyA`UYa##H}f?C*)m=2IDpEWwHAh!aXN8;sz~r!aobE@SQw z@M9)~&;AiiKIp?l?^C4Z6=zB__AdYc0RR6Yv{O6)000I_L_t&o0E#9WM9JECF#rGn M07*qoM6N<$g46O?S^xk5 delta 701 zcmV;u0z&=r1=R(RB!7xYL_t(|0i04xYZFlv{^rgjGdD?-j7p5v7FzlOMOs@6E_A2h zRuKFPqNOM*{)i&##)XIqBI3rSJ8@S@Up6$-*3ybimC~enwRz3tdhSe(ExPi~oHNXv z?{Usy81|h{H!SgWqDOpta#|$E!(#HfDUxGc%-soz)IEy(VSh1mD=79+a6mv|D&hmZ zh#o)M!gThBFfbfN@0lptyE#Pj03R)ZDRLaVb{Uo(hQeB(0m3&O0U-n|uAzSRsJB1&LxZ3A1&tEd*sl6xR%0+ezM zWH-tvZI+?xewj^;a}n!EATympW;RXE4usqEt9zNIgA7pTLQ!EvV(32Chnd@4FirQG z!2cY#A!r}O=UNUH%fhME{;9(VqpASs3e5O1v<+Q=W`DO+LnPqRQtWsDhN(i;*k1yF z$Ux)>N35?Ofx`(8CsYG$aAHSUOP3*M3`I1rAd`HTWbaAt%+SF*dtgzx#ceFS`+(sG zMc=ZJtsxZE$-#i!i6m({5HNwZ2oMYchkCmZ9Xbz2>sL%FC=Bv=dE`+=S)9s)hjbiD z7%&VXwtoQ;BAb?qP)7&s(Th+>wNcI^_2LcAJ}gNe*AtLRIspTm!H%2g902u6IdyQtj?lbrDF8}}l|NrR( jZI1u|00v1!K~w_(iF)8QcbG_)00000NkvXXu0mjfDvLud diff --git a/src/main/resources/cbbees.mixins.json b/src/main/resources/cbbees.mixins.json index d1e30f8..696aefd 100644 --- a/src/main/resources/cbbees.mixins.json +++ b/src/main/resources/cbbees.mixins.json @@ -11,6 +11,7 @@ "SchematicHandlerFindMixin", "SchematicHandlerHudMixin", "SchematicPromptScreenMixin", + "SchematicToolBaseMixin", "ToolSelectionScreenMixin" ], "injectors": { From a7323b52bbb25c21469ec45acb0a4e2f15066e27 Mon Sep 17 00:00:00 2001 From: devin Date: Sat, 25 Apr 2026 11:46:17 +0200 Subject: [PATCH 3/3] Insane update --- script.md | 564 ++++++++++++++++++ .../mixin/SchematicPromptScreenMixin.java | 74 --- .../kotlin/de/devin/cbbees/CreateBuzzyBeez.kt | 5 + .../de/devin/cbbees/config/CBBeesConfig.kt | 25 +- .../content/backpack/PortableBeehiveItem.kt | 1 + .../client/BeehiveTooltipComponent.kt | 1 - .../backpack/client/PortableBeehiveModel.kt | 1 + .../client/PortableBeehiveRenderer.kt | 9 +- .../backpack/client/UpgradeGridWidget.kt | 2 - .../cbbees/content/bee/MechanicalBeeEntity.kt | 34 +- .../content/bee/MechanicalBumbleBeeEntity.kt | 25 +- .../brain/behavior/DropOffItemsBehavior.kt | 2 +- .../bee/client/BeeTargetLineHandler.kt | 14 +- .../content/bee/client/BeeWorldRenderer.kt | 56 +- .../content/bee/flight/CheckpointActions.kt | 46 +- .../content/bee/flight/FlightPlanComputer.kt | 47 +- .../cbbees/content/bee/server/BeeWorker.kt | 3 + .../content/bee/server/ServerBeeData.kt | 2 +- .../content/bee/server/ServerBeeManager.kt | 90 ++- .../bee/state/ConstructionBeeStateMachine.kt | 4 +- .../content/beehive/MechanicalBeehiveBlock.kt | 29 +- .../beehive/MechanicalBeehiveBlockEntity.kt | 9 +- .../cbbees/content/domain/GlobalJobPool.kt | 55 +- .../de/devin/cbbees/content/domain/JobPool.kt | 15 + .../content/domain/StuckReasonResolver.kt | 24 +- .../domain/action/impl/DropOffItemsAction.kt | 4 +- .../domain/action/impl/PlaceBlockAction.kt | 6 +- .../domain/action/impl/RemoveBlockAction.kt | 2 +- .../content/domain/events/PlayerTickEvent.kt | 27 +- .../domain/job/client/JobProgressToast.kt | 6 +- .../content/domain/network/BeeNetwork.kt | 23 +- .../domain/network/BeeNetworkManager.kt | 6 + .../cbbees/content/domain/task/TaskBatch.kt | 13 + .../drone/client/DroneViewClientEvents.kt | 7 + .../schematics/client/AreaSelectionHandler.kt | 231 +++++++ .../client/ConstructionPlannerClientEvents.kt | 29 + .../client/ConstructionPlannerHUD.kt | 2 +- .../schematics/client/ConstructionRenderer.kt | 85 ++- .../client/DeconstructionHandler.kt | 287 +-------- .../client/DeconstructionRenderer.kt | 49 +- .../schematics/client/GroupPickerScreen.kt | 248 +++----- .../schematics/client/JobDetailScreen.kt | 229 ++++--- .../schematics/client/PickupHandler.kt | 191 +----- .../cbbees/content/upgrades/BeeContext.kt | 2 +- .../cbbees/content/upgrades/BeeUpgradeItem.kt | 5 - .../cbbees/content/upgrades/UpgradeShape.kt | 11 + .../devin/cbbees/gametest/CBBeesGameTests.kt | 68 +++ .../de/devin/cbbees/gametest/GameTestSetup.kt | 240 ++++++++ .../de/devin/cbbees/gametest/TestJobPool.kt | 47 ++ .../construction/ConstructionTests.kt | 115 ++++ .../deconstruction/DeconstructionTests.kt | 27 + .../devin/cbbees/gametest/dsl/Assertions.kt | 40 ++ .../devin/cbbees/gametest/dsl/BeeTestDsl.kt | 318 ++++++++++ .../devin/cbbees/gametest/dsl/GameTestDsl.kt | 79 +++ .../cbbees/gametest/flight/FlightPlanTests.kt | 129 ++++ .../gametest/inventory/BeeInventoryTests.kt | 160 +++++ .../gametest/logistics/LogisticsPortTests.kt | 51 ++ .../gametest/portable/PortableBeehiveTests.kt | 129 ++++ .../gametest/transport/TransportTests.kt | 98 +++ .../kotlin/de/devin/cbbees/items/AllItems.kt | 34 +- .../devin/cbbees/network/CCRServerEvents.kt | 63 +- .../cbbees/network/GridRemoveUpgradePacket.kt | 1 - .../cbbees/network/HiveJobsSyncPacket.kt | 15 +- .../de/devin/cbbees/ponder/CBBPonderPlugin.kt | 3 +- .../de/devin/cbbees/ponder/DeployerScenes.kt | 140 +++-- .../de/devin/cbbees/registry/AllKeys.kt | 12 + .../assets/cbbees/geo/CustomArmor.bbmodel | 1 + .../assets/cbbees/geo/mechanical_bee.geo.json | 153 ++++- .../cbbees/geo/mechanical_bumble_bee.geo.json | 173 +++++- .../resources/assets/cbbees/lang/en_us.json | 78 ++- .../cbbees/models/item/pickup_planner.json | 6 + .../cbbees/textures/entity/mechanical_bee.png | Bin 1660 -> 1710 bytes .../textures/entity/mechanical_bumble_bee.png | Bin 1577 -> 1585 bytes .../cbbees/textures/item/pickup_planner.png | Bin 0 -> 437 bytes src/main/resources/cbbees.mixins.json | 1 - .../structure/gametest/cbbees_deconstruct.nbt | Bin 0 -> 1109 bytes .../construction/cbbees_construction.nbt | Bin 0 -> 1146 bytes .../cbbees/structure/gametest/empty3x3.nbt | Bin 0 -> 88 bytes .../logistic_ports/cbbees_filtering.nbt | Bin 0 -> 1241 bytes .../logistic_ports/cbbees_priorities.nbt | Bin 0 -> 1229 bytes .../portable/cbbees_bees_dont_use_player.nbt | Bin 0 -> 983 bytes .../portable/cbbees_portable_ignore_port.nbt | Bin 0 -> 627 bytes .../portable/cbbees_use_logistics_first.nbt | Bin 0 -> 968 bytes .../gametest/transport/cbbees_transport.nbt | Bin 0 -> 1040 bytes .../transport/cbbees_transport_filtering.nbt | Bin 0 -> 1116 bytes .../transport/cbbees_transport_priorities.nbt | Bin 0 -> 1106 bytes 86 files changed, 3653 insertions(+), 1128 deletions(-) create mode 100644 script.md delete mode 100644 src/main/java/de/devin/cbbees/mixin/SchematicPromptScreenMixin.java create mode 100644 src/main/kotlin/de/devin/cbbees/content/domain/JobPool.kt create mode 100644 src/main/kotlin/de/devin/cbbees/content/schematics/client/AreaSelectionHandler.kt create mode 100644 src/main/kotlin/de/devin/cbbees/gametest/CBBeesGameTests.kt create mode 100644 src/main/kotlin/de/devin/cbbees/gametest/GameTestSetup.kt create mode 100644 src/main/kotlin/de/devin/cbbees/gametest/TestJobPool.kt create mode 100644 src/main/kotlin/de/devin/cbbees/gametest/construction/ConstructionTests.kt create mode 100644 src/main/kotlin/de/devin/cbbees/gametest/deconstruction/DeconstructionTests.kt create mode 100644 src/main/kotlin/de/devin/cbbees/gametest/dsl/Assertions.kt create mode 100644 src/main/kotlin/de/devin/cbbees/gametest/dsl/BeeTestDsl.kt create mode 100644 src/main/kotlin/de/devin/cbbees/gametest/dsl/GameTestDsl.kt create mode 100644 src/main/kotlin/de/devin/cbbees/gametest/flight/FlightPlanTests.kt create mode 100644 src/main/kotlin/de/devin/cbbees/gametest/inventory/BeeInventoryTests.kt create mode 100644 src/main/kotlin/de/devin/cbbees/gametest/logistics/LogisticsPortTests.kt create mode 100644 src/main/kotlin/de/devin/cbbees/gametest/portable/PortableBeehiveTests.kt create mode 100644 src/main/kotlin/de/devin/cbbees/gametest/transport/TransportTests.kt create mode 100644 src/main/resources/assets/cbbees/geo/CustomArmor.bbmodel create mode 100644 src/main/resources/assets/cbbees/models/item/pickup_planner.json create mode 100644 src/main/resources/assets/cbbees/textures/item/pickup_planner.png create mode 100644 src/main/resources/data/cbbees/structure/gametest/cbbees_deconstruct.nbt create mode 100644 src/main/resources/data/cbbees/structure/gametest/construction/cbbees_construction.nbt create mode 100644 src/main/resources/data/cbbees/structure/gametest/empty3x3.nbt create mode 100644 src/main/resources/data/cbbees/structure/gametest/logistic_ports/cbbees_filtering.nbt create mode 100644 src/main/resources/data/cbbees/structure/gametest/logistic_ports/cbbees_priorities.nbt create mode 100644 src/main/resources/data/cbbees/structure/gametest/portable/cbbees_bees_dont_use_player.nbt create mode 100644 src/main/resources/data/cbbees/structure/gametest/portable/cbbees_portable_ignore_port.nbt create mode 100644 src/main/resources/data/cbbees/structure/gametest/portable/cbbees_use_logistics_first.nbt create mode 100644 src/main/resources/data/cbbees/structure/gametest/transport/cbbees_transport.nbt create mode 100644 src/main/resources/data/cbbees/structure/gametest/transport/cbbees_transport_filtering.nbt create mode 100644 src/main/resources/data/cbbees/structure/gametest/transport/cbbees_transport_priorities.nbt diff --git a/script.md b/script.md new file mode 100644 index 0000000..368526b --- /dev/null +++ b/script.md @@ -0,0 +1,564 @@ +# How to Create a Create Mod Addon - Pipe Inspector + +## Video Intro + +**Talking point:** "In this tutorial, we're going to build a simple Create mod addon from scratch. It adds pipe statistics to the Engineer's Goggle overlay — when you look at a fluid pipe while wearing goggles, you'll see the fluid type, flow direction, pressure values, and how far away the nearest pump is. No blocks, no items — just pure functionality in about 100 lines of code." + +**Talking point:** "This is a great first addon because it teaches you the fundamentals: project setup, how Create's API works, and how to use mixins to extend existing block entities." + +--- + +## Part 1: Project Setup + +**Talking point:** "First, let's set up our project. We need a NeoForge 1.21.1 mod with Create as a dependency." + +### 1.1 Create a new project directory + +Create a new folder called `CreatePipeInspector`. Copy the Gradle wrapper files (`gradle/`, `gradlew`, `gradlew.bat`) from any existing NeoForge project, or generate them with `gradle wrapper --gradle-version 8.10`. + +### 1.2 `settings.gradle` + +**Talking point:** "The settings file tells Gradle where to find the NeoForge plugin and what our project is called." + +```groovy +pluginManagement { + repositories { + mavenLocal() + gradlePluginPortal() + maven { url = 'https://maven.neoforged.net/releases' } + } +} + +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '0.8.0' +} + +rootProject.name = 'CreatePipeInspector' +``` + +### 1.3 `gradle.properties` + +**Talking point:** "We keep all our version numbers in gradle.properties so they're easy to update later." + +```properties +# NeoForge +minecraft_version=1.21.1 +neo_version=21.1.206 +parchment_mappings_version=2024.11.17 + +# Create +create_version=6.0.9-215 +flywheel_version=1.0.6 +registrate_version=MC1.21-1.3.0+67 + +# Mod +mod_id=pipeinspector +mod_name=Create Pipe Inspector +mod_version=1.0.0 +mod_group_id=com.example.pipeinspector +mod_authors=YourName +mod_description=Shows fluid pipe statistics in the Engineer's Goggle overlay. +``` + +### 1.4 `build.gradle` + +**Talking point:** "This is the most important file in the setup. We need the NeoForge mod dev plugin, and then we add Create as a compile-time dependency. Notice we use `slim` and `transitive = false` — this avoids pulling in Create's entire dependency tree, since those are already present at runtime." + +```groovy +plugins { + id 'java-library' + id 'idea' + id 'net.neoforged.moddev' version '2.0.78' +} + +version = mod_version +group = mod_group_id + +repositories { + mavenLocal() + mavenCentral() + maven { url = "https://maven.createmod.net" } // Create, Flywheel + maven { url = "https://mvn.devos.one/snapshots/" } // Registrate +} + +base { + archivesName = mod_id +} + +java.toolchain.languageVersion = JavaLanguageVersion.of(21) + +neoForge { + version = neo_version + + parchment { + mappingsVersion = parchment_mappings_version + minecraftVersion = minecraft_version + } + + runs { + client { + client() + } + server { + server() + } + } + + mods { + "${mod_id}" { + sourceSet(sourceSets.main) + } + } +} + +dependencies { + // Create (slim = no bundled dependencies) + implementation("com.simibubi.create:create-${minecraft_version}:${create_version}:slim") { + transitive = false + } + + // Flywheel — Create's rendering engine + compileOnly("dev.engine-room.flywheel:flywheel-neoforge-api-${minecraft_version}:${flywheel_version}") + runtimeOnly("dev.engine-room.flywheel:flywheel-neoforge-${minecraft_version}:${flywheel_version}") + + // Registrate — Create's registration library + implementation("com.tterrag.registrate:Registrate:${registrate_version}") +} + +// Expand properties in neoforge.mods.toml +tasks.withType(ProcessResources).configureEach { + var replaceProperties = [ + minecraft_version : minecraft_version, + neo_version : neo_version, + mod_id : mod_id, + mod_name : mod_name, + mod_version : mod_version, + mod_authors : mod_authors, + mod_description : mod_description, + ] + inputs.properties replaceProperties + filesMatching(['META-INF/neoforge.mods.toml']) { + expand replaceProperties + [project: project] + } +} +``` + +**Talking point:** "The key repositories are `maven.createmod.net` for Create and Flywheel, and `mvn.devos.one` for Registrate. These are the standard repos every Create addon uses." + +--- + +## Part 2: Mod Metadata + +### 2.1 `src/main/resources/META-INF/neoforge.mods.toml` + +**Talking point:** "The neoforge.mods.toml file tells NeoForge about our mod — its ID, name, dependencies, and that we have a mixin config. Notice we declare Create as a required dependency so the game won't load without it." + +```toml +modLoader = "javafml" +loaderVersion = "[4,)" +license = "MIT" + +[[mods]] +modId = "${mod_id}" +version = "${mod_version}" +displayName = "${mod_name}" +authors = "${mod_authors}" +description = '''${mod_description}''' + +[[dependencies.${mod_id}]] +modId = "neoforge" +type = "required" +versionRange = "[21,)" +ordering = "NONE" +side = "BOTH" + +[[dependencies.${mod_id}]] +modId = "minecraft" +type = "required" +versionRange = "[1.21.1]" +ordering = "NONE" +side = "BOTH" + +[[dependencies.${mod_id}]] +modId = "create" +type = "required" +versionRange = "[6.0,)" +ordering = "AFTER" +side = "BOTH" + +[[mixins]] +config = "${mod_id}.mixins.json" +``` + +--- + +## Part 3: Understanding Create's Goggle System + +**Talking point:** "Before we write any code, let's understand how Create's goggle overlay works. When you wear Engineer's Goggles and look at a block, Create's `GoggleOverlayRenderer` checks if the block entity implements the `IHaveGoggleInformation` interface. If it does, it calls `addToGoggleTooltip()` and renders whatever text you return." + +**Talking point:** "Fluid pipes in Create do NOT implement this interface by default — that's why you don't see any info when looking at pipes. We're going to add it using Mixins, which let us inject code into existing classes." + +**Talking point:** "The data we want lives in `FluidTransportBehaviour` — every pipe block entity has one. It holds a map of `PipeConnection` objects, one per connected direction. Each connection tracks the fluid flow and pressure values." + +--- + +## Part 4: The Mod Entry Point + +### 4.1 `src/main/java/com/example/pipeinspector/PipeInspector.java` + +**Talking point:** "Our mod class is almost empty. Since we're using mixins to add functionality to existing blocks, we don't need to register anything. This is about as minimal as a mod can get." + +```java +package com.example.pipeinspector; + +import net.neoforged.fml.common.Mod; + +@Mod(PipeInspector.ID) +public class PipeInspector { + public static final String ID = "pipeinspector"; +} +``` + +--- + +## Part 5: The Mixins + +**Talking point:** "Now for the core of our addon. We need two mixins — one for standard copper fluid pipes (`FluidPipeBlockEntity`), and one for glass, encased, and smart pipes (`StraightPipeBlockEntity`). Both do the same thing: they implement `IHaveGoggleInformation` and delegate to our handler class." + +### 5.1 `src/main/resources/pipeinspector.mixins.json` + +**Talking point:** "First, the mixin config file. This tells the mixin system where to find our mixin classes." + +```json +{ + "required": true, + "minVersion": "0.8", + "package": "com.example.pipeinspector.mixin", + "compatibilityLevel": "JAVA_21", + "mixins": [ + "FluidPipeGoggleMixin", + "StraightPipeGoggleMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} +``` + +### 5.2 `src/main/java/com/example/pipeinspector/mixin/FluidPipeGoggleMixin.java` + +**Talking point:** "This mixin targets `FluidPipeBlockEntity` — that's the standard copper fluid pipe. By implementing `IHaveGoggleInformation` on the mixin class, the Mixin framework will add that interface to the target class at runtime. So when Create's renderer checks `be instanceof IHaveGoggleInformation`, it will return true for pipes." + +```java +package com.example.pipeinspector.mixin; + +import java.util.List; + +import org.spongepowered.asm.mixin.Mixin; + +import com.example.pipeinspector.PipeGoggleHandler; +import com.simibubi.create.api.equipment.goggles.IHaveGoggleInformation; +import com.simibubi.create.content.fluids.pipes.FluidPipeBlockEntity; +import com.simibubi.create.foundation.blockEntity.SmartBlockEntity; + +import net.minecraft.network.chat.Component; + +@Mixin(FluidPipeBlockEntity.class) +public class FluidPipeGoggleMixin implements IHaveGoggleInformation { + + @Override + public boolean addToGoggleTooltip(List tooltip, boolean isPlayerSneaking) { + return PipeGoggleHandler.addPipeInfo(tooltip, (SmartBlockEntity) (Object) this); + } +} +``` + +**Talking point:** "Notice the cast `(SmartBlockEntity) (Object) this`. In a mixin, `this` refers to the target class instance at runtime — which IS a `SmartBlockEntity`. But the compiler doesn't know that, so we cast through `Object` first. This is a standard mixin pattern." + +### 5.3 `src/main/java/com/example/pipeinspector/mixin/StraightPipeGoggleMixin.java` + +**Talking point:** "The second mixin is identical but targets `StraightPipeBlockEntity`. This covers glass pipes, encased pipes, and smart fluid pipes — since smart pipes extend straight pipes, they inherit our mixin too." + +```java +package com.example.pipeinspector.mixin; + +import java.util.List; + +import org.spongepowered.asm.mixin.Mixin; + +import com.example.pipeinspector.PipeGoggleHandler; +import com.simibubi.create.api.equipment.goggles.IHaveGoggleInformation; +import com.simibubi.create.content.fluids.pipes.StraightPipeBlockEntity; +import com.simibubi.create.foundation.blockEntity.SmartBlockEntity; + +import net.minecraft.network.chat.Component; + +@Mixin(StraightPipeBlockEntity.class) +public class StraightPipeGoggleMixin implements IHaveGoggleInformation { + + @Override + public boolean addToGoggleTooltip(List tooltip, boolean isPlayerSneaking) { + return PipeGoggleHandler.addPipeInfo(tooltip, (SmartBlockEntity) (Object) this); + } +} +``` + +--- + +## Part 6: The Pipe Goggle Handler + +### 6.1 `src/main/java/com/example/pipeinspector/PipeGoggleHandler.java` + +**Talking point:** "This is where all the actual logic lives. We read the pipe's transport behaviour to get flow and pressure data, then format it using Create's `CreateLang` builder — the same API Create uses internally for all its goggle tooltips. This ensures our text looks consistent with the rest of Create's UI." + +**Talking point (on the BFS):** "For finding the nearest pump, we do a simple breadth-first search through connected pipes. We start at the current pipe and check each neighbor — if it's a pump, we're done. If it's another pipe, we add it to the search queue. We limit the search to Create's pump range so we don't search forever." + +```java +package com.example.pipeinspector; + +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.Set; + +import com.simibubi.create.content.fluids.FluidPropagator; +import com.simibubi.create.content.fluids.FluidTransportBehaviour; +import com.simibubi.create.content.fluids.PipeConnection; +import com.simibubi.create.content.fluids.pump.PumpBlockEntity; +import com.simibubi.create.foundation.blockEntity.SmartBlockEntity; +import com.simibubi.create.foundation.blockEntity.behaviour.BlockEntityBehaviour; +import com.simibubi.create.foundation.utility.CreateLang; + +import net.createmod.catnip.data.Couple; +import net.minecraft.ChatFormatting; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.network.chat.Component; +import net.minecraft.world.level.Level; + +public class PipeGoggleHandler { + + public static boolean addPipeInfo(List tooltip, SmartBlockEntity be) { + FluidTransportBehaviour transport = be.getBehaviour(FluidTransportBehaviour.TYPE); + if (transport == null || transport.interfaces == null) + return false; + + // Header + CreateLang.translate("pipeinspector.goggle.header") + .forGoggles(tooltip); + + boolean hasInfo = false; + + // Flow and pressure per connection + for (Map.Entry entry : transport.interfaces.entrySet()) { + Direction dir = entry.getKey(); + PipeConnection connection = entry.getValue(); + + if (connection.hasFlow()) { + PipeConnection.Flow flow = connection.flow.orElse(null); + if (flow != null && !flow.fluid.isEmpty()) { + String dirName = dir.getName().substring(0, 1).toUpperCase() + + dir.getName().substring(1); + String flowDir = flow.inbound ? "In" : "Out"; + + CreateLang.builder() + .add(Component.translatable( + flow.fluid.getHoverName().getString())) + .style(ChatFormatting.GRAY) + .text(ChatFormatting.DARK_GRAY, + " " + dirName + " ") + .add(CreateLang.builder() + .text(flow.inbound + ? ChatFormatting.GREEN + : ChatFormatting.GOLD, flowDir)) + .forGoggles(tooltip, 1); + hasInfo = true; + } + } + + if (connection.hasPressure()) { + Couple pressure = connection.getPressure(); + float inbound = pressure.getFirst(); + float outward = pressure.getSecond(); + + if (inbound != 0 || outward != 0) { + String dirName = dir.getName().substring(0, 1).toUpperCase() + + dir.getName().substring(1); + + CreateLang.builder() + .text(ChatFormatting.DARK_GRAY, dirName + " ") + .add(CreateLang.builder() + .text(ChatFormatting.AQUA, + String.format("%.1f", inbound))) + .text(ChatFormatting.DARK_GRAY, " / ") + .add(CreateLang.builder() + .text(ChatFormatting.AQUA, + String.format("%.1f", outward))) + .forGoggles(tooltip, 1); + hasInfo = true; + } + } + } + + // Pump distance + Level level = be.getLevel(); + if (level != null) { + int pumpDist = findNearestPump(level, be.getBlockPos()); + int pumpRange = FluidPropagator.getPumpRange(); + + if (pumpDist >= 0) { + int remaining = pumpRange - pumpDist; + CreateLang.translate("pipeinspector.goggle.pump_distance", + pumpDist, remaining) + .style(remaining <= 2 + ? ChatFormatting.RED + : ChatFormatting.GRAY) + .forGoggles(tooltip, 1); + } else { + CreateLang.translate("pipeinspector.goggle.no_pump") + .style(ChatFormatting.DARK_GRAY) + .forGoggles(tooltip, 1); + } + hasInfo = true; + } + + return hasInfo; + } + + /** + * BFS through connected pipes to find the nearest pump. + * Returns the distance in blocks, or -1 if no pump found + * within range. + */ + private static int findNearestPump(Level level, BlockPos start) { + int maxRange = FluidPropagator.getPumpRange(); + Set visited = new HashSet<>(); + Queue queue = new LinkedList<>(); + Queue distances = new LinkedList<>(); + + visited.add(start); + queue.add(start); + distances.add(0); + + while (!queue.isEmpty()) { + BlockPos current = queue.poll(); + int dist = distances.poll(); + + if (dist > maxRange) + continue; + + for (Direction dir : Direction.values()) { + BlockPos neighbor = current.relative(dir); + if (visited.contains(neighbor)) + continue; + visited.add(neighbor); + + // Check if neighbor is a pump + if (level.getBlockEntity(neighbor) + instanceof PumpBlockEntity) { + return dist + 1; + } + + // Check if neighbor is a pipe (has fluid transport) + FluidTransportBehaviour pipe = BlockEntityBehaviour + .get(level, neighbor, FluidTransportBehaviour.TYPE); + if (pipe != null) { + queue.add(neighbor); + distances.add(dist + 1); + } + } + } + + return -1; + } +} +``` + +**Talking point (on CreateLang):** "The `CreateLang.translate()` and `CreateLang.builder()` methods give us Create's styled text builder. The `.forGoggles(tooltip)` call formats the line with the right indentation and spacing that matches all other goggle overlays. The second parameter `1` adds an extra indent level for sub-items." + +**Talking point (on pressure display):** "The pressure is stored as a `Couple` — that's Create's pair type. First value is inbound pressure, second is outward. When a pump pushes fluid through a pipe, you'll see non-zero pressure values that indicate which direction the fluid is being pushed." + +**Talking point (on pump coloring):** "We color the pump distance red if there are only 2 or fewer blocks of range remaining. This gives players a quick visual warning that they're approaching the pump's maximum range." + +--- + +## Part 7: Localization + +### 7.1 `src/main/resources/assets/pipeinspector/lang/en_us.json` + +**Talking point:** "Finally, our language file. Create's `CreateLang.translate()` automatically prepends our mod ID, so `pipeinspector.goggle.header` is the full key." + +```json +{ + "pipeinspector.goggle.header": "Pipe Status", + "pipeinspector.goggle.pump_distance": "Pump: %s blocks (%s remaining)", + "pipeinspector.goggle.no_pump": "No pump connected" +} +``` + +--- + +## Part 8: Testing + +**Talking point:** "Let's test it. Run `./gradlew runClient`, load into a creative world, and set up a simple pipe network." + +### Test Setup +1. Place a fluid tank with water +2. Connect fluid pipes from the tank +3. Place a mechanical pump powered by a shaft +4. Extend pipes past the pump +5. Put on Engineer's Goggles (Create's helmet item) +6. Look at different pipes in the network + +### What You Should See +- **Header**: "Pipe Status" +- **Flow lines**: The fluid name (e.g. "Water"), the direction (North, South, etc.), and whether flow is inbound or outbound +- **Pressure lines**: Per-direction pressure values showing how the pump distributes force +- **Pump distance**: How many blocks away the nearest pump is, and how many blocks of range remain +- **At pipes far from pump**: Red coloring when only 2 blocks of range remain +- **At disconnected pipes**: "No pump connected" in gray + +--- + +## Part 9: Recap & Project Structure + +**Talking point:** "Let's recap what we built. Our entire addon is just 9 files." + +``` +CreatePipeInspector/ +├── build.gradle # Build config with Create dependency +├── gradle.properties # Version numbers +├── settings.gradle # Project name + NeoForge repo +├── src/main/ +│ ├── java/com/example/pipeinspector/ +│ │ ├── PipeInspector.java # Mod entry point (3 lines of real code) +│ │ ├── PipeGoggleHandler.java # Tooltip logic + pump finder (~100 lines) +│ │ └── mixin/ +│ │ ├── FluidPipeGoggleMixin.java # Adds goggles to copper pipes +│ │ └── StraightPipeGoggleMixin.java # Adds goggles to glass/encased/smart pipes +│ └── resources/ +│ ├── pipeinspector.mixins.json # Mixin config +│ ├── META-INF/neoforge.mods.toml # Mod metadata +│ └── assets/pipeinspector/lang/en_us.json # Translations +``` + +**Talking point:** "The key takeaway is how simple a Create addon can be. We didn't need to register any blocks, items, or renderers. Create's goggle system is designed to be extended — you just implement the interface, and the overlay appears. Mixins let us add that interface to classes we don't own." + +**Talking point:** "From here, you could extend this addon with more features — like showing fluid throughput rates, color-coded pressure visualization in the world, or even a config to customize what info is displayed. But that's for another video." + +--- + +## Key APIs Reference + +| API | What It Does | +|-----|-------------| +| `IHaveGoggleInformation` | Interface that makes a block entity show info in the goggle overlay | +| `FluidTransportBehaviour.TYPE` | Behaviour type to look up pipe transport data on a SmartBlockEntity | +| `PipeConnection` | Holds flow and pressure data for one direction of a pipe | +| `PipeConnection.Flow` | The actual flow — fluid type, direction (inbound/outbound), progress | +| `PipeConnection.getPressure()` | Returns `Couple` — [inbound, outward] pressure values | +| `FluidPropagator.getPumpRange()` | Max distance a pump can push fluid (from Create's config) | +| `BlockEntityBehaviour.get(level, pos, type)` | Look up a behaviour on any SmartBlockEntity at a position | +| `CreateLang.translate().forGoggles(tooltip)` | Create's styled tooltip builder with proper goggle formatting | diff --git a/src/main/java/de/devin/cbbees/mixin/SchematicPromptScreenMixin.java b/src/main/java/de/devin/cbbees/mixin/SchematicPromptScreenMixin.java deleted file mode 100644 index 9714252..0000000 --- a/src/main/java/de/devin/cbbees/mixin/SchematicPromptScreenMixin.java +++ /dev/null @@ -1,74 +0,0 @@ -package de.devin.cbbees.mixin; - -import com.simibubi.create.content.schematics.client.SchematicPromptScreen; -import com.simibubi.create.foundation.gui.AllIcons; -import com.simibubi.create.foundation.gui.widget.IconButton; -import de.devin.cbbees.content.schematics.client.GroupPickerScreen; -import de.devin.cbbees.content.schematics.client.SchematicGroupManager; -import kotlin.Unit; -import kotlin.jvm.functions.Function1; -import net.createmod.catnip.gui.AbstractSimiScreen; -import net.minecraft.client.Minecraft; -import net.minecraft.client.gui.components.EditBox; -import net.minecraft.network.chat.Component; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.Shadow; -import org.spongepowered.asm.mixin.Unique; -import org.spongepowered.asm.mixin.injection.At; -import org.spongepowered.asm.mixin.injection.Inject; -import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; - -/** - * Mixin for Create's {@link SchematicPromptScreen} (the Schematic & Quill save dialog). - * Adds a "Group" button that opens a {@link GroupPickerScreen} for assigning the - * schematic to a group before saving. - */ -@Mixin(value = SchematicPromptScreen.class, remap = false) -public abstract class SchematicPromptScreenMixin extends AbstractSimiScreen { - - @Shadow - private EditBox nameField; - - @Unique - private String ccr$selectedGroup = ""; - - @Unique - private IconButton ccr$groupButton; - - @Inject(method = "init", at = @At("TAIL")) - private void ccr$addGroupButton(CallbackInfo ci) { - int x = guiLeft; - int y = guiTop + 2; - - // Add group button below the name field area - ccr$groupButton = new IconButton(x + 80, y + 53, AllIcons.I_TOOLBOX); - ccr$groupButton.setToolTip(Component.translatable("gui.cbbees.schematic_prompt.group")); - ccr$groupButton.withCallback(() -> { - Function1 callback = (selectedPath) -> { - ccr$selectedGroup = selectedPath; - return Unit.INSTANCE; - }; - // Pass this screen as parentScreen so GroupPicker returns here on close/confirm - Minecraft.getInstance().setScreen(new GroupPickerScreen( - callback, ccr$selectedGroup, (SchematicPromptScreen) (Object) this - )); - }); - addRenderableWidget(ccr$groupButton); - } - - @Inject(method = "confirm", at = @At("HEAD")) - private void ccr$saveGroupOnConfirm(boolean convertImmediately, CallbackInfo ci) { - if (nameField == null) return; - String filename = nameField.getValue(); - if (filename == null || filename.isEmpty()) return; - - // Ensure .nbt extension for the group mapping - if (!filename.endsWith(".nbt")) { - filename = filename + ".nbt"; - } - - if (!ccr$selectedGroup.isEmpty()) { - SchematicGroupManager.INSTANCE.setGroup(filename, ccr$selectedGroup); - } - } -} diff --git a/src/main/kotlin/de/devin/cbbees/CreateBuzzyBeez.kt b/src/main/kotlin/de/devin/cbbees/CreateBuzzyBeez.kt index 337c438..cee3e79 100644 --- a/src/main/kotlin/de/devin/cbbees/CreateBuzzyBeez.kt +++ b/src/main/kotlin/de/devin/cbbees/CreateBuzzyBeez.kt @@ -105,6 +105,11 @@ object CreateBuzzyBeez { AllPackets.register(it) } + MOD_BUS.addListener { event -> + LOGGER.info("[cbbees] Registering game tests...") + de.devin.cbbees.gametest.CBBeesGameTests.onRegisterGameTests(event) + } + if (net.neoforged.fml.loading.FMLEnvironment.dist.isClient) { MOD_BUS.register(CCRClientEvents::class.java) NeoForge.EVENT_BUS.register(BeeNetworkClientEvents::class.java) diff --git a/src/main/kotlin/de/devin/cbbees/config/CBBeesConfig.kt b/src/main/kotlin/de/devin/cbbees/config/CBBeesConfig.kt index 8332f6d..dcbd054 100644 --- a/src/main/kotlin/de/devin/cbbees/config/CBBeesConfig.kt +++ b/src/main/kotlin/de/devin/cbbees/config/CBBeesConfig.kt @@ -29,7 +29,6 @@ object CBBeesConfig { val honeyEfficiencyCarryBonus: ModConfigSpec.IntValue val honeyEfficiencyFuelReduction: ModConfigSpec.DoubleValue val honeyTankCapacityBonus: ModConfigSpec.IntValue - val reinforcedPlatingSpringBonus: ModConfigSpec.DoubleValue // Drone view settings val droneBaseRange: ModConfigSpec.DoubleValue @@ -72,27 +71,27 @@ object CBBeesConfig { .push("beehive") maxBeesPerHive = builder - .comment("Maximum number of active bees per hive") + .comment("Hard cap on active bees per hive, after RPM and upgrade scaling. Even with max RPM and upgrades, a hive will never exceed this.") .defineInRange("maxBeesPerHive", 16, 1, 64) minActiveBeesAtRpm = builder - .comment("Minimum active bees when the hive has any RPM, so even slow shafts deploy at least this many bees") + .comment("Minimum active bees when the hive has any RPM. Ensures even slow shafts deploy at least this many bees.") .defineInRange("minActiveBeesAtRpm", 1, 0, 64) hiveBaseRange = builder - .comment("Base work range of a beehive before RPM scaling (at minimum RPM)") + .comment("Base work range (blocks) of a beehive at minimum RPM, before RPM scaling is applied.") .defineInRange("hiveBaseRange", 1, 0, 128) hiveRangePerRpm = builder - .comment("Work range added per RPM: range = baseRange + RPM * this (16 RPM -> 5, 256 RPM -> 65)") + .comment("Work range added per RPM. Formula: range = baseRange + RPM * this. Example: 64 RPM * 0.25 = 16 + 1 base = 17 blocks.") .defineInRange("hiveRangePerRpm", 0.25, 0.01, 10.0) hiveRpmSpeedDivisor = builder - .comment("RPM divisor for bee speed and spring efficiency: multiplier = 1 + RPM / this value") + .comment("RPM divisor for bee flight speed and spring efficiency. Formula: multiplier = 1 + RPM / this. Higher = slower scaling.") .defineInRange("hiveRpmSpeedDivisor", 256.0, 1.0, 1024.0) hiveRpmBeeDivisor = builder - .comment("RPM divisor for workforce scaling: extra bees = RPM / this value") + .comment("RPM divisor for max active bees. Formula: extra bees = RPM / this. Example: 64 RPM / 8 = 8 extra bees.") .defineInRange("hiveRpmBeeDivisor", 8.0, 1.0, 256.0) builder.pop() @@ -105,11 +104,11 @@ object CBBeesConfig { .define("beePickupItems", true) defaultMaxActiveBees = builder - .comment("Default max active robots before RPM/upgrade scaling") + .comment("Default max active bees before RPM and upgrade scaling. This is the base value that RPM bonuses are added to.") .defineInRange("defaultMaxActiveBees", 4, 1, 64) defaultWorkRange = builder - .comment("Default work range for BeeContext before hive overrides") + .comment("Default work range (blocks) for portable beehives and other non-hive bee sources.") .defineInRange("defaultWorkRange", 32.0, 1.0, 256.0) builder.pop() @@ -141,10 +140,6 @@ object CBBeesConfig { .comment("Extra honey capacity per Honey Tank upgrade") .defineInRange("honeyTankCapacityBonus", 200, 50, 5000) - reinforcedPlatingSpringBonus = builder - .comment("Spring efficiency bonus per Reinforced Plating upgrade (0.25 = +25%)") - .defineInRange("reinforcedPlatingSpringBonus", 0.25, 0.01, 2.0) - builder.pop() builder.comment("Drone View Settings — controls drone camera behavior") @@ -180,11 +175,11 @@ object CBBeesConfig { .defineInRange("springDrainFlight", 0.0001, 0.0, 1.0) springDrainPickup = builder - .comment("Spring tension drained per item pickup (BumbleBee)") + .comment("Spring tension drained per item pickup from a logistics port (construction and transport bees).") .defineInRange("springDrainPickup", 0.01, 0.0, 1.0) springDrainDeposit = builder - .comment("Spring tension drained per item deposit (BumbleBee)") + .comment("Spring tension drained per item deposit to a logistics port (construction and transport bees).") .defineInRange("springDrainDeposit", 0.01, 0.0, 1.0) springRechargeTicks = builder diff --git a/src/main/kotlin/de/devin/cbbees/content/backpack/PortableBeehiveItem.kt b/src/main/kotlin/de/devin/cbbees/content/backpack/PortableBeehiveItem.kt index 4306951..ee627e0 100644 --- a/src/main/kotlin/de/devin/cbbees/content/backpack/PortableBeehiveItem.kt +++ b/src/main/kotlin/de/devin/cbbees/content/backpack/PortableBeehiveItem.kt @@ -76,6 +76,7 @@ class PortableBeehiveItem(properties: Properties) : ArmorItem(ArmorMaterials.IRO if (this.renderer == null) { this.renderer = PortableBeehiveRenderer() } + this.renderer!!.updateRenderState(itemStack, livingEntity) this.renderer!!.prepForRender(livingEntity, itemStack, armorSlot, original) return this.renderer!! } diff --git a/src/main/kotlin/de/devin/cbbees/content/backpack/client/BeehiveTooltipComponent.kt b/src/main/kotlin/de/devin/cbbees/content/backpack/client/BeehiveTooltipComponent.kt index f4d03b2..8fdce56 100644 --- a/src/main/kotlin/de/devin/cbbees/content/backpack/client/BeehiveTooltipComponent.kt +++ b/src/main/kotlin/de/devin/cbbees/content/backpack/client/BeehiveTooltipComponent.kt @@ -89,7 +89,6 @@ class BeehiveTooltipComponent(val data: BeehiveTooltipData) : ClientTooltipCompo UpgradeType.SOFT_TOUCH -> 0xFF00FF88.toInt() // Green UpgradeType.DROP_ITEMS -> 0xFFFF4444.toInt() // Red UpgradeType.HONEY_TANK -> 0xFFD97F00.toInt() // Amber - UpgradeType.REINFORCED_PLATING -> 0xFF8888AA.toInt() // Steel UpgradeType.DRONE_VIEW -> 0xFF9933FF.toInt() // Purple UpgradeType.DRONE_RANGE -> 0xFF00CCCC.toInt() // Cyan } diff --git a/src/main/kotlin/de/devin/cbbees/content/backpack/client/PortableBeehiveModel.kt b/src/main/kotlin/de/devin/cbbees/content/backpack/client/PortableBeehiveModel.kt index 19137c9..c620ff9 100644 --- a/src/main/kotlin/de/devin/cbbees/content/backpack/client/PortableBeehiveModel.kt +++ b/src/main/kotlin/de/devin/cbbees/content/backpack/client/PortableBeehiveModel.kt @@ -6,6 +6,7 @@ import net.minecraft.resources.ResourceLocation import software.bernie.geckolib.model.GeoModel class PortableBeehiveModel : GeoModel() { + override fun getModelResource(animatable: PortableBeehiveItem): ResourceLocation { return CreateBuzzyBeez.asResource("geo/portable_beehive.geo.json") } diff --git a/src/main/kotlin/de/devin/cbbees/content/backpack/client/PortableBeehiveRenderer.kt b/src/main/kotlin/de/devin/cbbees/content/backpack/client/PortableBeehiveRenderer.kt index d43e426..86e646d 100644 --- a/src/main/kotlin/de/devin/cbbees/content/backpack/client/PortableBeehiveRenderer.kt +++ b/src/main/kotlin/de/devin/cbbees/content/backpack/client/PortableBeehiveRenderer.kt @@ -1,6 +1,13 @@ package de.devin.cbbees.content.backpack.client import de.devin.cbbees.content.backpack.PortableBeehiveItem +import net.minecraft.world.entity.LivingEntity +import net.minecraft.world.item.ItemStack import software.bernie.geckolib.renderer.GeoArmorRenderer -class PortableBeehiveRenderer : GeoArmorRenderer(PortableBeehiveModel()) +class PortableBeehiveRenderer : GeoArmorRenderer(PortableBeehiveModel()) { + + fun updateRenderState(stack: ItemStack, entity: LivingEntity) { + // No-op — model selection no longer depends on stack data + } +} diff --git a/src/main/kotlin/de/devin/cbbees/content/backpack/client/UpgradeGridWidget.kt b/src/main/kotlin/de/devin/cbbees/content/backpack/client/UpgradeGridWidget.kt index 1dbf4e0..9367f01 100644 --- a/src/main/kotlin/de/devin/cbbees/content/backpack/client/UpgradeGridWidget.kt +++ b/src/main/kotlin/de/devin/cbbees/content/backpack/client/UpgradeGridWidget.kt @@ -247,7 +247,6 @@ class UpgradeGridWidget( UpgradeType.SOFT_TOUCH -> ItemStack(AllItems.SOFT_TOUCH.get()) UpgradeType.DROP_ITEMS -> ItemStack(AllItems.DROP_ITEMS.get()) UpgradeType.HONEY_TANK -> ItemStack(AllItems.HONEY_TANK.get()) - UpgradeType.REINFORCED_PLATING -> ItemStack(AllItems.REINFORCED_PLATING.get()) UpgradeType.DRONE_VIEW -> ItemStack(AllItems.DRONE_VIEW.get()) UpgradeType.DRONE_RANGE -> ItemStack(AllItems.DRONE_RANGE.get()) } @@ -259,7 +258,6 @@ class UpgradeGridWidget( UpgradeType.SOFT_TOUCH -> 0xFF00FF88.toInt() UpgradeType.DROP_ITEMS -> 0xFFFF4444.toInt() UpgradeType.HONEY_TANK -> 0xFFD97F00.toInt() - UpgradeType.REINFORCED_PLATING -> 0xFF8888AA.toInt() UpgradeType.DRONE_VIEW -> 0xFF9933FF.toInt() UpgradeType.DRONE_RANGE -> 0xFF00CCCC.toInt() } diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/MechanicalBeeEntity.kt b/src/main/kotlin/de/devin/cbbees/content/bee/MechanicalBeeEntity.kt index 0466700..3ecca39 100644 --- a/src/main/kotlin/de/devin/cbbees/content/bee/MechanicalBeeEntity.kt +++ b/src/main/kotlin/de/devin/cbbees/content/bee/MechanicalBeeEntity.kt @@ -107,6 +107,7 @@ class MechanicalBeeEntity(entityType: EntityType, level: Leve override fun getWorkerY(): Double = getY() override fun getWorkerZ(): Double = getZ() + override val hiveId: UUID? get() = homeId override var networkId: UUID = UUID.randomUUID() /** Tick when spring recharge completes at hive. -1 = not recharging. */ @@ -286,12 +287,25 @@ class MechanicalBeeEntity(entityType: EntityType, level: Leve super.tick() if (level().isClientSide) return - // Legacy entity bees from old saves — drop as item and discard. + // Legacy entity bees from old saves — return to hive or drop as item. // All new bees use ServerBeeManager (non-entity system). if (!isDrone) { val beeItem = beeItemStack() - level().addFreshEntity(ItemEntity(level(), x, y, z, beeItem)) - dropInventory() + val hive = beehive() + if (hive != null && hive.returnBee(beeItem)) { + // Returned to hive — drop carried items at hive position + val hivePos = hive.pos + for (i in 0 until inventory.containerSize) { + val stack = inventory.getItem(i) + if (!stack.isEmpty) { + level().addFreshEntity(ItemEntity(level(), hivePos.x + 0.5, hivePos.y + 1.0, hivePos.z + 0.5, stack.copy())) + } + } + } else { + // Hive unavailable — drop bee item + inventory at current position + level().addFreshEntity(ItemEntity(level(), x, y, z, beeItem)) + dropInventory() + } discard() return } @@ -308,6 +322,20 @@ class MechanicalBeeEntity(entityType: EntityType, level: Leve } } + /** + * Skip client-side position interpolation for drones — snap instantly + * so WASD controls feel crisp rather than spongy. + */ + override fun lerpTo(x: Double, y: Double, z: Double, yRot: Float, xRot: Float, steps: Int) { + if (isDrone) { + setPos(x, y, z) + setYRot(yRot) + setXRot(xRot) + } else { + super.lerpTo(x, y, z, yRot, xRot, steps) + } + } + private fun tickDrone() { val owner = getOwnerPlayer() ?: run { discard() diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/MechanicalBumbleBeeEntity.kt b/src/main/kotlin/de/devin/cbbees/content/bee/MechanicalBumbleBeeEntity.kt index 63714cb..03ef1cd 100644 --- a/src/main/kotlin/de/devin/cbbees/content/bee/MechanicalBumbleBeeEntity.kt +++ b/src/main/kotlin/de/devin/cbbees/content/bee/MechanicalBumbleBeeEntity.kt @@ -205,15 +205,24 @@ class MechanicalBumbleBeeEntity(entityType: EntityType, level override fun tick() { super.tick() if (level().isClientSide) return - // Legacy entity bee from old save — drop as item and discard + // Legacy entity bee from old save — return to hive or drop as item val beeItem = beeItemStack() - level().addFreshEntity(ItemEntity(level(), x, y, z, beeItem)) - // Drop inventory items - for (i in 0 until inventory.containerSize) { - val stack = inventory.getItem(i) - if (!stack.isEmpty) { - level().addFreshEntity(ItemEntity(level(), x, y, z, stack.copy())) - inventory.setItem(i, ItemStack.EMPTY) + val hive = beehive() + if (hive != null && hive.returnBee(beeItem)) { + val hivePos = hive.pos + for (i in 0 until inventory.containerSize) { + val stack = inventory.getItem(i) + if (!stack.isEmpty) { + level().addFreshEntity(ItemEntity(level(), hivePos.x + 0.5, hivePos.y + 1.0, hivePos.z + 0.5, stack.copy())) + } + } + } else { + level().addFreshEntity(ItemEntity(level(), x, y, z, beeItem)) + for (i in 0 until inventory.containerSize) { + val stack = inventory.getItem(i) + if (!stack.isEmpty) { + level().addFreshEntity(ItemEntity(level(), x, y, z, stack.copy())) + } } } discard() diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/brain/behavior/DropOffItemsBehavior.kt b/src/main/kotlin/de/devin/cbbees/content/bee/brain/behavior/DropOffItemsBehavior.kt index 540fda3..9c68ce1 100644 --- a/src/main/kotlin/de/devin/cbbees/content/bee/brain/behavior/DropOffItemsBehavior.kt +++ b/src/main/kotlin/de/devin/cbbees/content/bee/brain/behavior/DropOffItemsBehavior.kt @@ -45,7 +45,7 @@ class DropOffItemsBehavior : Behavior( if (excess.isEmpty()) return val network = entity.network() - val dropOffPort = network?.findDropOff(excess.first()) + val dropOffPort = network?.findDropOff(excess.first(), entity.homeId) if (dropOffPort == null) { // Try giving items to the owner player (portable beehive bees) diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/client/BeeTargetLineHandler.kt b/src/main/kotlin/de/devin/cbbees/content/bee/client/BeeTargetLineHandler.kt index 7a5088f..93995f1 100644 --- a/src/main/kotlin/de/devin/cbbees/content/bee/client/BeeTargetLineHandler.kt +++ b/src/main/kotlin/de/devin/cbbees/content/bee/client/BeeTargetLineHandler.kt @@ -30,20 +30,27 @@ object BeeTargetLineHandler { @SubscribeEvent @JvmStatic fun onClientTick(event: ClientTickEvent.Post) { + val mc = Minecraft.getInstance() + val profiler = mc.level?.profiler + // Update pause state for wall-clock freeze, tick rotation smoothing - val paused = Minecraft.getInstance().isPaused + val paused = mc.isPaused BeeClientTracker.setPaused(paused) - if (!paused) BeeClientTracker.tickClient() + if (!paused) { + profiler?.push("cbbees_beeClientTick") + BeeClientTracker.tickClient() + profiler?.pop() + } if (!CBBeesClientConfig.showBeeTargetLines.get()) return - val mc = Minecraft.getInstance() val player = mc.player ?: return mc.level ?: return if (mc.screen != null) return if (!GogglesItem.isWearingGoggles(player)) return + profiler?.push("cbbees_targetLines") val lookedAtEntity = mc.crosshairPickEntity val playerPos = player.position() @@ -71,5 +78,6 @@ object BeeTargetLineHandler { .colored(color) .lineWidth(1 / 16f) } + profiler?.pop() } } diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/client/BeeWorldRenderer.kt b/src/main/kotlin/de/devin/cbbees/content/bee/client/BeeWorldRenderer.kt index bc3b7f3..f40cbae 100644 --- a/src/main/kotlin/de/devin/cbbees/content/bee/client/BeeWorldRenderer.kt +++ b/src/main/kotlin/de/devin/cbbees/content/bee/client/BeeWorldRenderer.kt @@ -32,14 +32,14 @@ object BeeWorldRenderer { private val BUMBLE_MODEL = CreateBuzzyBeez.asResource("geo/mechanical_bumble_bee.geo.json") private val BUMBLE_TEXTURE = CreateBuzzyBeez.asResource("textures/entity/mechanical_bumble_bee.png") - private val WING_BONES = setOf("lwing", "rwing") - private const val LIGHT_BONE = "light" - private const val LIGHT_ALPHA = 0.45f + private val WING_BONES = setOf("leftwing_bone", "rightwing_bone") + private const val GEAR_BONE = "gear2" private const val MAX_RENDER_DIST_SQ = 64.0 * 64.0 private const val FULL_BRIGHT_LIGHT = 0xF000F0 private const val WING_FLAP_SPEED = 25f private const val WING_FLAP_AMPLITUDE = 0.6f + private const val GEAR_ROTATION_PERIOD = 4f @SubscribeEvent @JvmStatic @@ -47,7 +47,8 @@ object BeeWorldRenderer { if (event.stage != RenderLevelStageEvent.Stage.AFTER_ENTITIES) return val mc = Minecraft.getInstance() - mc.level ?: return + val level = mc.level ?: return + val profiler = level.profiler val camPos = mc.gameRenderer.mainCamera.position val partialTick = event.partialTick.realtimeDeltaTicks @@ -57,6 +58,7 @@ object BeeWorldRenderer { val flightBees = BeeClientTracker.getFlightBees() if (flightBees.isEmpty()) return + profiler.push("cbbees_beeRender") val maxDistSq = MAX_RENDER_DIST_SQ val time = System.nanoTime() / 1_000_000_000.0f @@ -82,6 +84,7 @@ object BeeWorldRenderer { } bufferSource.endBatch() + profiler.pop() } private fun renderModel( @@ -92,23 +95,17 @@ object BeeWorldRenderer { time: Float, ) { val bakedModel: BakedGeoModel = GeckoLibCache.getBakedModels()[modelRes] ?: return - val opaqueBuffer = bufferSource.getBuffer(RenderType.entityCutoutNoCull(textureRes)) + val renderType = RenderType.entityCutoutNoCull(textureRes) + val buffer = bufferSource.getBuffer(renderType) val packedLight = FULL_BRIGHT_LIGHT - // Wing flap oscillation — same for both bee and bumble bee models + // Wing flap oscillation val wingAngle = sin(time * WING_FLAP_SPEED) * WING_FLAP_AMPLITUDE + // Gear spin — full rotation over ~4 seconds on X axis + val gearAngle = (time % GEAR_ROTATION_PERIOD) / GEAR_ROTATION_PERIOD * Math.PI.toFloat() * 2f - // Opaque pass — everything except the light bone bakedModel.topLevelBones.forEach { bone -> - if (bone.name != LIGHT_BONE) { - renderBone(poseStack, opaqueBuffer, bone, packedLight, wingAngle, 1f) - } - } - - // Translucent pass — light bone only (if present) - bakedModel.topLevelBones.find { it.name == LIGHT_BONE }?.let { lightBone -> - val translucentBuffer = bufferSource.getBuffer(RenderType.entityTranslucent(textureRes)) - renderBone(poseStack, translucentBuffer, lightBone, packedLight, wingAngle, LIGHT_ALPHA) + renderBone(poseStack, buffer, bone, packedLight, wingAngle, gearAngle) } } @@ -118,16 +115,13 @@ object BeeWorldRenderer { bone: GeoBone, packedLight: Int, wingAngle: Float, - alpha: Float, + gearAngle: Float, ) { if (bone.isHidden) return poseStack.pushPose() - // Mirror the pivot X for left wing — both wings share pivot [-3, 7, -2] - // in the model, but lwing's cube is on the positive X side - val rawPx = bone.pivotX / 16f - val px = if (bone.name == "lwing") -rawPx else rawPx + val px = bone.pivotX / 16f val py = bone.pivotY / 16f val pz = bone.pivotZ / 16f @@ -140,25 +134,27 @@ object BeeWorldRenderer { poseStack.mulPose(Axis.XP.rotation(bone.rotX)) } - // Wing flap — lwing and rwing mirror each other on Z axis - if (bone.name in WING_BONES) { - val sign = if (bone.name == "lwing") 1f else -1f - poseStack.mulPose(Axis.ZP.rotation(wingAngle * sign)) + // Animated bones + when (bone.name) { + in WING_BONES -> { + val sign = if (bone.name.startsWith("left")) 1f else -1f + poseStack.mulPose(Axis.ZP.rotation(wingAngle * sign)) + } + GEAR_BONE -> { + poseStack.mulPose(Axis.YP.rotation(gearAngle)) + } } poseStack.translate(-px.toDouble(), -py.toDouble(), -pz.toDouble()) // Render cubes - val isWing = bone.name in WING_BONES val matrix = poseStack.last() bone.cubes.forEach { cube -> cube.quads().forEach { quad -> val normal = quad.normal() - // Wings are zero-height planes — skip the bottom face to prevent z-fighting - if (isWing && normal.y() < 0) return@forEach quad.vertices().forEach { vertex -> buffer.addVertex(matrix.pose(), vertex.position().x(), vertex.position().y(), vertex.position().z()) - .setColor(1f, 1f, 1f, alpha) + .setColor(1f, 1f, 1f, 1f) .setUv(vertex.texU(), vertex.texV()) .setOverlay(OverlayTexture.NO_OVERLAY) .setLight(packedLight) @@ -169,7 +165,7 @@ object BeeWorldRenderer { // Recurse children bone.childBones.forEach { child -> - renderBone(poseStack, buffer, child, packedLight, wingAngle, alpha) + renderBone(poseStack, buffer, child, packedLight, wingAngle, gearAngle) } poseStack.popPose() diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/flight/CheckpointActions.kt b/src/main/kotlin/de/devin/cbbees/content/bee/flight/CheckpointActions.kt index 4006f71..136a960 100644 --- a/src/main/kotlin/de/devin/cbbees/content/bee/flight/CheckpointActions.kt +++ b/src/main/kotlin/de/devin/cbbees/content/bee/flight/CheckpointActions.kt @@ -46,19 +46,33 @@ class GatherFromPort(private val items: List) : CheckpointAction { override fun onArrival(bee: ServerBeeData, level: ServerLevel, gameTime: Long): Boolean { val network = bee.network() ?: return false + var allGathered = true items.forEach { item -> if (bee.isInventoryFull()) return@forEach val searchStack = item.copyWithCount(1) - val provider = network.findAvailableProvider(searchStack, bee.id) ?: return@forEach - if (provider is PortableBeeHive) return@forEach + val provider = network.findAvailableProvider(searchStack, bee.id) ?: run { + allGathered = false + return@forEach + } + if (provider is PortableBeeHive) { allGathered = false; return@forEach } if (provider.hasItemStack(item) && provider.removeItemStack(item)) { val remainder = bee.addToInventory(item.copy()) if (!remainder.isEmpty) provider.addItemStack(remainder) bee.consumeSpring(CBBeesConfig.springDrainPickup.get()) + } else { + allGathered = false } } - return true + + if (allGathered) return true + + // Items not available — another bee took them or provider was emptied. + // Return home immediately and release the batch so the stall system + // can report it and the batch gets redispatched when items are restocked. + bee.currentTask?.releaseWithoutRetry() + bee.springTension = -9999f + return false } } @@ -79,9 +93,9 @@ class ExecuteBeeAction( private val task: BeeTask, ) : CheckpointAction { private var activated = false + private var failTicks = 0 override fun onArrival(bee: ServerBeeData, level: ServerLevel, gameTime: Long): Boolean { - // Call onActivate once when first arriving (resolves dynamic targets like DropOff ports) if (!activated) { beeAction.onActivate(bee) activated = true @@ -93,16 +107,28 @@ class ExecuteBeeAction( val done = beeAction.execute(level, bee, bee.getBeeContext()) if (done) { + failTicks = 0 val drain = if (beeAction is RemoveBlockAction) CBBeesConfig.springDrainBreak.get() else CBBeesConfig.springDrainPlace.get() bee.consumeSpring(drain) - // Mark the task as completed and advance the batch task.complete() bee.currentTask?.advance() + } else { + failTicks++ + // If the action fails repeatedly (e.g., missing materials at placement), + // release the batch and return home rather than looping forever. + if (failTicks >= MAX_ACTION_FAIL_TICKS) { + bee.currentTask?.releaseWithoutRetry() + bee.springTension = -9999f + } } return done } + + companion object { + private const val MAX_ACTION_FAIL_TICKS = 20 // 1 second + } } /** Global per-tick throttle for block operations across all bees. */ @@ -148,10 +174,15 @@ class CheckForNextWork : CheckpointAction { FlightPlanComputer.computeAsync( bee, nextBatch, network, level ) { plan -> + if (plan == null) { + // Can't build plan (missing materials) — release without retry + nextBatch.releaseWithoutRetry() + bee.currentTask = null + return@computeAsync + } bee.flightPlan = plan bee.planStartTick = level.gameTime bee.currentCheckpointIndex = 0 - // Skip FlyThrough at index 0, start flying to index 1 if (plan.checkpoints.size > 1) { val travel = FlightPlan.travelTicks( plan.checkpoints[0].pos, plan.checkpoints[1].pos, plan.speed @@ -159,7 +190,6 @@ class CheckForNextWork : CheckpointAction { bee.currentCheckpointIndex = 1 bee.nextCheckpointArrivalTick = level.gameTime + travel } - // Client starts at 0 so it renders the full flight from current pos ServerBeeManager.broadcastFlightPlan(bee, plan, clientStartIndex = 0) } return true // advance past this checkpoint (plan will be replaced async) @@ -302,7 +332,7 @@ class DropOffItems : CheckpointAction { val contents = bee.getInventoryContents() if (contents.isEmpty()) return true - val port = bee.network()?.findDropOff(contents.first()) + val port = bee.network()?.findDropOff(contents.first(), bee.hiveId) contents.forEach { item -> if (port != null) { val remainder = port.addItemStack(item.copy()) diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/flight/FlightPlanComputer.kt b/src/main/kotlin/de/devin/cbbees/content/bee/flight/FlightPlanComputer.kt index bc7e2a3..e9372d0 100644 --- a/src/main/kotlin/de/devin/cbbees/content/bee/flight/FlightPlanComputer.kt +++ b/src/main/kotlin/de/devin/cbbees/content/bee/flight/FlightPlanComputer.kt @@ -59,24 +59,23 @@ object FlightPlanComputer { batch: TaskBatch, network: BeeNetwork, level: ServerLevel, - onComplete: (FlightPlan) -> Unit, + onComplete: (FlightPlan?) -> Unit, ) { // Snapshot: build raw checkpoints on the server thread (reads network/task data) val rawCheckpoints = buildRawConstructionCheckpoints(bee, batch, network) + if (rawCheckpoints == null) { + // Can't build a valid plan (e.g., missing materials with no provider). + // Return null so the caller can release the batch. + level.server.execute { onComplete(null) } + return + } // Snapshot: collect all block positions that need collision checks val collisionSnapshot = snapshotCollisions(rawCheckpoints.map { it.pos }, level) executor.submit { - // Off-thread: insert obstacle waypoints using the snapshot (no Level access needed) val finalCheckpoints = insertObstacleWaypointsFromSnapshot(rawCheckpoints, collisionSnapshot, level) - - val plan = FlightPlan(bee.id, BeeType.CONSTRUCTION, DEFAULT_SPEED, finalCheckpoints) - - // Deliver result back on the server thread. server.execute drains - // during runAllTasks() at the start of the next tick — before our - // ServerTickScheduler and before ServerBeeManager.tickAll(), so the - // flight plan is set before the bee advances any checkpoints. + val plan = FlightPlan(bee.id, bee.type, DEFAULT_SPEED, finalCheckpoints) level.server.execute { onComplete(plan) } } } @@ -148,10 +147,10 @@ object FlightPlanComputer { batch: TaskBatch, network: BeeNetwork, level: ServerLevel? = null - ): FlightPlan { - val raw = buildRawConstructionCheckpoints(bee, batch, network) + ): FlightPlan? { + val raw = buildRawConstructionCheckpoints(bee, batch, network) ?: return null val checkpoints = if (level != null) insertObstacleWaypoints(raw, level) else raw - return FlightPlan(bee.id, BeeType.CONSTRUCTION, DEFAULT_SPEED, checkpoints) + return FlightPlan(bee.id, bee.type, DEFAULT_SPEED, checkpoints) } /** @@ -161,25 +160,30 @@ object FlightPlanComputer { fun forTransport(bee: ServerBeeData, task: TransportTask, level: ServerLevel? = null): FlightPlan { val raw = buildRawTransportCheckpoints(bee, task) val checkpoints = if (level != null) insertObstacleWaypoints(raw, level) else raw - return FlightPlan(bee.id, BeeType.TRANSPORT, DEFAULT_SPEED, checkpoints) + return FlightPlan(bee.id, bee.type, DEFAULT_SPEED, checkpoints) } - private fun buildRawConstructionCheckpoints(bee: ServerBeeData, batch: TaskBatch, network: BeeNetwork) = buildList { + private fun buildRawConstructionCheckpoints(bee: ServerBeeData, batch: TaskBatch, network: BeeNetwork): List? = buildList { add(Checkpoint(bee.blockPosition(), FlyThrough)) val missing = computeMissingItems(bee, batch) if (missing.isNotEmpty()) { - findBestProvider(network, missing, bee.id)?.let { - add(Checkpoint(it.pos.above(), GatherFromPort(missing), clientPauseTicks = GATHER_PAUSE_TICKS)) + val provider = findBestProvider(network, missing, bee.id) + if (provider == null) { + // No provider has the required materials — abort flight plan + return null } + add(Checkpoint(provider.pos.above(), GatherFromPort(missing), clientPauseTicks = GATHER_PAUSE_TICKS)) } val remainingTasks = batch.getRemainingTasks() + var lastCheckpointPos = bee.blockPosition() remainingTasks.forEach { task -> val action = task.action when (action) { is DropOffItemsAction -> { - val dropPort = network.findDropOff(ItemStack.EMPTY) + val dropPort = network.findDropOff(ItemStack.EMPTY, bee.hiveId) val dropPos = dropPort?.pos?.above() ?: task.targetPos add(Checkpoint(dropPos, ExecuteBeeAction(action, task), clientPauseTicks = DROP_OFF_PAUSE_TICKS)) + lastCheckpointPos = dropPos } is RemoveBlockAction -> { @@ -190,17 +194,18 @@ object FlightPlanComputer { clientPauseTicks = action.getWorkTicks(bee.getBeeContext()) ) ) + lastCheckpointPos = task.targetPos } else -> { add(Checkpoint(task.targetPos, ExecuteBeeAction(action, task))) + lastCheckpointPos = task.targetPos } } } - // Check for next batch RIGHT AFTER the last task — not at the hive. - // If work exists, the bee replans from here (the construction site) without flying home. - val lastTaskPos = remainingTasks.lastOrNull()?.targetPos ?: bee.blockPosition() - add(Checkpoint(lastTaskPos, CheckForNextWork())) + // Check for next batch at the last checkpoint position (which is the port + // for pickup/dropoff, or the block for construction/deconstruction). + add(Checkpoint(lastCheckpointPos, CheckForNextWork())) // Only reached if no more work — fly home and enter val hiveApproach = (bee.hivePos ?: bee.blockPosition()).above() add(Checkpoint(hiveApproach, EnterHive())) diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/server/BeeWorker.kt b/src/main/kotlin/de/devin/cbbees/content/bee/server/BeeWorker.kt index b391bfe..41fa48e 100644 --- a/src/main/kotlin/de/devin/cbbees/content/bee/server/BeeWorker.kt +++ b/src/main/kotlin/de/devin/cbbees/content/bee/server/BeeWorker.kt @@ -16,6 +16,9 @@ import java.util.UUID interface BeeWorker { val uuid: UUID val networkId: UUID + /** The UUID of the hive that spawned this bee. Used to scope portable beehive drop-offs. */ + val hiveId: UUID? + get() = null fun blockPosition(): BlockPos fun level(): Level diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/server/ServerBeeData.kt b/src/main/kotlin/de/devin/cbbees/content/bee/server/ServerBeeData.kt index cd032ea..bf601fc 100644 --- a/src/main/kotlin/de/devin/cbbees/content/bee/server/ServerBeeData.kt +++ b/src/main/kotlin/de/devin/cbbees/content/bee/server/ServerBeeData.kt @@ -38,7 +38,7 @@ class ServerBeeData( val id: UUID, val type: BeeType, override var networkId: UUID, - var hiveId: UUID? = null, + override var hiveId: UUID? = null, /** Owner player UUID for portable beehive bees. */ var ownerId: UUID? = null, ) : BeeWorker { diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/server/ServerBeeManager.kt b/src/main/kotlin/de/devin/cbbees/content/bee/server/ServerBeeManager.kt index db9d0c2..d65d24e 100644 --- a/src/main/kotlin/de/devin/cbbees/content/bee/server/ServerBeeManager.kt +++ b/src/main/kotlin/de/devin/cbbees/content/bee/server/ServerBeeManager.kt @@ -7,8 +7,12 @@ import de.devin.cbbees.content.bee.flight.ExecuteBeeAction import de.devin.cbbees.content.bee.flight.FlightPlan import de.devin.cbbees.content.bee.flight.FlightPlanComputer import de.devin.cbbees.content.bee.state.* +import de.devin.cbbees.content.beehive.MechanicalBeehiveBlockEntity import de.devin.cbbees.content.domain.beehive.BeeHive import de.devin.cbbees.content.domain.network.ServerBeeNetworkManager +import de.devin.cbbees.items.AllItems +import net.minecraft.world.entity.item.ItemEntity +import net.minecraft.world.item.ItemStack import de.devin.cbbees.content.domain.task.TaskBatch import de.devin.cbbees.content.domain.task.TransportTask import de.devin.cbbees.content.upgrades.BeeContext @@ -46,24 +50,63 @@ object ServerBeeManager { } fun clear() { - // Drop carried items so nothing is lost on shutdown + val currentLevel = level for (bee in bees.values) { - try { bee.dropInventory() } catch (_: UninitializedPropertyAccessException) {} - } - // Release task batches so they return to PENDING on next load - for (bee in bees.values) { - bee.currentTask?.release() + returnBeeToHive(bee, currentLevel) } bees.clear() level = null } + /** + * Returns a bee to its hive on shutdown/disconnect. The bee item goes back + * into the hive inventory, carried items are dropped at the hive, and the + * task batch is released so it can be re-dispatched on next load. + */ + private fun returnBeeToHive(bee: ServerBeeData, currentLevel: ServerLevel?) { + val beeItem = ItemStack( + if (bee.type == BeeType.CONSTRUCTION) AllItems.MECHANICAL_BEE.get() + else AllItems.MECHANICAL_BUMBLE_BEE.get() + ) + + // Try cached hive first, then look up fresh from world by hiveId + var hive = bee.hiveInstance + if (hive == null && currentLevel != null && bee.hiveId != null) { + hive = ServerBeeNetworkManager.findHive(bee.hiveId!!) + } + // Also try looking up the block entity directly from hivePos + if (hive == null && currentLevel != null && bee.hivePos != null) { + hive = currentLevel.getBlockEntity(bee.hivePos!!) as? BeeHive + } + + if (hive != null && hive.returnBee(beeItem)) { + if (currentLevel != null) { + bee.getInventoryContents().forEach { item -> + bee.removeFromInventory(item, item.count) + currentLevel.addFreshEntity( + ItemEntity(currentLevel, hive.pos.x + 0.5, hive.pos.y + 1.0, hive.pos.z + 0.5, item.copy()) + ) + } + } + (hive as? MechanicalBeehiveBlockEntity)?.onBeeRemovedById(bee.id) + } else if (currentLevel != null) { + currentLevel.addFreshEntity( + ItemEntity(currentLevel, bee.pos.x, bee.pos.y, bee.pos.z, beeItem) + ) + try { bee.dropInventory() } catch (_: UninitializedPropertyAccessException) {} + } + + bee.currentTask?.release() + } + // ════════════════════════════════════════════════════════════════════ // Tick // ════════════════════════════════════════════════════════════════════ /** Pending removals — collected during tick, applied after iteration. */ private val pendingRemovals = mutableSetOf() + /** Bees that need to be returned to their hive on removal (abnormal exit). */ + private val pendingReturns = mutableSetOf() private var isTicking = false /** Max checkpoint actions per tick — prevents mass-arrival lag spikes. Read from config. */ @@ -72,12 +115,14 @@ object ServerBeeManager { fun tickAll(serverLevel: ServerLevel, gameTime: Long) { isTicking = true pendingRemovals.clear() + pendingReturns.clear() val profiler = serverLevel.profiler val snapshot = bees.values.toTypedArray() var checkpointsThisTick = 0 val confirmBatch = mutableListOf() + profiler.push("checkpoints") for (bee in snapshot) { if (bee.id in pendingRemovals) continue bee._level = serverLevel @@ -91,7 +136,7 @@ object ServerBeeManager { // Throttle: spread checkpoint processing across ticks if (checkpointsThisTick >= maxCheckpointsPerTick) continue - profiler.push("beeCheckpoint") + profiler.push("arrival") checkpointsThisTick++ val checkpoint = plan.checkpoints[bee.currentCheckpointIndex] bee.pos = Vec3.atCenterOf(checkpoint.pos) @@ -103,18 +148,32 @@ object ServerBeeManager { } advanceCheckpoint(bee, gameTime) } - profiler.pop() + profiler.pop() // arrival - if (bee.springTension < -999f) pendingRemovals.add(bee.id) + if (bee.springTension < -999f) { + pendingRemovals.add(bee.id) + pendingReturns.add(bee.id) + } } + profiler.pop() // checkpoints // Send all action confirmations in one batched packet if (confirmBatch.isNotEmpty()) { + profiler.push("broadcastConfirm") broadcastCheckpointConfirmBatch(confirmBatch) + profiler.pop() } isTicking = false - pendingRemovals.forEach { bees.remove(it) } + pendingRemovals.forEach { id -> + val bee = bees.remove(id) + // Only return bees that exited abnormally (spring signal), not those + // already returned by their checkpoint action (e.g., EnterHive). + if (bee != null && id in pendingReturns) { + returnBeeToHive(bee, level) + } + } + pendingReturns.clear() } private fun advanceCheckpoint(bee: ServerBeeData, gameTime: Long) { @@ -180,10 +239,18 @@ object ServerBeeManager { val network = ServerBeeNetworkManager.getNetwork(networkId, level!!) if (network != null) { FlightPlanComputer.computeAsync(bee, batch, network, level!!) { plan -> + if (plan == null) { + // Flight plan failed (e.g., no provider for required materials). + // Return the bee to its hive and put the batch back to PENDING. + // Don't count as a retry — material unavailability is transient. + batch.releaseWithoutRetry() + removeBee(bee.id) + returnBeeToHive(bee, level) + return@computeAsync + } bee.flightPlan = plan bee.planStartTick = level!!.gameTime bee.currentCheckpointIndex = 0 - // Start flying to checkpoint 1 — don't arrive at checkpoint 0 instantly if (plan.checkpoints.size > 1) { val travel = FlightPlan.travelTicks( plan.checkpoints[0].pos, plan.checkpoints[1].pos, plan.speed @@ -191,7 +258,6 @@ object ServerBeeManager { bee.currentCheckpointIndex = 1 bee.nextCheckpointArrivalTick = level!!.gameTime + travel } - // Client starts at 0 (hive) so it renders the full departure flight broadcastFlightPlan(bee, plan, clientStartIndex = 0) } } diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/state/ConstructionBeeStateMachine.kt b/src/main/kotlin/de/devin/cbbees/content/bee/state/ConstructionBeeStateMachine.kt index f927a6b..756932b 100644 --- a/src/main/kotlin/de/devin/cbbees/content/bee/state/ConstructionBeeStateMachine.kt +++ b/src/main/kotlin/de/devin/cbbees/content/bee/state/ConstructionBeeStateMachine.kt @@ -299,7 +299,7 @@ object ConstructionBeeStateMachine { private fun tickDroppingItemsData(bee: ServerBeeData, level: ServerLevel) { val excess = getExcessItems(bee, bee.currentTask) if (excess.isEmpty()) { bee.constructionState = ConstructionBeeState.FLYING_HOME; bee.walkTarget = bee.hivePos; return } - val port = bee.network()?.findDropOff(excess.first()) + val port = bee.network()?.findDropOff(excess.first(), bee.hiveId) if (port == null) { excess.forEach { item -> bee.removeFromInventory(item, item.count) @@ -774,7 +774,7 @@ object ConstructionBeeStateMachine { } val network = bee.network() - val dropOffPort = network?.findDropOff(excess.first()) + val dropOffPort = network?.findDropOff(excess.first(), bee.homeId) if (dropOffPort == null) { val owner = bee.getOwnerPlayer() diff --git a/src/main/kotlin/de/devin/cbbees/content/beehive/MechanicalBeehiveBlock.kt b/src/main/kotlin/de/devin/cbbees/content/beehive/MechanicalBeehiveBlock.kt index e3e242e..4a40e53 100644 --- a/src/main/kotlin/de/devin/cbbees/content/beehive/MechanicalBeehiveBlock.kt +++ b/src/main/kotlin/de/devin/cbbees/content/beehive/MechanicalBeehiveBlock.kt @@ -7,15 +7,9 @@ import de.devin.cbbees.registry.AllBlockEntityTypes import net.minecraft.core.BlockPos import net.minecraft.core.Direction import net.minecraft.core.Direction.Axis -import net.minecraft.network.chat.Component -import net.minecraft.world.InteractionResult -import net.minecraft.world.entity.player.Inventory -import net.minecraft.world.entity.player.Player -import net.minecraft.world.level.Level import net.minecraft.world.level.LevelReader import net.minecraft.world.level.block.entity.BlockEntityType import net.minecraft.world.level.block.state.BlockState -import net.minecraft.world.phys.BlockHitResult class MechanicalBeehiveBlock(properties: Properties) : KineticBlock(properties), IBE, ICogWheel { @@ -28,28 +22,7 @@ class MechanicalBeehiveBlock(properties: Properties) : KineticBlock(properties), return Axis.Y } - override fun useWithoutItem( - state: BlockState, - level: Level, - pos: BlockPos, - player: Player, - hit: BlockHitResult - ): InteractionResult { - if (level.isClientSide) return InteractionResult.SUCCESS - - withBlockEntityDo(level, pos) { be -> - val menuProvider = object : net.minecraft.world.MenuProvider { - override fun getDisplayName() = Component.translatable("block.cbbees.mechanical_beehive") - override fun createMenu(id: Int, inv: Inventory, player: Player) = - MechanicalBeehiveMenu(id, inv, be) - } - player.openMenu(menuProvider) { buf -> - buf.writeBlockPos(pos) - } - } - - return InteractionResult.CONSUME - } + // No right-click GUI — use goggles for network info, click job AABB for job details override fun getBlockEntityType(): BlockEntityType { return AllBlockEntityTypes.MECHANICAL_BEEHIVE.get() diff --git a/src/main/kotlin/de/devin/cbbees/content/beehive/MechanicalBeehiveBlockEntity.kt b/src/main/kotlin/de/devin/cbbees/content/beehive/MechanicalBeehiveBlockEntity.kt index 4ebfa1a..feb52e0 100644 --- a/src/main/kotlin/de/devin/cbbees/content/beehive/MechanicalBeehiveBlockEntity.kt +++ b/src/main/kotlin/de/devin/cbbees/content/beehive/MechanicalBeehiveBlockEntity.kt @@ -105,16 +105,17 @@ class MechanicalBeehiveBlockEntity(type: BlockEntityType<*>, pos: BlockPos, stat } override fun acceptBatch(batch: TaskBatch): Boolean { - if (getAvailableBeeCount() <= 0) return false - if (getActiveBeeCountForJob(batch.job.jobId) >= getBeeContext().maxActiveBees) return false - - this.setChanged() + if (getActiveBeeCount() >= getBeeContext().maxActiveBees) return false // Pickup batches use bumble bees; everything else uses construction bees val beeItemClass = if (batch.beeType == BeeType.TRANSPORT) MechanicalBumbleBeeItem::class.java else MechanicalBeeItem::class.java + + if (getAvailableBeeCountOfType(beeItemClass) <= 0) return false + + this.setChanged() val beeItem = consumeBeeOfType(beeItemClass) if (beeItem.isEmpty) return false return spawnBee(beeItem, batch) diff --git a/src/main/kotlin/de/devin/cbbees/content/domain/GlobalJobPool.kt b/src/main/kotlin/de/devin/cbbees/content/domain/GlobalJobPool.kt index d2933fb..dbc4bff 100644 --- a/src/main/kotlin/de/devin/cbbees/content/domain/GlobalJobPool.kt +++ b/src/main/kotlin/de/devin/cbbees/content/domain/GlobalJobPool.kt @@ -1,7 +1,10 @@ package de.devin.cbbees.content.domain import de.devin.cbbees.CreateBuzzyBeez +import de.devin.cbbees.config.CBBeesConfig +import de.devin.cbbees.content.bee.server.BeeType import de.devin.cbbees.content.domain.beehive.BeeHive +import java.util.UUID import de.devin.cbbees.content.domain.job.BeeJob import de.devin.cbbees.content.domain.job.JobStatus import de.devin.cbbees.content.domain.network.ServerBeeNetworkManager @@ -17,7 +20,7 @@ import de.devin.cbbees.util.ServerSide * */ @ServerSide -object GlobalJobPool : SavedData() { +object GlobalJobPool : SavedData(), JobPool { private val jobBacklog = mutableListOf() private var redispatchCounter = 0 private var watchdogCounter = 0 @@ -34,8 +37,10 @@ object GlobalJobPool : SavedData() { watchdogCounter = 0 } - fun tick(gameTime: Long = 0L) { - if (jobBacklog.removeIf { it.status == JobStatus.COMPLETED || it.status == JobStatus.CANCELLED }) { + override fun tick(gameTime: Long) { + if (jobBacklog.removeIf { + it.status == JobStatus.COMPLETED || it.status == JobStatus.CANCELLED + }) { this.setDirty() } @@ -113,36 +118,66 @@ object GlobalJobPool : SavedData() { * - Retrying failed/released batches * - Assigning work to newly available bees in hives */ + /** Max batches dispatched per call — prevents spawning hundreds of bees in one tick. */ + private val maxDispatchesPerCycle: Int get() = CBBeesConfig.maxCheckpointsPerTick.get() + private fun redispatchPendingBatches(gameTime: Long) { val allNetworks = ServerBeeNetworkManager.getNetworks() if (allNetworks.isEmpty()) return + var dispatched = 0 for (job in jobBacklog) { if (job.status == JobStatus.COMPLETED || job.status == JobStatus.CANCELLED) continue for (batch in job.batches) { + if (dispatched >= maxDispatchesPerCycle) return if (batch.status != TaskStatus.PENDING) continue if (!batch.canRetry()) continue if (!batch.isCooldownElapsed(gameTime)) continue if (!job.isPhaseReady(batch.phase)) continue - val targetNetwork = allNetworks.filter { network -> + val inWorldNetworks = allNetworks.filter { network -> val firstComp = network.components.firstOrNull() - firstComp != null && firstComp.world == job.level && - network.isInRange(batch.targetPosition) && - network.hives.any { it.getAvailableBeeCount() > 0 } - }.minByOrNull { network -> + firstComp != null && firstComp.world == job.level + } + if (inWorldNetworks.isEmpty()) continue + + val inRangeNetworks = inWorldNetworks.filter { it.isInRange(batch.targetPosition) } + if (inRangeNetworks.isEmpty()) continue + + val withBees = inRangeNetworks.filter { network -> + network.hives.any { it.getAvailableBeeCount() > 0 } + } + if (withBees.isEmpty()) continue + + val withPorts = if (batch.beeType == BeeType.TRANSPORT) { + withBees.filter { it.findDropOff(net.minecraft.world.item.ItemStack.EMPTY) != null } + } else withBees + if (withPorts.isEmpty()) continue + + val targetNetwork = withPorts.minByOrNull { network -> network.hives.minOfOrNull { it.pos.distSqr(batch.targetPosition) } ?: Double.MAX_VALUE } ?: continue + + // Always assign the network so StuckReasonResolver can find it batch.assignedNetworkId = targetNetwork.id + + // Check if required materials are available before dispatching. + // This prevents the wasteful spawn-fail-return cycle when materials are missing. + val missingMaterials = batch.tasks.map { it.action } + .filterIsInstance() + .flatMap { it.requiredItems } + .any { req -> targetNetwork.findAvailableProvider(req) == null } + if (missingMaterials) continue targetNetwork.dispatchBatch(batch) + dispatched++ } } } val workers: Set get() = ServerBeeNetworkManager.getNetworks().flatMap { it.hives }.toSet() - fun getAllJobs(): List = jobBacklog + override fun getAllJobs(): List = jobBacklog fun workBacklog(beeHive: BeeHive): TaskBatch? { val network = beeHive.network() @@ -208,7 +243,7 @@ object GlobalJobPool : SavedData() { * runs every few ticks. This avoids a synchronous O(batches × networks × hives) * spike when placing large schematics. */ - fun dispatchNewJob(job: BeeJob) { + override fun dispatchNewJob(job: BeeJob) { // Prevent duplicate active jobs with same uniqueness key if (job.uniquenessKey != null && jobBacklog.any { it.uniquenessKey == job.uniquenessKey && diff --git a/src/main/kotlin/de/devin/cbbees/content/domain/JobPool.kt b/src/main/kotlin/de/devin/cbbees/content/domain/JobPool.kt new file mode 100644 index 0000000..4f5d0bd --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/domain/JobPool.kt @@ -0,0 +1,15 @@ +package de.devin.cbbees.content.domain + +import de.devin.cbbees.content.domain.job.BeeJob + +/** + * Abstraction for job dispatch and lifecycle management. + * + * Production code uses [GlobalJobPool] (singleton, cross-network dispatch). + * Tests can substitute a scoped implementation that dispatches to a specific network. + */ +interface JobPool { + fun dispatchNewJob(job: BeeJob) + fun getAllJobs(): List + fun tick(gameTime: Long = 0L) +} diff --git a/src/main/kotlin/de/devin/cbbees/content/domain/StuckReasonResolver.kt b/src/main/kotlin/de/devin/cbbees/content/domain/StuckReasonResolver.kt index 2fba8b4..103d1e6 100644 --- a/src/main/kotlin/de/devin/cbbees/content/domain/StuckReasonResolver.kt +++ b/src/main/kotlin/de/devin/cbbees/content/domain/StuckReasonResolver.kt @@ -4,27 +4,33 @@ import de.devin.cbbees.content.domain.action.ItemConsumingAction import de.devin.cbbees.content.domain.job.BeeJob import de.devin.cbbees.content.domain.network.BeeNetwork import de.devin.cbbees.content.domain.task.TaskStatus +import net.minecraft.world.item.ItemStack object StuckReasonResolver { fun firstReasonOrNull(network: BeeNetwork, job: BeeJob): String? { - // 1) Any batch target out of the hive coverage if (job.batches.any { !network.isInRange(it.targetPosition) }) - return "Target is outside hive coverage" + return "cbbees.stall.out_of_range" - // 2) Missing resources for any pending batch + // Collect all missing items across all pending batches + val allMissing = mutableMapOf() job.batches.filter { it.status == TaskStatus.PENDING }.forEach { b -> - val missing = b.tasks.map { it.action } + b.tasks.map { it.action } .filterIsInstance() .flatMap { it.requiredItems } .filter { req -> network.findAvailableProvider(req) == null } - if (missing.isNotEmpty()) return "Missing resources (${missing.size})" + .forEach { stack -> + val name = stack.hoverName.string + allMissing[name] = (allMissing[name] ?: 0) + stack.count + } + } + if (allMissing.isNotEmpty()) { + val itemList = allMissing.entries.joinToString(", ") { "${it.value}x ${it.key}" } + return "Missing: $itemList" } - // 3) No free bees (all hives in network currently have none available) - val totalAvailable = network.hives.sumOf { it.getAvailableBeeCount() } - if (totalAvailable <= 0) return "No available bees" + val totalBees = network.hives.sumOf { it.getAvailableBeeCount() + it.getActiveBeeCount() } + if (totalBees <= 0) return "cbbees.stall.no_bees" - // 4) Otherwise, let it be silent return null } } diff --git a/src/main/kotlin/de/devin/cbbees/content/domain/action/impl/DropOffItemsAction.kt b/src/main/kotlin/de/devin/cbbees/content/domain/action/impl/DropOffItemsAction.kt index 538983a..3a27fc1 100644 --- a/src/main/kotlin/de/devin/cbbees/content/domain/action/impl/DropOffItemsAction.kt +++ b/src/main/kotlin/de/devin/cbbees/content/domain/action/impl/DropOffItemsAction.kt @@ -21,7 +21,7 @@ class DropOffItemsAction(initialPos: BlockPos) : BeeAction { override fun onActivate(worker: BeeWorker) { cachedWorker = worker - val port = worker.network()?.findDropOff(ItemStack.EMPTY) + val port = worker.network()?.findDropOff(ItemStack.EMPTY, worker.hiveId) // Just set the target — actual item handling happens in execute() _pos = port?.pos ?: worker.blockPosition() } @@ -32,7 +32,7 @@ class DropOffItemsAction(initialPos: BlockPos) : BeeAction { // Try each item individually — different items might go to different ports contents.forEach { item -> - val port = worker.network()?.findDropOff(item) + val port = worker.network()?.findDropOff(item, worker.hiveId) if (port != null) { val remainder = port.addItemStack(item.copy()) worker.removeFromInventory(item, item.count) diff --git a/src/main/kotlin/de/devin/cbbees/content/domain/action/impl/PlaceBlockAction.kt b/src/main/kotlin/de/devin/cbbees/content/domain/action/impl/PlaceBlockAction.kt index 4885e63..ce37246 100644 --- a/src/main/kotlin/de/devin/cbbees/content/domain/action/impl/PlaceBlockAction.kt +++ b/src/main/kotlin/de/devin/cbbees/content/domain/action/impl/PlaceBlockAction.kt @@ -56,7 +56,11 @@ class PlaceBlockAction( } } - consumeItems(worker) + if (!consumeItems(worker)) { + // Bee doesn't have the required materials — can't place. + // Signal failure so ExecuteBeeAction can handle it. + return false + } // Use Create's BlockHelper for proper schematic block placement. // This handles all edge cases: rails, state filtering, block entity data, diff --git a/src/main/kotlin/de/devin/cbbees/content/domain/action/impl/RemoveBlockAction.kt b/src/main/kotlin/de/devin/cbbees/content/domain/action/impl/RemoveBlockAction.kt index d927fe1..ddf6394 100644 --- a/src/main/kotlin/de/devin/cbbees/content/domain/action/impl/RemoveBlockAction.kt +++ b/src/main/kotlin/de/devin/cbbees/content/domain/action/impl/RemoveBlockAction.kt @@ -61,7 +61,7 @@ class RemoveBlockAction(override val pos: BlockPos) : BeeAction { } level.destroyBlock(pos, false) - val hasPort = worker.network()?.findDropOff(ItemStack.EMPTY) != null + val hasPort = worker.network()?.findDropOff(ItemStack.EMPTY, worker.hiveId) != null (drops + extraDrops).forEach { drop -> if (hasPort) { // Port available — pick up into inventory for later deposit diff --git a/src/main/kotlin/de/devin/cbbees/content/domain/events/PlayerTickEvent.kt b/src/main/kotlin/de/devin/cbbees/content/domain/events/PlayerTickEvent.kt index 17ed5d6..b1a16b6 100644 --- a/src/main/kotlin/de/devin/cbbees/content/domain/events/PlayerTickEvent.kt +++ b/src/main/kotlin/de/devin/cbbees/content/domain/events/PlayerTickEvent.kt @@ -1,6 +1,5 @@ package de.devin.cbbees.content.domain.events -import de.devin.cbbees.CreateBuzzyBeez import de.devin.cbbees.content.backpack.PortableBeehiveItem import de.devin.cbbees.content.domain.beehive.PortableBeeHive import de.devin.cbbees.content.domain.job.JobCalculationProgress @@ -46,8 +45,18 @@ class PlayerTickEvent { @SubscribeEvent fun onPlayerTick(event: PlayerTickEvent.Post) { val player = event.entity - if (player.level().isClientSide || player.tickCount % 40 != 0) return + if (player.level().isClientSide) return + + val profiler = player.level().profiler + + // Mechanical Wings — runs every tick (not throttled) + profiler.push("cbbees_flight") + handleFlightUpgrade(player) + profiler.pop() + + if (player.tickCount % 40 != 0) return + profiler.push("cbbees_portableHive") val pool = ServerBeeNetworkManager val existingHive = @@ -67,6 +76,20 @@ class PlayerTickEvent { pool.unregisterWorker(player.uuid) } } + profiler.pop() + } + + // ── Backwards compatibility: disable flight from removed Mechanical Wings upgrade ── + + private fun handleFlightUpgrade(player: net.minecraft.world.entity.player.Player) { + if (player.isCreative || player.isSpectator) return + // Mechanical Wings upgrade was removed in 1.3.0. Gracefully disable flight + // for players who had it active from a previous version. + if (player.abilities.mayfly && !player.isCreative && !player.isSpectator) { + player.abilities.mayfly = false + player.abilities.flying = false + player.onUpdateAbilities() + } } private fun hasPortableHive(player: net.minecraft.world.entity.player.Player): Boolean { diff --git a/src/main/kotlin/de/devin/cbbees/content/domain/job/client/JobProgressToast.kt b/src/main/kotlin/de/devin/cbbees/content/domain/job/client/JobProgressToast.kt index 9bfd626..c743ad6 100644 --- a/src/main/kotlin/de/devin/cbbees/content/domain/job/client/JobProgressToast.kt +++ b/src/main/kotlin/de/devin/cbbees/content/domain/job/client/JobProgressToast.kt @@ -126,11 +126,7 @@ class JobProgressToast(private val jobId: UUID) : Toast { private const val TITLE_COLOR = 0xFFFFFFFF.toInt() private const val DETAIL_COLOR = 0xFFAAAAAA.toInt() - private const val BAR_BG_COLOR = 0xFF333333.toInt() - private const val BAR_FILL_ACTIVE = 0xFFFFCC00.toInt() // bee yellow - private const val BAR_FILL_DONE = 0xFF55DD55.toInt() - private const val BAR_FILL_FAIL = 0xFFDD5555.toInt() - + /** Auto-hide if no progress packets arrive for this long (server hung / left dim). */ private const val STALE_TIMEOUT_MS = 60_000L diff --git a/src/main/kotlin/de/devin/cbbees/content/domain/network/BeeNetwork.kt b/src/main/kotlin/de/devin/cbbees/content/domain/network/BeeNetwork.kt index d1a50a6..0db8547 100644 --- a/src/main/kotlin/de/devin/cbbees/content/domain/network/BeeNetwork.kt +++ b/src/main/kotlin/de/devin/cbbees/content/domain/network/BeeNetwork.kt @@ -54,14 +54,20 @@ class BeeNetwork( val removed = _components.removeAll { comp -> val be = comp as? BlockEntity ?: return@removeAll false - // Check if the block entity is marked as removed if (be.isRemoved) return@removeAll true - // Check if the world still has this exact block entity at the position val worldBe = be.level?.getBlockEntity(be.blockPos) - worldBe !== be + if (worldBe !== be) return@removeAll true + // Non-anchor components (ports) must remain in range of at least one anchor + if (!topology.isAnchor(comp)) { + val inRange = _components.any { other -> + topology.isAnchor(other) && topology.isLogisticsRange(other, comp.pos) + } + if (!inRange) return@removeAll true + } + false } if (removed) { - de.devin.cbbees.CreateBuzzyBeez.LOGGER.warn("[NET] Purged stale component(s) from network $id") + de.devin.cbbees.CreateBuzzyBeez.LOGGER.warn("[NET] Purged stale/out-of-range component(s) from network $id") invalidateComponentCaches() } } @@ -94,9 +100,12 @@ class BeeNetwork( .maxByOrNull { it.priority() } } - fun findDropOff(stack: ItemStack): LogisticsPort? { - return ports.filter { it.isValidForDropOff() && (stack.isEmpty || it.testFilter(stack)) } - .maxByOrNull { it.priority() } + fun findDropOff(stack: ItemStack, beeHiveId: UUID? = null): LogisticsPort? { + return ports.filter { + it.isValidForDropOff() + && (stack.isEmpty || it.testFilter(stack)) + && (beeHiveId == null || it !is PortableBeeHive || it.id == beeHiveId) + }.maxByOrNull { it.priority() } } fun findAvailableProvider(stack: ItemStack, excludeBeeId: UUID? = null): LogisticsPort? { diff --git a/src/main/kotlin/de/devin/cbbees/content/domain/network/BeeNetworkManager.kt b/src/main/kotlin/de/devin/cbbees/content/domain/network/BeeNetworkManager.kt index 6f9624f..829b76e 100644 --- a/src/main/kotlin/de/devin/cbbees/content/domain/network/BeeNetworkManager.kt +++ b/src/main/kotlin/de/devin/cbbees/content/domain/network/BeeNetworkManager.kt @@ -220,6 +220,12 @@ object ServerBeeNetworkManager { fun registerPort(port: LogisticsPort) = registerComponent(port) + /** Adds a pre-built network directly. Used by gametests to bypass spatial scanning. */ + fun addNetwork(network: BeeNetwork) { + networks.add(network) + rebuildIndexes() + } + fun unregisterPort(port: LogisticsPort) = unregisterComponent(port) fun getNetworkAt(level: Level, pos: BlockPos): BeeNetwork? { diff --git a/src/main/kotlin/de/devin/cbbees/content/domain/task/TaskBatch.kt b/src/main/kotlin/de/devin/cbbees/content/domain/task/TaskBatch.kt index 2779d15..91b37e3 100644 --- a/src/main/kotlin/de/devin/cbbees/content/domain/task/TaskBatch.kt +++ b/src/main/kotlin/de/devin/cbbees/content/domain/task/TaskBatch.kt @@ -93,6 +93,19 @@ class TaskBatch( } } + /** + * Returns the batch to PENDING without incrementing the retry counter. + * Used when a flight plan fails due to transient conditions (missing materials) + * that the player can resolve — the batch should retry indefinitely. + */ + fun releaseWithoutRetry() { + currentIndex = 0 + assignedBeeId = null + tasks.forEach { it.release() } + status = TaskStatus.PENDING + // Keep assignedNetworkId so the stall resolver can find the network + } + fun assignToBee(beeId: UUID, gameTime: Long) { status = TaskStatus.IN_PROGRESS assignedBeeId = beeId diff --git a/src/main/kotlin/de/devin/cbbees/content/drone/client/DroneViewClientEvents.kt b/src/main/kotlin/de/devin/cbbees/content/drone/client/DroneViewClientEvents.kt index e74a8e0..43f9e04 100644 --- a/src/main/kotlin/de/devin/cbbees/content/drone/client/DroneViewClientEvents.kt +++ b/src/main/kotlin/de/devin/cbbees/content/drone/client/DroneViewClientEvents.kt @@ -126,6 +126,13 @@ object DroneViewClientEvents { dz = dz / len * speed PacketDistributor.sendToServer(MoveDronePacket(dx, dz)) + + // Client-side prediction: move the drone immediately for zero-latency feel. + // The server will send the authoritative position next tick. + val drone = mc.level?.getEntity(DroneViewClientState.droneEntityId) + if (drone != null) { + drone.setPos(drone.x + dx, drone.y, drone.z + dz) + } } /** diff --git a/src/main/kotlin/de/devin/cbbees/content/schematics/client/AreaSelectionHandler.kt b/src/main/kotlin/de/devin/cbbees/content/schematics/client/AreaSelectionHandler.kt new file mode 100644 index 0000000..c1a9da7 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/schematics/client/AreaSelectionHandler.kt @@ -0,0 +1,231 @@ +package de.devin.cbbees.content.schematics.client + +import com.simibubi.create.foundation.utility.RaycastHelper +import de.devin.cbbees.content.deployer.SchematicProgram +import de.devin.cbbees.network.ProgramSchematicPacket +import de.devin.cbbees.registry.AllKeys +import com.mojang.blaze3d.platform.InputConstants +import net.createmod.catnip.animation.AnimationTickHolder +import net.createmod.catnip.math.VecHelper +import net.minecraft.client.Minecraft +import net.minecraft.core.BlockPos +import net.minecraft.core.Direction +import net.minecraft.core.Direction.AxisDirection +import net.minecraft.network.chat.Component +import net.minecraft.network.protocol.common.custom.CustomPacketPayload +import net.minecraft.util.Mth +import net.minecraft.world.InteractionHand +import net.minecraft.world.item.context.BlockPlaceContext +import net.minecraft.world.item.context.UseOnContext +import net.minecraft.world.phys.AABB +import net.minecraft.world.phys.HitResult +import net.minecraft.world.phys.Vec3 +import net.neoforged.neoforge.network.PacketDistributor +import org.lwjgl.glfw.GLFW + +/** + * Reusable two-corner area selection handler. Both the Deconstruction Planner and + * Pickup Planner delegate to this — only the translation key prefix, packet factory, + * and program factory differ. + * + * Supports: + * - Block raycast targeting (normal mode) + * - CTRL free-aim targeting (snap point at fixed range in front of camera) + * - CTRL+Scroll resizing by dragging faces + * - Shift+RMB to cancel selection + * - Automatic selection clear when the tool is switched away + */ +class AreaSelectionHandler( + private val keyPrefix: String, + private val isActive: () -> Boolean, + private val createPacket: (BlockPos, BlockPos) -> CustomPacketPayload, + private val createProgram: (BlockPos, BlockPos) -> SchematicProgram, +) { + private var selectedFace: Direction? = null + private var wasActive = false + + fun onScroll(delta: Double): Boolean { + if (!isActive()) return false + if (!isFreeAimDown()) return false + + // Adjust free-aim range when second corner not yet set + if (DeconstructionSelection.secondPos == null) { + DeconstructionSelection.range = Mth.clamp(DeconstructionSelection.range + delta.toInt(), 1, 100) + return true + } + + // Resize selection by dragging faces + val face = selectedFace ?: return true + val first = DeconstructionSelection.firstPos ?: return true + val second = DeconstructionSelection.secondPos ?: return true + + var bb = AABB(Vec3.atLowerCornerOf(first), Vec3.atLowerCornerOf(second)) + val vec = face.normal + val projectedView = Minecraft.getInstance().gameRenderer.mainCamera.position + + var adjustedDelta = delta + if (bb.contains(projectedView)) { + adjustedDelta *= -1 + } + + val intDelta = if (adjustedDelta > 0) Math.ceil(adjustedDelta).toInt() else Math.floor(adjustedDelta).toInt() + val x = vec.x * intDelta + val y = vec.y * intDelta + val z = vec.z * intDelta + + val axisDirection = face.axisDirection + if (axisDirection == AxisDirection.NEGATIVE) { + bb = bb.move(-x.toDouble(), -y.toDouble(), -z.toDouble()) + } + + val maxX = maxOf(bb.maxX - x * axisDirection.step, bb.minX) + val maxY = maxOf(bb.maxY - y * axisDirection.step, bb.minY) + val maxZ = maxOf(bb.maxZ - z * axisDirection.step, bb.minZ) + + bb = AABB(bb.minX, bb.minY, bb.minZ, maxX, maxY, maxZ) + + DeconstructionSelection.firstPos = BlockPos.containing(bb.minX, bb.minY, bb.minZ) + DeconstructionSelection.secondPos = BlockPos.containing(bb.maxX, bb.maxY, bb.maxZ) + + val player = Minecraft.getInstance().player ?: return true + val sizeX = (bb.xsize + 1).toInt() + val sizeY = (bb.ysize + 1).toInt() + val sizeZ = (bb.zsize + 1).toInt() + player.displayClientMessage( + Component.translatable("$keyPrefix.dimensions", sizeX, sizeY, sizeZ), true + ) + return true + } + + fun onMouseInput(button: Int, pressed: Boolean): Boolean { + if (!pressed || button != GLFW.GLFW_MOUSE_BUTTON_RIGHT) return false + if (!isActive()) return false + + val player = Minecraft.getInstance().player ?: return false + + if (player.isShiftKeyDown) { + discard() + return true + } + + if (DeconstructionSelection.secondPos != null) return true + + if (DeconstructionSelection.selectedPos == null) { + player.displayClientMessage(Component.translatable("$keyPrefix.no_target"), true) + return true + } + + if (DeconstructionSelection.firstPos != null) { + DeconstructionSelection.secondPos = DeconstructionSelection.selectedPos + return true + } + + DeconstructionSelection.firstPos = DeconstructionSelection.selectedPos + player.displayClientMessage(Component.translatable("$keyPrefix.first_pos"), true) + return true + } + + fun discard() { + DeconstructionSelection.discard() + Minecraft.getInstance().player?.displayClientMessage( + Component.translatable("$keyPrefix.abort"), true + ) + } + + fun tick() { + val active = isActive() + if (!active) { + // Clear selection when the tool is switched away + if (wasActive) { + DeconstructionSelection.discard() + wasActive = false + } + return + } + wasActive = true + + val player = Minecraft.getInstance().player ?: return + + // Update selected position based on where player is looking + if (DeconstructionSelection.secondPos == null) { + if (isFreeAimDown()) { + // CTRL free-aim: snap selection point at fixed range in front of camera + val pt = AnimationTickHolder.getPartialTicks() + val targetVec = player.getEyePosition(pt) + .add(player.lookAngle.scale(DeconstructionSelection.range.toDouble())) + DeconstructionSelection.selectedPos = BlockPos.containing(targetVec) + } else { + // Normal block raycast + val trace = RaycastHelper.rayTraceRange(player.level(), player, 75.0) + if (trace != null && trace.type == HitResult.Type.BLOCK) { + var hit = trace.blockPos + val replaceable = player.level().getBlockState(hit) + .canBeReplaced(BlockPlaceContext(UseOnContext(player, InteractionHand.MAIN_HAND, trace))) + if (trace.direction.axis.isVertical && !replaceable) { + hit = hit.relative(trace.direction) + } + DeconstructionSelection.selectedPos = hit + } else { + DeconstructionSelection.selectedPos = null + } + } + } + + // Update selected face for resizing + selectedFace = null + val first = DeconstructionSelection.firstPos + val second = DeconstructionSelection.secondPos + if (first != null && second != null) { + val bb = AABB(Vec3.atLowerCornerOf(first), Vec3.atLowerCornerOf(second)) + .expandTowards(1.0, 1.0, 1.0) + .inflate(0.45) + val projectedView = Minecraft.getInstance().gameRenderer.mainCamera.position + val inside = bb.contains(projectedView) + val result = RaycastHelper.rayTraceUntil(player, 70.0) { pos -> + inside xor bb.contains(VecHelper.getCenterOf(pos)) + } + selectedFace = when { + result.missed() -> null + inside -> result.facing.opposite + else -> result.facing + } + } + + DeconstructionRenderer.renderWorldOutline(selectedFace) + } + + fun onKeyInput(key: Int, pressed: Boolean): Boolean { + if (!pressed || !isActive()) return false + + if (AllKeys.START_ACTION.matches(key, 0) && DeconstructionSelection.isComplete()) { + val first = DeconstructionSelection.firstPos!! + val second = DeconstructionSelection.secondPos!! + PacketDistributor.sendToServer(createPacket(first, second)) + DeconstructionSelection.discard() + return true + } + + if (AllKeys.STOP_ACTION.matches(key, 0)) { + PacketDistributor.sendToServer(de.devin.cbbees.network.StopTasksPacket.INSTANCE) + return true + } + + if (AllKeys.PROGRAM_ACTION.matches(key, 0) && DeconstructionSelection.isComplete()) { + val first = DeconstructionSelection.firstPos!! + val second = DeconstructionSelection.secondPos!! + PacketDistributor.sendToServer(ProgramSchematicPacket(createProgram(first, second))) + Minecraft.getInstance().player?.displayClientMessage( + Component.translatable("cbbees.schematic.programmed").withStyle { it.withColor(0x88CCFF) }, true + ) + return true + } + + return false + } + + private fun isFreeAimDown(): Boolean { + val key = AllKeys.FREE_AIM + val window = Minecraft.getInstance().window ?: return false + return InputConstants.isKeyDown(window.window, key.key.value) + } +} diff --git a/src/main/kotlin/de/devin/cbbees/content/schematics/client/ConstructionPlannerClientEvents.kt b/src/main/kotlin/de/devin/cbbees/content/schematics/client/ConstructionPlannerClientEvents.kt index a516d6a..a75c946 100644 --- a/src/main/kotlin/de/devin/cbbees/content/schematics/client/ConstructionPlannerClientEvents.kt +++ b/src/main/kotlin/de/devin/cbbees/content/schematics/client/ConstructionPlannerClientEvents.kt @@ -3,7 +3,9 @@ package de.devin.cbbees.content.schematics.client import de.devin.cbbees.content.drone.client.DroneViewClientState import de.devin.cbbees.items.AllItems import de.devin.cbbees.registry.AllKeys +import net.minecraft.ChatFormatting import net.minecraft.client.Minecraft +import net.minecraft.network.chat.Component import net.neoforged.bus.api.SubscribeEvent import net.neoforged.neoforge.client.event.ClientTickEvent import net.neoforged.neoforge.client.event.InputEvent @@ -27,6 +29,7 @@ object ConstructionPlannerClientEvents { fun onClientTick(event: ClientTickEvent.Post) { ConstructionPlannerHandler.tick() ConstructionPlannerHUD.update() + checkStuckJobTooltip() // Clear custom tool state if player is no longer holding the planner if (ConstructionToolState.activeTool != ConstructionToolState.CustomTool.NONE) { @@ -116,4 +119,30 @@ object ConstructionPlannerClientEvents { SchematicHoverPreview.render(event) } + /** + * Shows the stall reason as an action bar message when the player + * looks at a stuck job's red AABB outline. + */ + private fun checkStuckJobTooltip() { + val mc = Minecraft.getInstance() + val player = mc.player ?: return + if (mc.screen != null) return + + val eyePos = player.getEyePosition(1f) + val lookDir = player.lookAngle + val jobId = ConstructionRenderer.findJobAtRay(eyePos, lookDir, 5.0) ?: return + val job = ConstructionRenderer.getJobInfo(jobId) ?: return + val reason = job.reason ?: return + + val reasonText = if (reason.startsWith("cbbees.")) { + Component.translatable(reason) + } else { + Component.literal(reason) + } + player.displayClientMessage( + Component.literal("${ChatFormatting.RED}${ChatFormatting.BOLD}! ") + .append(reasonText.copy().withStyle(ChatFormatting.RED)), + true + ) + } } diff --git a/src/main/kotlin/de/devin/cbbees/content/schematics/client/ConstructionPlannerHUD.kt b/src/main/kotlin/de/devin/cbbees/content/schematics/client/ConstructionPlannerHUD.kt index 5fb130d..788ab76 100644 --- a/src/main/kotlin/de/devin/cbbees/content/schematics/client/ConstructionPlannerHUD.kt +++ b/src/main/kotlin/de/devin/cbbees/content/schematics/client/ConstructionPlannerHUD.kt @@ -122,7 +122,7 @@ object ConstructionPlannerHUD { val secondHintWidth = mc.font.width(secondHint) // Compact hint (shown above panel when Alt not held) - val compactHint = Component.translatable("gui.cbbees.construction_planner.hint_alt") + val compactHint = Component.translatable("gui.cbbees.construction_planner.hint_alt", AllKeys.SCHEMATIC_MODIFIER.translatedKeyMessage) val compactHintWidth = mc.font.width(compactHint) // Breadcrumb diff --git a/src/main/kotlin/de/devin/cbbees/content/schematics/client/ConstructionRenderer.kt b/src/main/kotlin/de/devin/cbbees/content/schematics/client/ConstructionRenderer.kt index 6e5e016..334a5d2 100644 --- a/src/main/kotlin/de/devin/cbbees/content/schematics/client/ConstructionRenderer.kt +++ b/src/main/kotlin/de/devin/cbbees/content/schematics/client/ConstructionRenderer.kt @@ -49,6 +49,9 @@ object ConstructionRenderer { /** Alpha for ghost block rendering (0.0 = invisible, 1.0 = opaque). */ private const val GHOST_ALPHA = 0.5f + private const val NORMAL_COLOR = 0x6886c5 + private const val STUCK_COLOR = 0xFF5555 + private val outlineCache = mutableMapOf() /** Cached max bounds per job — only grows, never shrinks. */ @@ -84,9 +87,20 @@ object ConstructionRenderer { ColoringVertexConsumer(consumer, 1f, 1f, 1f, alpha) override fun getBuffer(type: RenderType): VertexConsumer { - // Redirect chunk buffer layers (solid, cutout, etc.) to translucent - // so GL blending is enabled and vertex alpha takes effect - val redirected = if (type in chunkLayers) RenderType.translucent() else type + // Redirect opaque render types to translucent variants so GL + // blending is enabled and the vertex alpha from ColoringVertexConsumer + // actually takes effect. This covers both chunk layers (solid, cutout) + // and entity layers (entitySolid, entityCutout — used by block entity + // renderers like chests, signs, beds, etc.). + val redirected = if (type in chunkLayers) { + RenderType.translucent() + } else if (!type.name.contains("translucent")) { + RenderType.entityTranslucentCull( + net.minecraft.client.renderer.texture.TextureAtlas.LOCATION_BLOCKS + ) + } else { + type + } return wrap(delegate.getBuffer(redirected)) } @@ -104,6 +118,7 @@ object ConstructionRenderer { val mc = Minecraft.getInstance() val level = mc.level ?: return + val profiler = level.profiler val jobs = ClientJobCache.getAllJobs() if (jobs.isEmpty()) { @@ -114,13 +129,24 @@ object ConstructionRenderer { return } + profiler.push("cbbees_constructionGhosts") + val dataVersion = ClientJobCache.version val gameTick = level.gameTime val shouldCheckBlocks = gameTick - lastBlockCheckTick >= BLOCK_CHECK_INTERVAL if (dataVersion != lastDataVersion || shouldCheckBlocks) { + profiler.push("rebuildCache") rebuildCache(jobs, level, shouldCheckBlocks) lastDataVersion = dataVersion if (shouldCheckBlocks) lastBlockCheckTick = gameTick + profiler.pop() + } + + // Update outline colors every frame (stall reason may change between cache rebuilds) + for (job in jobs) { + val outline = outlineCache[job.jobId] ?: continue + val color = if (job.reason != null) STUCK_COLOR else NORMAL_COLOR + outline.params.colored(color) } val poseStack = event.poseStack @@ -130,21 +156,26 @@ object ConstructionRenderer { val transparentBuffer = TransparentBuffer(superBuffer, opacity) // Render each job's schematic via Create's SchematicRenderer with transparency + profiler.push("renderGhosts") for ((_, jobRenderer) in rendererCache) { poseStack.pushPose() poseStack.translate(-camera.x, -camera.y, -camera.z) jobRenderer.renderer.render(poseStack, transparentBuffer) poseStack.popPose() } + profiler.pop() // Render blue outlines (at full opacity, through the real buffer) + profiler.push("renderOutlines") val pt = AnimationTickHolder.getPartialTicks() for ((_, outline) in outlineCache) { outline.render(poseStack, superBuffer, camera, pt) } + profiler.pop() superBuffer.draw() RenderSystem.enableCull() + profiler.pop() // cbbees_constructionGhosts } private fun rebuildCache(jobs: List, clientLevel: Level, checkBlocks: Boolean) { @@ -165,29 +196,37 @@ object ConstructionRenderer { val renderer = buildSchematicRenderer(job, clientLevel) if (renderer != null) { rendererCache[job.jobId] = renderer + } - // Build outline from actual block positions in the blockMap. - // SchematicLevel is created with anchor=ZERO, so blockMap keys - // are already in global coordinates — no anchor offset needed. - val positions = renderer.schematicLevel.blockMap.keys - if (positions.isEmpty()) continue - val bounds = AABB( - positions.minOf { it.x }.toDouble(), - positions.minOf { it.y }.toDouble(), - positions.minOf { it.z }.toDouble(), - (positions.maxOf { it.x } + 1).toDouble(), - (positions.maxOf { it.y } + 1).toDouble(), - (positions.maxOf { it.z } + 1).toDouble() - ) - outlineBoundsCache[job.jobId] = bounds - val outline = AABBOutline(bounds) - outline.params - .colored(0x6886c5) - .withFaceTexture(AllSpecialTextures.CHECKERED) - .lineWidth(1 / 16f) - outlineCache[job.jobId] = outline + // Build outline for this job — from schematic positions or batch targets + if (job.jobId !in outlineBoundsCache) { + val positions: Set = if (renderer != null) { + renderer.schematicLevel.blockMap.keys + } else { + // Non-schematic jobs (deconstruction, pickup): use batch target positions + job.batches.map { it.target }.toSet() + } + if (positions.isNotEmpty()) { + val bounds = AABB( + positions.minOf { it.x }.toDouble(), + positions.minOf { it.y }.toDouble(), + positions.minOf { it.z }.toDouble(), + (positions.maxOf { it.x } + 1).toDouble(), + (positions.maxOf { it.y } + 1).toDouble(), + (positions.maxOf { it.z } + 1).toDouble() + ) + outlineBoundsCache[job.jobId] = bounds + val outline = AABBOutline(bounds) + val color = if (job.reason != null) STUCK_COLOR else NORMAL_COLOR + outline.params + .colored(color) + .withFaceTexture(AllSpecialTextures.CHECKERED) + .lineWidth(1 / 16f) + outlineCache[job.jobId] = outline + } } } + } } diff --git a/src/main/kotlin/de/devin/cbbees/content/schematics/client/DeconstructionHandler.kt b/src/main/kotlin/de/devin/cbbees/content/schematics/client/DeconstructionHandler.kt index b596ae9..a6d6132 100644 --- a/src/main/kotlin/de/devin/cbbees/content/schematics/client/DeconstructionHandler.kt +++ b/src/main/kotlin/de/devin/cbbees/content/schematics/client/DeconstructionHandler.kt @@ -1,290 +1,31 @@ package de.devin.cbbees.content.schematics.client -import com.simibubi.create.foundation.utility.RaycastHelper -import de.devin.cbbees.CreateBuzzyBeez -import de.devin.cbbees.items.AllItems import de.devin.cbbees.content.deployer.SchematicProgram -import de.devin.cbbees.network.ProgramSchematicPacket +import de.devin.cbbees.items.AllItems import de.devin.cbbees.network.StartDeconstructionPacket -import de.devin.cbbees.network.StopTasksPacket -import de.devin.cbbees.registry.AllKeys -import net.createmod.catnip.animation.AnimationTickHolder -import net.createmod.catnip.math.VecHelper import net.minecraft.client.Minecraft -import net.minecraft.core.BlockPos -import net.minecraft.core.Direction -import net.minecraft.core.Direction.AxisDirection -import net.minecraft.network.chat.Component -import net.minecraft.util.Mth -import net.minecraft.world.InteractionHand -import net.minecraft.world.item.context.BlockPlaceContext -import net.minecraft.world.item.context.UseOnContext -import net.minecraft.world.phys.AABB -import net.minecraft.world.phys.HitResult -import net.minecraft.world.phys.Vec3 -import net.neoforged.neoforge.network.PacketDistributor -import org.lwjgl.glfw.GLFW /** * Client-side handler for the Deconstruction Planner tool. - * - * This mirrors Create's SchematicAndQuillHandler but renders in red - * and opens a deconstruction prompt instead of saving a schematic. - * - * Selection flow: - * 1. Player holds Deconstruction Planner - * 2. Right-click to set first corner - * 3. Right-click again to set second corner - * 4. Scroll wheel to resize selection - * 5. Right-click to open deconstruction prompt - * 6. Shift+Right-click to cancel selection + * Delegates all selection logic to [AreaSelectionHandler]. */ object DeconstructionHandler { - /** Currently selected face (for resizing) */ - private var selectedFace: Direction? = null - - /** - * Handles mouse scroll input for resizing the selection. - * @return true if the scroll was handled - */ - fun mouseScrolled(delta: Double): Boolean { - if (!isActive()) return false - - // Only handle scroll when CTRL is held - if (!isCtrlDown()) return false - - if (DeconstructionSelection.secondPos == null) { - DeconstructionSelection.range = Mth.clamp(DeconstructionSelection.range + delta.toInt(), 1, 100) - } - - val face = selectedFace ?: return true - val first = DeconstructionSelection.firstPos ?: return true - val second = DeconstructionSelection.secondPos ?: return true - - var bb = AABB(Vec3.atLowerCornerOf(first), Vec3.atLowerCornerOf(second)) - val vec = face.normal - val projectedView = Minecraft.getInstance().gameRenderer.mainCamera.position - - var adjustedDelta = delta - if (bb.contains(projectedView)) { - adjustedDelta *= -1 - } - - // Round away from zero - val intDelta = if (adjustedDelta > 0) Math.ceil(adjustedDelta).toInt() else Math.floor(adjustedDelta).toInt() - val x = vec.x * intDelta - val y = vec.y * intDelta - val z = vec.z * intDelta - - val axisDirection = face.axisDirection - if (axisDirection == AxisDirection.NEGATIVE) { - bb = bb.move(-x.toDouble(), -y.toDouble(), -z.toDouble()) - } - - val maxX = maxOf(bb.maxX - x * axisDirection.step, bb.minX) - val maxY = maxOf(bb.maxY - y * axisDirection.step, bb.minY) - val maxZ = maxOf(bb.maxZ - z * axisDirection.step, bb.minZ) - - bb = AABB(bb.minX, bb.minY, bb.minZ, maxX, maxY, maxZ) + private val handler = AreaSelectionHandler( + keyPrefix = "cbbees.deconstruction", + isActive = ::isActive, + createPacket = { pos1, pos2 -> StartDeconstructionPacket(pos1, pos2) }, + createProgram = { pos1, pos2 -> SchematicProgram.Deconstruction(pos1, pos2) }, + ) - DeconstructionSelection.firstPos = BlockPos.containing(bb.minX, bb.minY, bb.minZ) - DeconstructionSelection.secondPos = BlockPos.containing(bb.maxX, bb.maxY, bb.maxZ) + fun mouseScrolled(delta: Double) = handler.onScroll(delta) + fun onMouseInput(button: Int, pressed: Boolean) = handler.onMouseInput(button, pressed) + fun onKeyInput(key: Int, pressed: Boolean) = handler.onKeyInput(key, pressed) + fun tick() = handler.tick() + fun discard() = handler.discard() - val player = Minecraft.getInstance().player ?: return true - val sizeX = (bb.xsize + 1).toInt() - val sizeY = (bb.ysize + 1).toInt() - val sizeZ = (bb.zsize + 1).toInt() - player.displayClientMessage( - Component.translatable("cbbees.deconstruction.dimensions", sizeX, sizeY, sizeZ), - true - ) - - return true - } - - /** - * Handles mouse button input. - * @param button The mouse button (0=left, 1=right) - * @param pressed Whether the button was pressed (true) or released (false) - * @return true if the input was handled - */ - fun onMouseInput(button: Int, pressed: Boolean): Boolean { - if (!pressed || button != GLFW.GLFW_MOUSE_BUTTON_RIGHT) return false - if (!isActive()) return false - - val player = Minecraft.getInstance().player ?: return false - - // Shift + right-click to discard selection - if (player.isShiftKeyDown) { - discard() - return true - } - - // If both corners are set, we don't open the prompt screen anymore. - // The deconstruction is now started via the HUD button or R key. - if (DeconstructionSelection.secondPos != null) { - return true - } - - // No target selected - if (DeconstructionSelection.selectedPos == null) { - player.displayClientMessage( - Component.translatable("cbbees.deconstruction.no_target"), - true - ) - return true - } - - // Set second corner if first is already set - if (DeconstructionSelection.firstPos != null) { - DeconstructionSelection.secondPos = DeconstructionSelection.selectedPos - player.displayClientMessage( - Component.translatable("cbbees.deconstruction.second_pos", AllKeys.START_ACTION.translatedKeyMessage), - true - ) - return true - } - - // Set first corner - DeconstructionSelection.firstPos = DeconstructionSelection.selectedPos - player.displayClientMessage( - Component.translatable("cbbees.deconstruction.first_pos"), - true - ) - return true - } - - /** - * Discards the current selection. - */ - fun discard() { - DeconstructionSelection.discard() - Minecraft.getInstance().player?.displayClientMessage( - Component.translatable("cbbees.deconstruction.abort"), - true - ) - } - - /** - * Called every client tick to update selection state and rendering. - */ - fun tick() { - if (!isActive()) return - - val player = Minecraft.getInstance().player ?: return - - // Update selected position based on where player is looking - if (isCtrlDown()) { - // Free-aim mode when CTRL is held - val pt = AnimationTickHolder.getPartialTicks() - val targetVec = - player.getEyePosition(pt).add(player.lookAngle.scale(DeconstructionSelection.range.toDouble())) - DeconstructionSelection.selectedPos = BlockPos.containing(targetVec) - } else { - // Normal raycast mode - val trace = RaycastHelper.rayTraceRange(player.level(), player, 75.0) - if (trace != null && trace.type == HitResult.Type.BLOCK) { - var hit = trace.blockPos - val replaceable = player.level().getBlockState(hit) - .canBeReplaced(BlockPlaceContext(UseOnContext(player, InteractionHand.MAIN_HAND, trace))) - if (trace.direction.axis.isVertical && !replaceable) { - hit = hit.relative(trace.direction) - } - DeconstructionSelection.selectedPos = hit - } else { - DeconstructionSelection.selectedPos = null - } - } - - // Update selected face for resizing - selectedFace = null - val first = DeconstructionSelection.firstPos - val second = DeconstructionSelection.secondPos - if (first != null && second != null) { - var bb = AABB(Vec3.atLowerCornerOf(first), Vec3.atLowerCornerOf(second)) - .expandTowards(1.0, 1.0, 1.0) - .inflate(0.45) - val projectedView = Minecraft.getInstance().gameRenderer.mainCamera.position - val inside = bb.contains(projectedView) - val result = RaycastHelper.rayTraceUntil(player, 70.0) { pos -> - inside xor bb.contains(VecHelper.getCenterOf(pos)) - } - selectedFace = when { - result.missed() -> null - inside -> result.facing.opposite - else -> result.facing - } - } - - // Render the selection box - DeconstructionRenderer.renderWorldOutline(selectedFace) - } - - /** - * Handles key input for deconstruction. - */ - fun onKeyInput(key: Int, pressed: Boolean): Boolean { - if (!pressed || !isActive()) return false - - if (AllKeys.START_ACTION.matches(key, 0) && DeconstructionSelection.isComplete()) { - val first = DeconstructionSelection.firstPos!! - val second = DeconstructionSelection.secondPos!! - - // Send packet to server to start deconstruction - PacketDistributor.sendToServer(StartDeconstructionPacket(first, second)) - - // Show feedback - Minecraft.getInstance().player?.displayClientMessage( - Component.translatable("message.cbbees.planner.started") - .withStyle { it.withColor(0x00FF00) }, - true - ) - - discard() - return true - } - - if (AllKeys.STOP_ACTION.matches(key, 0)) { - PacketDistributor.sendToServer(StopTasksPacket.INSTANCE) - return true - } - - if (AllKeys.PROGRAM_ACTION.matches(key, 0) && DeconstructionSelection.isComplete()) { - val first = DeconstructionSelection.firstPos!! - val second = DeconstructionSelection.secondPos!! - - val program = SchematicProgram.Deconstruction(first, second) - PacketDistributor.sendToServer(ProgramSchematicPacket(program)) - - Minecraft.getInstance().player?.displayClientMessage( - Component.translatable("cbbees.schematic.programmed") - .withStyle { it.withColor(0x88CCFF) }, - true - ) - return true - } - - return false - } - - /** - * Checks if the deconstruction handler should be active. - * Active when player is holding the Deconstruction Planner item. - */ fun isActive(): Boolean { val player = Minecraft.getInstance().player ?: return false - val mainHandItem = player.mainHandItem - return AllItems.DECONSTRUCTION_PLANNER.isIn(mainHandItem) - } - - /** - * Checks if CTRL key is held. - */ - private fun isCtrlDown(): Boolean { - return Minecraft.getInstance().window?.let { window -> - GLFW.glfwGetKey(window.window, GLFW.GLFW_KEY_LEFT_CONTROL) == GLFW.GLFW_PRESS || - GLFW.glfwGetKey(window.window, GLFW.GLFW_KEY_RIGHT_CONTROL) == GLFW.GLFW_PRESS - } ?: false + return AllItems.DECONSTRUCTION_PLANNER.isIn(player.mainHandItem) } } diff --git a/src/main/kotlin/de/devin/cbbees/content/schematics/client/DeconstructionRenderer.kt b/src/main/kotlin/de/devin/cbbees/content/schematics/client/DeconstructionRenderer.kt index 44a7555..89f686c 100644 --- a/src/main/kotlin/de/devin/cbbees/content/schematics/client/DeconstructionRenderer.kt +++ b/src/main/kotlin/de/devin/cbbees/content/schematics/client/DeconstructionRenderer.kt @@ -26,24 +26,28 @@ import kotlin.math.abs @OnlyIn(Dist.CLIENT) object DeconstructionRenderer { private val outlineSlot = Any() - private const val SELECTION_COLOR = 0xc56868 + private const val DECONSTRUCTION_COLOR = 0xc56868 + private const val PICKUP_COLOR = 0x68c588 /** Eased offset controlling hint panel visibility (0 = hidden, 10 = fully visible). */ private var yOffset = 0f fun renderWorldOutline(selectedFace: Direction?) { val box = DeconstructionSelection.getSelectionBox() ?: return + val color = if (PickupHandler.isActive()) PICKUP_COLOR else DECONSTRUCTION_COLOR Outliner.getInstance() .chaseAABB(outlineSlot, box) - .colored(SELECTION_COLOR) + .colored(color) .withFaceTextures(AllSpecialTextures.CHECKERED, AllSpecialTextures.HIGHLIGHT_CHECKERED) .lineWidth(1 / 16f) .highlightFace(selectedFace) } + private fun isAnyPlannerActive() = DeconstructionHandler.isActive() || PickupHandler.isActive() + fun update() { - if (!DeconstructionHandler.isActive()) { + if (!isAnyPlannerActive()) { yOffset = 0f return } @@ -56,7 +60,8 @@ object DeconstructionRenderer { } fun renderHUD(guiGraphics: GuiGraphics, deltaTracker: DeltaTracker) { - if (!DeconstructionHandler.isActive()) return + if (!isAnyPlannerActive()) return + val isPickup = PickupHandler.isActive() val mc = Minecraft.getInstance() if (mc.options.hideGui || mc.screen != null) return @@ -73,7 +78,8 @@ object DeconstructionRenderer { val second = DeconstructionSelection.secondPos // Title - val titleText = Component.translatable("cbbees.deconstruction.title") + val titleKey = if (isPickup) "cbbees.pickup.title" else "cbbees.deconstruction.title" + val titleText = Component.translatable(titleKey) val titleWidth = mc.font.width(titleText) // Info line (dimensions or "first corner set") @@ -92,36 +98,32 @@ object DeconstructionRenderer { val infoWidth = mc.font.width(infoText) // Context-sensitive hints + val hintPrefix = if (isPickup) "gui.cbbees.pickup" else "gui.cbbees.deconstruction" val hintText = if (second != null) { Component.translatable( - "gui.cbbees.deconstruction.hint_ready", + "$hintPrefix.hint_ready", AllKeys.START_ACTION.translatedKeyMessage ) } else { - Component.translatable("gui.cbbees.deconstruction.hint_select") + Component.translatable("$hintPrefix.hint_select") } val hintWidth = mc.font.width(hintText) val secondHint = if (second != null) { Component.translatable( - "gui.cbbees.deconstruction.hint_program", + "$hintPrefix.hint_program", AllKeys.PROGRAM_ACTION.translatedKeyMessage ) } else { - Component.translatable("gui.cbbees.deconstruction.hint_scroll") + Component.translatable("$hintPrefix.hint_scroll") } val secondHintWidth = mc.font.width(secondHint) - // Compact hint (shown above panel when Alt not held) - val compactHint = Component.translatable("gui.cbbees.deconstruction.hint_alt") - val compactHintWidth = mc.font.width(compactHint) - // Layout val mainHeight = 32 val bgWidth = maxOf( titleWidth, infoWidth, - compactHintWidth, hintWidth, secondHintWidth ) + 30 @@ -133,16 +135,6 @@ object DeconstructionRenderer { RenderSystem.enableBlend() - // === "Hold Alt" text above main panel (fades out when Alt held) === - if (hintAlpha < 0.7f) { - val fade = 1f - (hintAlpha / 0.7f) - val a = (fade * 0xFF).toInt().coerceIn(0, 255) - guiGraphics.drawString( - mc.font, compactHint, - centerX - compactHintWidth / 2, bgY - 14, - (a shl 24) or 0xFFCCCC, true - ) - } // === Main panel background === RenderSystem.setShaderColor(1f, 1f, 1f, if (hintAlpha > 0.5f) 7f / 8f else 3f / 4f) @@ -154,10 +146,11 @@ object DeconstructionRenderer { RenderSystem.setShaderColor(1f, 1f, 1f, 1f) // Title + val titleColor = if (isPickup) 0xCCFFCC else 0xFFCCCC guiGraphics.drawString( mc.font, titleText, centerX - titleWidth / 2, bgY + 5, - 0xFFCCCC, false + titleColor, false ) // Info line @@ -171,7 +164,8 @@ object DeconstructionRenderer { if (hintAlpha > 0.25f) { val hintBgY = bgY + mainHeight + 2 - RenderSystem.setShaderColor(0.8f, 0.7f, 0.7f, hintAlpha) + if (isPickup) RenderSystem.setShaderColor(0.7f, 0.8f, 0.7f, hintAlpha) + else RenderSystem.setShaderColor(0.8f, 0.7f, 0.7f, hintAlpha) guiGraphics.blit( gray.location, bgX, hintBgY, gray.startX.toFloat(), gray.startY.toFloat(), @@ -179,10 +173,11 @@ object DeconstructionRenderer { ) RenderSystem.setShaderColor(1f, 1f, 1f, 1f) + val hintColor = if (isPickup) 0xCCFFCC else 0xFFCCCC guiGraphics.drawString( mc.font, hintText, centerX - hintWidth / 2, hintBgY + 4, - stringAlphaComponent or 0xFFCCCC, false + stringAlphaComponent or hintColor, false ) guiGraphics.drawString( mc.font, secondHint, diff --git a/src/main/kotlin/de/devin/cbbees/content/schematics/client/GroupPickerScreen.kt b/src/main/kotlin/de/devin/cbbees/content/schematics/client/GroupPickerScreen.kt index 7ed7e7f..b677215 100644 --- a/src/main/kotlin/de/devin/cbbees/content/schematics/client/GroupPickerScreen.kt +++ b/src/main/kotlin/de/devin/cbbees/content/schematics/client/GroupPickerScreen.kt @@ -1,215 +1,167 @@ package de.devin.cbbees.content.schematics.client +import com.simibubi.create.foundation.gui.AllIcons +import com.simibubi.create.foundation.gui.widget.IconButton import net.minecraft.client.Minecraft import net.minecraft.client.gui.GuiGraphics -import net.minecraft.client.gui.components.Button import net.minecraft.client.gui.components.EditBox import net.minecraft.client.gui.components.ObjectSelectionList import net.minecraft.client.gui.screens.Screen import net.minecraft.network.chat.Component +import net.createmod.catnip.gui.AbstractSimiScreen import net.neoforged.api.distmarker.Dist import net.neoforged.api.distmarker.OnlyIn import org.lwjgl.glfw.GLFW /** * Popup screen for selecting or creating a group path for a schematic. - * Used from the main browser and from the Schematic & Quill save dialog (via mixin). - * - * Renders as a centered panel with: - * - Scrollable list of existing groups - * - Text field for creating a new group - * - Confirm / Cancel buttons - * - * @param callback Called with the selected group path when confirmed (empty string = root). - * The callback is responsible for any group persistence — this screen only - * picks the path. After calling the callback, this screen returns to [parentScreen]. - * @param currentGroup The currently assigned group path (pre-selected in the list) - * @param parentScreen Screen to return to on close/cancel. Null = close all screens. + * Styled to match the Schematic Deployer screen. */ @OnlyIn(Dist.CLIENT) class GroupPickerScreen( private val callback: (String) -> Unit, private val currentGroup: String = "", private val parentScreen: Screen? = null -) : Screen(Component.translatable("gui.cbbees.group_picker.title")) { +) : AbstractSimiScreen(Component.translatable("gui.cbbees.group_picker.title")) { companion object { - private const val PANEL_WIDTH = 220 - private const val PANEL_HEIGHT = 200 + private const val PANEL_W = 220 + private const val PANEL_H = 210 + private const val MARGIN = 10 + + private const val BG_PANEL = 0xCC2A2A2A.toInt() + private const val BG_TITLE = 0xFF3A3A3A.toInt() + private const val BORDER = 0xFF555555.toInt() + private const val GOLD = 0xFFD4AA00.toInt() + private const val LABEL = 0xAAAAAA } - private var panelLeft = 0 - private var panelTop = 0 - private var groupList: GroupSelectionList? = null private var newGroupField: EditBox? = null private var selectedPath: String = currentGroup + private var confirmButton: IconButton? = null + private var cancelButton: IconButton? = null override fun init() { + setWindowSize(PANEL_W, PANEL_H) super.init() - panelLeft = (width - PANEL_WIDTH) / 2 - panelTop = (height - PANEL_HEIGHT) / 2 - - val innerLeft = panelLeft + 10 - val innerWidth = PANEL_WIDTH - 20 + val x = guiLeft + val y = guiTop + val innerLeft = x + MARGIN + val innerWidth = PANEL_W - MARGIN * 2 - // Scrollable list of existing groups - val listTop = panelTop + 28 - val listHeight = 100 - groupList = GroupSelectionList( - minecraft!!, innerWidth, listHeight, listTop, 16, innerLeft - ) + // Scrollable group list + val listTop = y + 38 + val listHeight = 94 + groupList = GroupSelectionList(minecraft!!, innerWidth, listHeight, listTop, 16, innerLeft) addWidget(groupList!!) - // Populate list - groupList!!.addGroupEntry( - GroupEntry( - "", - Component.translatable("gui.cbbees.group_picker.root") - ) - ) - + groupList!!.addGroupEntry(GroupEntry("", Component.translatable("gui.cbbees.group_picker.root"))) for (path in SchematicGroupManager.getAllGroupPaths().sorted()) { val depth = path.count { it == '/' } val indent = " ".repeat(depth) val displayName = path.substringAfterLast("/") groupList!!.addGroupEntry(GroupEntry(path, Component.literal("$indent$displayName"))) } + groupList!!.children().find { it.path == currentGroup }?.let { groupList!!.selected = it } - // Pre-select current group - groupList!!.children().find { it.path == currentGroup }?.let { - groupList!!.selected = it - } - - // "Or create new" text field - val fieldY = panelTop + 148 - newGroupField = EditBox( - font, innerLeft, fieldY, innerWidth, 16, - Component.translatable("gui.cbbees.group_picker.new_group") - ) + // "Create new" text field (below the "Or create new:" label) + val fieldY = y + PANEL_H - 46 + newGroupField = EditBox(font, innerLeft, fieldY, innerWidth, 16, + Component.translatable("gui.cbbees.group_picker.new_group")) newGroupField!!.setMaxLength(200) newGroupField!!.value = "" newGroupField!!.setHint(Component.translatable("gui.cbbees.group_picker.new_group_hint")) addRenderableWidget(newGroupField!!) - // Buttons at bottom of panel - val buttonY = panelTop + PANEL_HEIGHT - 24 - val buttonWidth = (innerWidth - 6) / 2 - - addRenderableWidget(Button.builder(Component.translatable("gui.cbbees.group_picker.confirm")) { - confirm() - }.bounds(innerLeft, buttonY, buttonWidth, 20).build()) - - addRenderableWidget(Button.builder(Component.translatable("gui.cbbees.group_picker.cancel")) { - onClose() - }.bounds(innerLeft + buttonWidth + 6, buttonY, buttonWidth, 20).build()) - } - - private fun confirm() { - val newGroup = newGroupField?.value?.trim() ?: "" - val result = if (newGroup.isNotEmpty()) { - newGroup.trim('/') - } else { - selectedPath + // Confirm / Cancel icon buttons + confirmButton = IconButton(x + PANEL_W - 25, y + PANEL_H - 25, AllIcons.I_CONFIRM).also { + it.setToolTip(Component.translatable("gui.cbbees.group_picker.confirm")) + addRenderableWidget(it) + } + cancelButton = IconButton(x + 5, y + PANEL_H - 25, AllIcons.I_DISABLE).also { + it.setToolTip(Component.translatable("gui.cbbees.group_picker.cancel")) + addRenderableWidget(it) } - callback(result) - // Return to parent (don't call onClose() which would null the screen - // after the callback may have already set a different screen) - minecraft?.setScreen(parentScreen) - } - - override fun onClose() { - // Return to parent screen instead of closing everything - minecraft?.setScreen(parentScreen) - } - - override fun render(guiGraphics: GuiGraphics, mouseX: Int, mouseY: Int, partialTick: Float) { - super.render(guiGraphics, mouseX, mouseY, partialTick) - - // Panel background - guiGraphics.fill( - panelLeft - 1, panelTop - 1, - panelLeft + PANEL_WIDTH + 1, panelTop + PANEL_HEIGHT + 1, 0xFF333333.toInt() - ) - guiGraphics.fill( - panelLeft, panelTop, - panelLeft + PANEL_WIDTH, panelTop + PANEL_HEIGHT, 0xFF1a1a2e.toInt() - ) - - // Title - guiGraphics.drawCenteredString( - font, title, - panelLeft + PANEL_WIDTH / 2, panelTop + 6, 0xFFFFFF - ) - - // "Existing groups:" label - guiGraphics.drawString( - font, - Component.translatable("gui.cbbees.group_picker.existing"), - panelLeft + 10, panelTop + 18, 0xAAAAAA, false - ) - - // Render the group list - groupList?.render(guiGraphics, mouseX, mouseY, partialTick) - - // "Or create new:" label - guiGraphics.drawString( - font, - Component.translatable("gui.cbbees.group_picker.or_create"), - panelLeft + 10, panelTop + 136, 0xAAAAAA, false - ) - - // Text input background highlight - val fieldX = panelLeft + 9 - val fieldY = panelTop + 147 - val fieldW = PANEL_WIDTH - 18 - guiGraphics.fill(fieldX, fieldY, fieldX + fieldW, fieldY + 18, 0x60000000) - } - - override fun renderBackground(guiGraphics: GuiGraphics, mouseX: Int, mouseY: Int, partialTick: Float) { - renderTransparentBackground(guiGraphics) } - // Forward mouse events to the group list override fun mouseClicked(mouseX: Double, mouseY: Double, button: Int): Boolean { - groupList?.let { - if (it.mouseClicked(mouseX, mouseY, button)) return true - } + confirmButton?.let { if (it.isMouseOver(mouseX, mouseY)) { confirm(); return true } } + cancelButton?.let { if (it.isMouseOver(mouseX, mouseY)) { onClose(); return true } } + groupList?.let { if (it.mouseClicked(mouseX, mouseY, button)) return true } return super.mouseClicked(mouseX, mouseY, button) } override fun mouseScrolled(mouseX: Double, mouseY: Double, scrollX: Double, scrollY: Double): Boolean { - groupList?.let { - if (it.mouseScrolled(mouseX, mouseY, scrollX, scrollY)) return true - } + groupList?.let { if (it.mouseScrolled(mouseX, mouseY, scrollX, scrollY)) return true } return super.mouseScrolled(mouseX, mouseY, scrollX, scrollY) } override fun mouseDragged(mouseX: Double, mouseY: Double, button: Int, dragX: Double, dragY: Double): Boolean { - groupList?.let { - if (it.mouseDragged(mouseX, mouseY, button, dragX, dragY)) return true - } + groupList?.let { if (it.mouseDragged(mouseX, mouseY, button, dragX, dragY)) return true } return super.mouseDragged(mouseX, mouseY, button, dragX, dragY) } override fun mouseReleased(mouseX: Double, mouseY: Double, button: Int): Boolean { - groupList?.let { - if (it.mouseReleased(mouseX, mouseY, button)) return true - } + groupList?.let { if (it.mouseReleased(mouseX, mouseY, button)) return true } return super.mouseReleased(mouseX, mouseY, button) } override fun keyPressed(keyCode: Int, scanCode: Int, modifiers: Int): Boolean { - // Enter confirms if (keyCode == GLFW.GLFW_KEY_ENTER || keyCode == GLFW.GLFW_KEY_KP_ENTER) { - confirm() - return true + confirm(); return true } return super.keyPressed(keyCode, scanCode, modifiers) } - // --- Inner classes --- + private fun confirm() { + val newGroup = newGroupField?.value?.trim() ?: "" + val result = if (newGroup.isNotEmpty()) newGroup.trim('/') else selectedPath + callback(result) + minecraft?.setScreen(parentScreen) + } + + override fun onClose() { + minecraft?.setScreen(parentScreen) + } + + override fun renderWindow(graphics: GuiGraphics, mouseX: Int, mouseY: Int, partialTicks: Float) { + val x = guiLeft + val y = guiTop + val w = windowWidth + val h = windowHeight + + // Panel background + border + graphics.fill(x, y, x + w, y + h, BG_PANEL) + graphics.fill(x, y, x + w, y + 1, BORDER) + graphics.fill(x, y + h - 1, x + w, y + h, BORDER) + graphics.fill(x, y, x + 1, y + h, BORDER) + graphics.fill(x + w - 1, y, x + w, y + h, BORDER) + + // Title bar + graphics.fill(x + 1, y + 1, x + w - 1, y + 18, BG_TITLE) + graphics.drawCenteredString(font, title, x + w / 2, y + 5, GOLD) + + // "Existing groups:" label + graphics.drawString(font, Component.translatable("gui.cbbees.group_picker.existing"), + x + MARGIN, y + 22, LABEL, false) + + // Group list + groupList?.render(graphics, mouseX, mouseY, partialTicks) + + // Separator + val sepY = y + PANEL_H - 62 + graphics.fill(x + MARGIN, sepY, x + w - MARGIN, sepY + 1, BORDER) + + // "Or create new:" label + graphics.drawString(font, Component.translatable("gui.cbbees.group_picker.or_create"), + x + MARGIN, sepY + 3, LABEL, false) + } + + override fun isPauseScreen(): Boolean = false + + // ── Inner classes ── inner class GroupEntry(val path: String, private val display: Component) : ObjectSelectionList.Entry() { @@ -222,8 +174,6 @@ class GroupPickerScreen( mouseX: Int, mouseY: Int, hovered: Boolean, partialTick: Float ) { val selected = groupList?.selected === this - - // Use the full list width for the highlight (not the narrower row width) val list = groupList ?: return val hlLeft = list.getRowLeft() val hlRight = hlLeft + list.getRowWidth() + 12 @@ -236,7 +186,7 @@ class GroupPickerScreen( val color = when { selected -> 0xFFFF00 - path.isEmpty() -> 0xAAAAAA + path.isEmpty() -> LABEL hovered -> 0xFFFFFF else -> 0xCCCCCC } @@ -246,30 +196,22 @@ class GroupPickerScreen( override fun mouseClicked(mouseX: Double, mouseY: Double, button: Int): Boolean { groupList?.setSelected(this) selectedPath = path - // Clear the new group field when selecting from the list newGroupField?.value = "" return true } } inner class GroupSelectionList( - mc: Minecraft, - width: Int, - height: Int, - top: Int, - itemHeight: Int, + mc: Minecraft, width: Int, height: Int, top: Int, itemHeight: Int, private val listLeft: Int ) : ObjectSelectionList(mc, width, height, top, itemHeight) { override fun getRowLeft(): Int = listLeft override fun getRowWidth(): Int = this.width - 12 - - // Suppress the default white selection box — we draw our own in the entry override fun isSelectedItem(index: Int): Boolean = false fun addGroupEntry(entry: GroupEntry): Int = super.addEntry(entry) - // Suppress the default full-screen dark background and separators override fun renderWidget(guiGraphics: GuiGraphics, mouseX: Int, mouseY: Int, partialTick: Float) { guiGraphics.enableScissor(listLeft, this.y, listLeft + this.width, this.y + this.height) guiGraphics.fill(listLeft, this.y, listLeft + this.width, this.y + this.height, 0x40000000) diff --git a/src/main/kotlin/de/devin/cbbees/content/schematics/client/JobDetailScreen.kt b/src/main/kotlin/de/devin/cbbees/content/schematics/client/JobDetailScreen.kt index 7fc46df..17245b1 100644 --- a/src/main/kotlin/de/devin/cbbees/content/schematics/client/JobDetailScreen.kt +++ b/src/main/kotlin/de/devin/cbbees/content/schematics/client/JobDetailScreen.kt @@ -2,32 +2,45 @@ package de.devin.cbbees.content.schematics.client import com.simibubi.create.foundation.gui.AllIcons import com.simibubi.create.foundation.gui.widget.IconButton -import de.devin.cbbees.content.beehive.client.ClientJobCache import de.devin.cbbees.content.domain.job.ClientJobInfo import de.devin.cbbees.network.CancelJobPacket import de.devin.cbbees.network.RequestPlayerJobsPacket +import net.createmod.catnip.gui.AbstractSimiScreen import net.minecraft.client.gui.GuiGraphics -import net.minecraft.client.gui.components.Button -import net.minecraft.client.gui.screens.Screen import net.minecraft.network.chat.Component import net.neoforged.neoforge.network.PacketDistributor import java.util.UUID /** - * A small overlay screen showing details for a single construction job. - * Opened by clicking on a job's bounding box outline in the world. + * Deployer-style overlay panel showing job details. + * Matches the visual style of the Schematic Deployer screen. */ -class JobDetailScreen(private val jobId: UUID) : Screen(Component.translatable("gui.cbbees.job_detail.title")) { +class JobDetailScreen(private val jobId: UUID) : AbstractSimiScreen(Component.translatable("gui.cbbees.job_detail.title")) { companion object { - private const val PANEL_WIDTH = 220 - private const val PANEL_HEIGHT = 115 + private const val PANEL_W = 200 + private const val LINE_H = 11 + private const val MARGIN = 8 + private const val BAR_H = 6 + + private const val BG_PANEL = 0xCC2A2A2A.toInt() + private const val BG_TITLE = 0xFF3A3A3A.toInt() + private const val BORDER = 0xFF555555.toInt() + private const val GOLD = 0xFFD4AA00.toInt() + private const val WHITE = 0xFFFFFF + private const val GRAY = 0x999999 + private const val RED = 0xFF5555 + private const val GREEN_BAR = 0xFF55AA55.toInt() + private const val RED_BAR = 0xFFAA3333.toInt() + private const val BAR_BG = 0xFF333333.toInt() } private var job: ClientJobInfo? = null private var refreshTicks = 0 + private var cancelButton: IconButton? = null override fun init() { + setWindowSize(PANEL_W, computePanelHeight()) super.init() PacketDistributor.sendToServer(RequestPlayerJobsPacket()) refreshJob() @@ -35,79 +48,157 @@ class JobDetailScreen(private val jobId: UUID) : Screen(Component.translatable(" private fun refreshJob() { job = ConstructionRenderer.getJobInfo(jobId) - clearWidgets() - - val x = (width - PANEL_WIDTH) / 2 - val y = (height - PANEL_HEIGHT) / 2 + cancelButton?.let { removeWidget(it) } if (job != null) { - val btnW = 80 - addRenderableWidget(Button.builder(Component.translatable("gui.cbbees.job_detail.cancel")) { - PacketDistributor.sendToServer(CancelJobPacket(jobId)) - onClose() - }.bounds(x + PANEL_WIDTH / 2 - btnW / 2, y + PANEL_HEIGHT - 26, btnW, 20).build()) + setWindowSize(PANEL_W, computePanelHeight()) + cancelButton = IconButton(guiLeft + windowWidth - 25, guiTop + windowHeight - 25, AllIcons.I_TRASH).also { + it.setToolTip(Component.translatable("gui.cbbees.job_detail.cancel")) + addRenderableWidget(it) + } } } - override fun renderBackground(guiGraphics: GuiGraphics, mouseX: Int, mouseY: Int, partialTick: Float) { - // No full-screen dimming — this is a small overlay panel + override fun mouseClicked(mouseX: Double, mouseY: Double, button: Int): Boolean { + cancelButton?.let { btn -> + if (btn.isMouseOver(mouseX, mouseY)) { + PacketDistributor.sendToServer(CancelJobPacket(jobId)) + onClose() + return true + } + } + return super.mouseClicked(mouseX, mouseY, button) } - override fun render(guiGraphics: GuiGraphics, mouseX: Int, mouseY: Int, partialTick: Float) { - super.render(guiGraphics, mouseX, mouseY, partialTick) - - val x = (width - PANEL_WIDTH) / 2 - val y = (height - PANEL_HEIGHT) / 2 + override fun renderWindow(graphics: GuiGraphics, mouseX: Int, mouseY: Int, partialTicks: Float) { + val x = guiLeft + val y = guiTop + val w = windowWidth + val h = windowHeight - // Panel background - guiGraphics.fill(x, y, x + PANEL_WIDTH, y + PANEL_HEIGHT, 0xFF707070.toInt()) - guiGraphics.fill(x + 2, y + 2, x + PANEL_WIDTH - 2, y + PANEL_HEIGHT - 2, 0xFFC6C6C6.toInt()) + // Panel background + border (matches deployer style) + graphics.fill(x, y, x + w, y + h, BG_PANEL) + graphics.fill(x, y, x + w, y + 1, BORDER) + graphics.fill(x, y + h - 1, x + w, y + h, BORDER) + graphics.fill(x, y, x + 1, y + h, BORDER) + graphics.fill(x + w - 1, y, x + w, y + h, BORDER) - // Terminal background - guiGraphics.fill(x + 6, y + 6, x + PANEL_WIDTH - 6, y + PANEL_HEIGHT - 6, 0xCC000000.toInt()) + // Title bar + graphics.fill(x + 1, y + 1, x + w - 1, y + 18, BG_TITLE) val j = job if (j == null) { - guiGraphics.drawCenteredString( - font, - Component.translatable("gui.cbbees.job_detail.not_found"), - x + PANEL_WIDTH / 2, - y + PANEL_HEIGHT / 2 - 4, - 0x909090 - ) - } else { - val textX = x + 12 - var textY = y + 12 - - // Job name - guiGraphics.drawString(font, "> Job ${j.name}", textX, textY, 0xFF44FF44.toInt(), false) - textY += 14 - - // Progress bar - val pct = if (j.total == 0) 1f else j.completed.toFloat() / j.total - val barW = PANEL_WIDTH - 90 - val barX = textX + 4 - guiGraphics.fill(barX - 1, textY - 1, barX + barW + 1, textY + 7, 0xFFAAAAAA.toInt()) - guiGraphics.fill(barX, textY, barX + barW, textY + 6, 0xFF000000.toInt()) - val filled = (barW * pct).toInt() - guiGraphics.fill(barX, textY, barX + filled, textY + 6, 0xFF00FF00.toInt()) - - val progressText = "[${j.completed}/${j.total}] ${(pct * 100).toInt()}%" - guiGraphics.drawString(font, progressText, barX + barW + 6, textY - 1, 0xFF44FF44.toInt(), false) - textY += 14 - - // Status - val statusColor = if (j.reason != null) 0xFFFF5555.toInt() else 0xFFAAAAAA.toInt() - val statusText = if (j.reason != null) "! STUCK: ${j.reason}" else " Status: ${j.status}" - guiGraphics.drawString(font, statusText, textX, textY, statusColor, false) - textY += 14 - - // Batches info - val activeBatches = j.batches.count { it.status != "COMPLETED" } - guiGraphics.drawString(font, " Batches: ${activeBatches} active", textX, textY, 0xFFAAAAAA.toInt(), false) + graphics.drawCenteredString(font, Component.translatable("gui.cbbees.job_detail.not_found"), + x + w / 2, y + 5, GRAY) + return + } + + // Title + val jobType = if (j.schematicPlacement != null) "Construction" else "Deconstruction" + graphics.drawCenteredString(font, "$jobType #${j.name}", x + w / 2, y + 5, GOLD) + + val innerW = w - MARGIN * 2 + var ty = y + 22 + + // Progress bar + val pct = if (j.total == 0) 1f else j.completed.toFloat() / j.total + val isStuck = j.reason != null + graphics.fill(x + MARGIN, ty, x + MARGIN + innerW, ty + BAR_H, BAR_BG) + val filled = (innerW * pct).toInt() + graphics.fill(x + MARGIN, ty, x + MARGIN + filled, ty + BAR_H, if (isStuck) RED_BAR else GREEN_BAR) + ty += BAR_H + 3 + + // Stats + graphics.drawString(font, "${j.completed}/${j.total} tasks ${(pct * 100).toInt()}%", + x + MARGIN, ty, WHITE, false) + ty += LINE_H + + // Bees + val activeBees = j.batches.flatMap { it.assignedBeeIds }.distinct().size + graphics.drawString(font, "Bees: $activeBees active", x + MARGIN, ty, GRAY, false) + ty += LINE_H + 3 + + // Stall reason + if (isStuck) { + val reason = j.reason!! + val reasonText = if (reason.startsWith("cbbees.")) { + Component.translatable(reason).string + } else reason + + val lines = font.splitter.splitLines(reasonText, innerW, + net.minecraft.network.chat.Style.EMPTY) + for ((i, line) in lines.withIndex()) { + if (i >= 3) { + graphics.drawString(font, "...", x + MARGIN, ty, RED, false) + ty += LINE_H + break + } + graphics.drawString(font, line.string, x + MARGIN, ty, RED, false) + ty += LINE_H + } + ty += 2 + } + + // Materials + val allRequired = j.batches + .filter { it.status != "COMPLETED" } + .flatMap { it.required } + .filter { !it.isEmpty } + if (allRequired.isNotEmpty()) { + graphics.drawString(font, "Materials:", x + MARGIN, ty, GOLD, false) + ty += LINE_H + + val aggregated = mutableMapOf() + for (stack in allRequired) { + val name = stack.hoverName.string + aggregated[name] = (aggregated[name] ?: 0) + stack.count + } + + var shown = 0 + for ((name, count) in aggregated) { + if (shown >= 4) { + graphics.drawString(font, " +${aggregated.size - 4} more...", x + MARGIN, ty, GRAY, false) + break + } + val text = " ${count}x $name" + val trimmed = if (font.width(text) > innerW) + font.plainSubstrByWidth(text, innerW - font.width("...")) + "..." + else text + graphics.drawString(font, trimmed, x + MARGIN, ty, WHITE, false) + ty += LINE_H + shown++ + } } } + private fun computePanelHeight(): Int { + val j = job ?: return 60 + var h = 22 // title bar + h += BAR_H + 3 // progress + h += LINE_H // stats + h += LINE_H + 3 // bees + + if (j.reason != null) { + val reason = if (j.reason.startsWith("cbbees.")) + Component.translatable(j.reason).string else j.reason + val lines = font.splitter.splitLines(reason, PANEL_W - MARGIN * 2, + net.minecraft.network.chat.Style.EMPTY) + h += minOf(lines.size, 3) * LINE_H + 2 + if (lines.size > 3) h += LINE_H + } + + val required = j.batches.filter { it.status != "COMPLETED" }.flatMap { it.required }.filter { !it.isEmpty } + if (required.isNotEmpty()) { + val unique = required.map { it.hoverName.string }.distinct().size + h += LINE_H // "Materials:" + h += minOf(unique, 4) * LINE_H + if (unique > 4) h += LINE_H + } + + h += 28 // cancel button + return h + } + override fun tick() { super.tick() refreshTicks++ @@ -117,11 +208,7 @@ class JobDetailScreen(private val jobId: UUID) : Screen(Component.translatable(" } val latest = ConstructionRenderer.getJobInfo(jobId) if (latest != job) refreshJob() - - // Close if job was completed/cancelled - if (latest == null && job != null) { - onClose() - } + if (latest == null && job != null) onClose() } override fun isPauseScreen(): Boolean = false diff --git a/src/main/kotlin/de/devin/cbbees/content/schematics/client/PickupHandler.kt b/src/main/kotlin/de/devin/cbbees/content/schematics/client/PickupHandler.kt index 9a07e5d..309ec01 100644 --- a/src/main/kotlin/de/devin/cbbees/content/schematics/client/PickupHandler.kt +++ b/src/main/kotlin/de/devin/cbbees/content/schematics/client/PickupHandler.kt @@ -1,198 +1,31 @@ package de.devin.cbbees.content.schematics.client -import com.simibubi.create.foundation.utility.RaycastHelper import de.devin.cbbees.content.deployer.SchematicProgram import de.devin.cbbees.items.AllItems -import de.devin.cbbees.network.ProgramSchematicPacket import de.devin.cbbees.network.StartPickupPacket -import de.devin.cbbees.registry.AllKeys -import net.createmod.catnip.math.VecHelper import net.minecraft.client.Minecraft -import net.minecraft.core.Direction -import net.minecraft.network.chat.Component -import net.minecraft.world.phys.AABB -import net.minecraft.world.phys.Vec3 -import net.neoforged.neoforge.network.PacketDistributor -import org.lwjgl.glfw.GLFW /** * Client-side handler for the Pickup Planner tool. - * - * Reuses [DeconstructionSelection] for the two-corner selection state and - * [DeconstructionRenderer] for the outline rendering (with a green tint). - * Sends [StartPickupPacket] on confirm or [ProgramSchematicPacket] with - * [SchematicProgram.Pickup] for deployer programming. + * Delegates all selection logic to [AreaSelectionHandler]. */ object PickupHandler { - private var selectedFace: Direction? = null + private val handler = AreaSelectionHandler( + keyPrefix = "cbbees.pickup", + isActive = ::isActive, + createPacket = { pos1, pos2 -> StartPickupPacket(pos1, pos2) }, + createProgram = { pos1, pos2 -> SchematicProgram.Pickup(pos1, pos2) }, + ) - fun onScroll(delta: Double): Boolean { - if (!isActive()) return false - if (!isCtrlDown()) return false - if (!DeconstructionSelection.isComplete()) return false - - val first = DeconstructionSelection.firstPos ?: return false - val second = DeconstructionSelection.secondPos ?: return false - val face = selectedFace ?: return false - - val axisDirection = face.axisDirection - val x = face.stepX - val y = face.stepY - val z = face.stepZ - val step = if (delta > 0) 1 else -1 - - var bb = AABB(Vec3.atLowerCornerOf(first), Vec3.atLowerCornerOf(second)) - .expandTowards(1.0, 1.0, 1.0) - - val maxX = maxOf(bb.maxX + x * step * axisDirection.step, bb.minX) - val maxY = maxOf(bb.maxY + y * step * axisDirection.step, bb.minY) - val maxZ = maxOf(bb.maxZ + z * step * axisDirection.step, bb.minZ) - - bb = AABB(bb.minX, bb.minY, bb.minZ, maxX, maxY, maxZ) - - DeconstructionSelection.firstPos = net.minecraft.core.BlockPos.containing(bb.minX, bb.minY, bb.minZ) - DeconstructionSelection.secondPos = net.minecraft.core.BlockPos.containing(bb.maxX, bb.maxY, bb.maxZ) - - val player = Minecraft.getInstance().player ?: return true - val sizeX = (bb.xsize + 1).toInt() - val sizeY = (bb.ysize + 1).toInt() - val sizeZ = (bb.zsize + 1).toInt() - player.displayClientMessage( - Component.translatable("cbbees.deconstruction.dimensions", sizeX, sizeY, sizeZ), - true - ) - return true - } - - fun onMouseInput(button: Int, pressed: Boolean): Boolean { - if (!pressed || button != GLFW.GLFW_MOUSE_BUTTON_RIGHT) return false - if (!isActive()) return false - - val player = Minecraft.getInstance().player ?: return false - - if (player.isShiftKeyDown) { - discard() - return true - } - - if (DeconstructionSelection.secondPos != null) return true - - if (DeconstructionSelection.selectedPos == null) { - player.displayClientMessage(Component.translatable("cbbees.deconstruction.no_target"), true) - return true - } - - if (DeconstructionSelection.firstPos != null) { - DeconstructionSelection.secondPos = DeconstructionSelection.selectedPos - player.displayClientMessage( - Component.translatable("cbbees.deconstruction.second_pos", AllKeys.START_ACTION.translatedKeyMessage), - true - ) - return true - } - - DeconstructionSelection.firstPos = DeconstructionSelection.selectedPos - player.displayClientMessage(Component.translatable("cbbees.deconstruction.first_pos"), true) - return true - } - - fun discard() { - DeconstructionSelection.discard() - Minecraft.getInstance().player?.displayClientMessage( - Component.translatable("cbbees.deconstruction.abort"), true - ) - } - - fun tick() { - if (!isActive()) return - - val player = Minecraft.getInstance().player ?: return - - // Raycast for block selection - if (DeconstructionSelection.secondPos == null) { - val result = RaycastHelper.rayTraceRange(player.level(), player, 75.0) - if (result.type == net.minecraft.world.phys.HitResult.Type.BLOCK) { - DeconstructionSelection.selectedPos = net.minecraft.core.BlockPos.containing(result.location) - } else { - DeconstructionSelection.selectedPos = null - } - } - - // Update selected face for resizing - selectedFace = null - val first = DeconstructionSelection.firstPos - val second = DeconstructionSelection.secondPos - if (first != null && second != null) { - var bb = AABB(Vec3.atLowerCornerOf(first), Vec3.atLowerCornerOf(second)) - .expandTowards(1.0, 1.0, 1.0) - .inflate(0.45) - val projectedView = Minecraft.getInstance().gameRenderer.mainCamera.position - val inside = bb.contains(projectedView) - val result = RaycastHelper.rayTraceUntil(player, 70.0) { pos -> - inside xor bb.contains(VecHelper.getCenterOf(pos)) - } - selectedFace = when { - result.missed() -> null - inside -> result.facing.opposite - else -> result.facing - } - } - - // Render with green outline - DeconstructionRenderer.renderWorldOutline(selectedFace) - } - - fun onKeyInput(key: Int, pressed: Boolean): Boolean { - if (!pressed || !isActive()) return false - - if (AllKeys.START_ACTION.matches(key, 0) && DeconstructionSelection.isComplete()) { - val first = DeconstructionSelection.firstPos!! - val second = DeconstructionSelection.secondPos!! - - PacketDistributor.sendToServer(StartPickupPacket(first, second)) - - Minecraft.getInstance().player?.displayClientMessage( - Component.translatable("message.cbbees.planner.started") - .withStyle { it.withColor(0x00FF00) }, - true - ) - discard() - return true - } - - if (AllKeys.STOP_ACTION.matches(key, 0)) { - PacketDistributor.sendToServer(de.devin.cbbees.network.StopTasksPacket.INSTANCE) - return true - } - - if (AllKeys.PROGRAM_ACTION.matches(key, 0) && DeconstructionSelection.isComplete()) { - val first = DeconstructionSelection.firstPos!! - val second = DeconstructionSelection.secondPos!! - - val program = SchematicProgram.Pickup(first, second) - PacketDistributor.sendToServer(ProgramSchematicPacket(program)) - - Minecraft.getInstance().player?.displayClientMessage( - Component.translatable("cbbees.schematic.programmed") - .withStyle { it.withColor(0x88CCFF) }, - true - ) - return true - } - - return false - } + fun onScroll(delta: Double) = handler.onScroll(delta) + fun onMouseInput(button: Int, pressed: Boolean) = handler.onMouseInput(button, pressed) + fun onKeyInput(key: Int, pressed: Boolean) = handler.onKeyInput(key, pressed) + fun tick() = handler.tick() + fun discard() = handler.discard() fun isActive(): Boolean { val player = Minecraft.getInstance().player ?: return false return AllItems.PICKUP_PLANNER.isIn(player.mainHandItem) } - - private fun isCtrlDown(): Boolean { - return Minecraft.getInstance().window?.let { window -> - GLFW.glfwGetKey(window.window, GLFW.GLFW_KEY_LEFT_CONTROL) == GLFW.GLFW_PRESS || - GLFW.glfwGetKey(window.window, GLFW.GLFW_KEY_RIGHT_CONTROL) == GLFW.GLFW_PRESS - } ?: false - } } diff --git a/src/main/kotlin/de/devin/cbbees/content/upgrades/BeeContext.kt b/src/main/kotlin/de/devin/cbbees/content/upgrades/BeeContext.kt index f1321a8..1c936db 100644 --- a/src/main/kotlin/de/devin/cbbees/content/upgrades/BeeContext.kt +++ b/src/main/kotlin/de/devin/cbbees/content/upgrades/BeeContext.kt @@ -24,5 +24,5 @@ data class BeeContext( /** Whether the drone view ability is available */ var droneViewAvailable: Boolean = false, /** Maximum range the drone can fly from the player */ - var droneRange: Double = CBBeesConfig.droneBaseRange.get() + var droneRange: Double = CBBeesConfig.droneBaseRange.get(), ) diff --git a/src/main/kotlin/de/devin/cbbees/content/upgrades/BeeUpgradeItem.kt b/src/main/kotlin/de/devin/cbbees/content/upgrades/BeeUpgradeItem.kt index 19bd159..ef5f0b5 100644 --- a/src/main/kotlin/de/devin/cbbees/content/upgrades/BeeUpgradeItem.kt +++ b/src/main/kotlin/de/devin/cbbees/content/upgrades/BeeUpgradeItem.kt @@ -33,9 +33,6 @@ enum class UpgradeType( HONEY_TANK(2, "tooltip.cbbees.upgrade.honey_tank", UpgradeShape.SQUARE_2X2, IUpgrade { ctx, count -> ctx.honeyCapacityBonus += count * CBBeesConfig.honeyTankCapacityBonus.get() }), - REINFORCED_PLATING(2, "tooltip.cbbees.upgrade.reinforced_plating", UpgradeShape.S_SHAPE, IUpgrade { ctx, count -> - ctx.springEfficiency += count * CBBeesConfig.reinforcedPlatingSpringBonus.get() - }), DRONE_VIEW(1, "tooltip.cbbees.upgrade.drone_view", UpgradeShape.BAR_2, IUpgrade { ctx, count -> if (count > 0) ctx.droneViewAvailable = true }), @@ -91,8 +88,6 @@ class DropItemsUpgrade(properties: Properties) : BeeUpgradeItem(UpgradeType.DROP class HoneyTankUpgrade(properties: Properties) : BeeUpgradeItem(UpgradeType.HONEY_TANK, properties) -class ReinforcedPlatingUpgrade(properties: Properties) : BeeUpgradeItem(UpgradeType.REINFORCED_PLATING, properties) - class DroneViewUpgrade(properties: Properties) : BeeUpgradeItem(UpgradeType.DRONE_VIEW, properties) class DroneRangeUpgrade(properties: Properties) : BeeUpgradeItem(UpgradeType.DRONE_RANGE, properties) diff --git a/src/main/kotlin/de/devin/cbbees/content/upgrades/UpgradeShape.kt b/src/main/kotlin/de/devin/cbbees/content/upgrades/UpgradeShape.kt index 100e4cc..384addc 100644 --- a/src/main/kotlin/de/devin/cbbees/content/upgrades/UpgradeShape.kt +++ b/src/main/kotlin/de/devin/cbbees/content/upgrades/UpgradeShape.kt @@ -70,5 +70,16 @@ data class UpgradeShape(val cells: List>) { * ``` */ val S_SHAPE = UpgradeShape(listOf(1 to 0, 2 to 0, 0 to 1, 1 to 1)) + + /** 3x3 square: 9 cells + * ``` + * ███ + * ███ + * ███ + * ``` + */ + val SQUARE_3X3 = UpgradeShape( + (0..2).flatMap { x -> (0..2).map { y -> x to y } } + ) } } diff --git a/src/main/kotlin/de/devin/cbbees/gametest/CBBeesGameTests.kt b/src/main/kotlin/de/devin/cbbees/gametest/CBBeesGameTests.kt new file mode 100644 index 0000000..ad66582 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/gametest/CBBeesGameTests.kt @@ -0,0 +1,68 @@ +package de.devin.cbbees.gametest + +import de.devin.cbbees.CreateBuzzyBeez +import net.minecraft.gametest.framework.GameTest +import net.minecraft.gametest.framework.GameTestHelper +import net.minecraft.gametest.framework.TestFunction +import net.neoforged.neoforge.event.RegisterGameTestsEvent +import java.lang.reflect.Method +import java.util.function.Consumer + +/** + * Registers all cbbees game tests with NeoForge's GameTest framework. + * + * Manually constructs [TestFunction] objects with the `cbbees:` namespace prefix + * so they pass NeoForge's namespace filter. + */ +object CBBeesGameTests { + + private val testHolders = arrayOf( + de.devin.cbbees.gametest.flight.FlightPlanTests::class.java, + de.devin.cbbees.gametest.inventory.BeeInventoryTests::class.java, + de.devin.cbbees.gametest.deconstruction.DeconstructionTests::class.java, + de.devin.cbbees.gametest.logistics.LogisticsPortTests::class.java, + de.devin.cbbees.gametest.construction.ConstructionTests::class.java, + de.devin.cbbees.gametest.transport.TransportTests::class.java, + de.devin.cbbees.gametest.portable.PortableBeehiveTests::class.java, + ) + + fun onRegisterGameTests(event: RegisterGameTestsEvent) { + event.register(CBBeesGameTests::class.java) + } + + @net.minecraft.gametest.framework.GameTestGenerator + @JvmStatic + fun generateTests(): Collection { + return testHolders.flatMap { holder -> + holder.declaredMethods + .filter { it.isAnnotationPresent(GameTest::class.java) } + .map { toTestFunction(it) } + } + } + + private fun toTestFunction(method: Method): TestFunction { + val annotation = method.getAnnotation(GameTest::class.java) + val className = method.declaringClass.simpleName + val testName = "${CreateBuzzyBeez.ID}:$className.${method.name}" + val template = annotation.template.let { + if (it.contains(":")) it else "${CreateBuzzyBeez.ID}:$it" + } + + return TestFunction( + annotation.batch, // batchName + testName, // testName + template, // structureName + net.minecraft.world.level.block.Rotation.NONE, // rotation + annotation.timeoutTicks, // maxTicks + annotation.setupTicks.toLong(), // setupTicks + annotation.required, // required + false, // manualOnly + annotation.attempts, // maxAttempts + annotation.requiredSuccesses, // requiredSuccesses + false, // skyAccess + Consumer { helper: GameTestHelper -> + method.invoke(null, helper) + }, + ) + } +} diff --git a/src/main/kotlin/de/devin/cbbees/gametest/GameTestSetup.kt b/src/main/kotlin/de/devin/cbbees/gametest/GameTestSetup.kt new file mode 100644 index 0000000..40fa5b1 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/gametest/GameTestSetup.kt @@ -0,0 +1,240 @@ +package de.devin.cbbees.gametest + +import de.devin.cbbees.content.beehive.MechanicalBeehiveBlockEntity +import de.devin.cbbees.content.domain.JobPool +import de.devin.cbbees.content.domain.job.BeeJob +import de.devin.cbbees.content.domain.network.BeeNetwork +import de.devin.cbbees.content.domain.network.INetworkComponent +import de.devin.cbbees.content.domain.network.ServerBeeNetworkManager +import de.devin.cbbees.content.logistics.ports.LogisticPortBlockEntity +import de.devin.cbbees.content.logistics.transport.TransportPortBlockEntity +import de.devin.cbbees.content.schematics.SchematicCreateBridge +import de.devin.cbbees.gametest.dsl.GameTestDsl +import de.devin.cbbees.items.AllItems +import net.minecraft.core.BlockPos +import net.minecraft.gametest.framework.GameTestAssertException +import net.minecraft.gametest.framework.GameTestHelper +import net.minecraft.server.level.ServerLevel +import net.minecraft.world.item.Item +import net.minecraft.world.item.ItemStack +import net.minecraft.world.level.block.Block +import net.neoforged.neoforge.capabilities.Capabilities +import java.util.UUID + +/** + * Shared utilities for integration game tests. + */ +object GameTestSetup { + + // ── Structure Scanning ── + + /** + * Scans a test structure using the provided [ScanConfig]. + */ + fun scanStructure(helper: GameTestHelper, level: ServerLevel, config: ScanConfig): StructureScan { + val bounds = helper.bounds + var beehive: MechanicalBeehiveBlockEntity? = null + val targetPositions = mutableListOf() + val ports = mutableListOf() + val transportPorts = mutableListOf() + val allPositions = mutableListOf() + + val targetBlocks = config.targetBlocks + + for (x in bounds.minX.toInt()..bounds.maxX.toInt()) { + for (y in bounds.minY.toInt()..bounds.maxY.toInt()) { + for (z in bounds.minZ.toInt()..bounds.maxZ.toInt()) { + val worldPos = BlockPos(x, y, z) + allPositions.add(worldPos) + val be = level.getBlockEntity(worldPos) + if (be is MechanicalBeehiveBlockEntity) beehive = be + if (be is LogisticPortBlockEntity) ports.add(be) + if (be is TransportPortBlockEntity) transportPorts.add(be) + val state = level.getBlockState(worldPos) + if (targetBlocks.any { state.`is`(it) }) { + targetPositions.add(worldPos) + } + } + } + } + + return StructureScan(beehive, targetPositions, ports, transportPorts, allPositions, config) + } + + // ── Network Setup ── + + fun setupBeehiveNetwork( + beehive: MechanicalBeehiveBlockEntity, + ports: List, + transportPorts: List = emptyList(), + level: ServerLevel, + beeCount: Int = 5, + ): TestJobPool { + val beeItem = ItemStack(AllItems.MECHANICAL_BEE.get()) + repeat(beeCount) { beehive.addBee(beeItem.copy()) } + + if (beehive.speed <= 0f) beehive.setSpeed(64f) + + // Collect all network components to register + val allComponents = mutableListOf() + allComponents.addAll(ports) + allComponents.addAll(transportPorts) + + // Unregister from any auto-registered networks (structure onLoad) + ServerBeeNetworkManager.unregisterWorker(beehive) + for (comp in allComponents) { + ServerBeeNetworkManager.unregisterComponent(comp) + } + + val networkId = UUID.randomUUID() + beehive.networkId = networkId + val network = BeeNetwork(networkId) + network.addComponent(beehive) + for (comp in allComponents) { + comp.networkId = networkId + network.addComponent(comp) + } + ServerBeeNetworkManager.addNetwork(network) + + return TestJobPool(network) + } + + // ── Job Dispatch ── + + fun dispatchDeconstruction( + positions: List, + level: ServerLevel, + jobPool: JobPool, + ): BeeJob { + val corner1 = positions.minWith(compareBy({ it.x }, { it.y }, { it.z })) + val corner2 = positions.maxWith(compareBy({ it.x }, { it.y }, { it.z })) + + val job = BeeJob( + jobId = UUID.randomUUID(), + centerPos = BlockPos( + (corner1.x + corner2.x) / 2, + (corner1.y + corner2.y) / 2, + (corner1.z + corner2.z) / 2, + ), + level = level, + ) + + val batches = SchematicCreateBridge(level).generateRemovalTasks(corner1, corner2, job) + job.addBatches(batches) + jobPool.dispatchNewJob(job) + return job + } + + /** + * Creates a construction job that places blocks at the given positions. + * Each entry maps a world position to a (blockState, requiredItems) pair. + */ + fun dispatchConstruction( + placements: List, + level: ServerLevel, + jobPool: JobPool, + ): BeeJob { + val positions = placements.map { it.pos } + val center = BlockPos( + positions.sumOf { it.x } / positions.size, + positions.sumOf { it.y } / positions.size, + positions.sumOf { it.z } / positions.size, + ) + + val job = BeeJob( + jobId = UUID.randomUUID(), + centerPos = center, + level = level, + ) + + val batches = placements.map { placement -> + val task = de.devin.cbbees.content.domain.task.BeeTask.place( + pos = placement.pos, + state = placement.state, + items = placement.items, + job = job, + ) + de.devin.cbbees.content.domain.task.TaskBatch(listOf(task), job, placement.pos) + } + + job.addBatches(batches) + jobPool.dispatchNewJob(job) + return job + } + + data class PlacementTask( + val pos: BlockPos, + val state: net.minecraft.world.level.block.state.BlockState, + val items: List, + ) + + // ── Helpers ── + + fun countItem(level: ServerLevel, pos: BlockPos, item: Item): Int { + val handler = level.getCapability(Capabilities.ItemHandler.BLOCK, pos, null) ?: return 0 + var count = 0 + for (slot in 0 until handler.slots) { + val stack = handler.getStackInSlot(slot) + if (stack.`is`(item)) count += stack.count + } + return count + } + + fun assertOrRetry(check: () -> Unit) { + try { + check() + } catch (e: GameTestAssertException) { + throw e + } catch (e: Exception) { + throw GameTestAssertException("Unexpected error: ${e.message}") + } + } + + // ── Data Classes ── + + /** + * Configuration for structure scanning, built via [ScanConfigBuilder]. + */ + data class ScanConfig( + val targetBlocks: List, + val expectedItems: List, + ) + + data class ExpectedItem(val item: Item, val count: Int) + + /** + * Builder DSL for [ScanConfig]. + * + * ```kotlin + * scanStructure { + * target(Blocks.DIRT) + * expect(Items.DIRT, count = 27) + * } + * ``` + */ + @GameTestDsl + class ScanConfigBuilder { + private val targets = mutableListOf() + private val expected = mutableListOf() + + /** Declares a block type that should be targeted for deconstruction. */ + fun target(block: Block) { targets.add(block) } + + /** Declares an expected item and count that should end up in inventories after the test. */ + fun expect(item: Item, count: Int) { expected.add(ExpectedItem(item, count)) } + + fun build() = ScanConfig(targets.toList(), expected.toList()) + } + + /** + * Result of scanning a test structure. + */ + data class StructureScan( + val beehive: MechanicalBeehiveBlockEntity?, + val targetPositions: List, + val ports: List, + val transportPorts: List, + val allPositions: List, + val config: ScanConfig, + ) +} diff --git a/src/main/kotlin/de/devin/cbbees/gametest/TestJobPool.kt b/src/main/kotlin/de/devin/cbbees/gametest/TestJobPool.kt new file mode 100644 index 0000000..f441b18 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/gametest/TestJobPool.kt @@ -0,0 +1,47 @@ +package de.devin.cbbees.gametest + +import de.devin.cbbees.content.domain.JobPool +import de.devin.cbbees.content.domain.job.BeeJob +import de.devin.cbbees.content.domain.job.JobStatus +import de.devin.cbbees.content.domain.network.BeeNetwork +import de.devin.cbbees.content.domain.task.TaskStatus + +/** + * Test-scoped [JobPool] that dispatches all batches to a single known network. + * + * Unlike [de.devin.cbbees.content.domain.GlobalJobPool] which scans all networks + * and picks the closest one, this implementation guarantees isolation between + * parallel gametest structures. + * + * [tick] re-dispatches pending batches so bees that complete their task and + * return to the hive can pick up the next batch. + */ +class TestJobPool(val network: BeeNetwork) : JobPool { + + private val jobs = mutableListOf() + + override fun dispatchNewJob(job: BeeJob) { + jobs.add(job) + dispatchPending() + } + + override fun getAllJobs(): List = jobs + + override fun tick(gameTime: Long) { + jobs.removeAll { it.status == JobStatus.COMPLETED || it.status == JobStatus.CANCELLED } + dispatchPending() + } + + private fun dispatchPending() { + for (job in jobs) { + if (job.status == JobStatus.COMPLETED || job.status == JobStatus.CANCELLED) continue + for (batch in job.batches) { + if (batch.status != TaskStatus.PENDING) continue + if (!batch.canRetry()) continue + if (!job.isPhaseReady(batch.phase)) continue + batch.assignedNetworkId = network.id + network.dispatchBatch(batch) + } + } + } +} diff --git a/src/main/kotlin/de/devin/cbbees/gametest/construction/ConstructionTests.kt b/src/main/kotlin/de/devin/cbbees/gametest/construction/ConstructionTests.kt new file mode 100644 index 0000000..1163bc6 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/gametest/construction/ConstructionTests.kt @@ -0,0 +1,115 @@ +package de.devin.cbbees.gametest.construction + +import de.devin.cbbees.gametest.GameTestSetup +import de.devin.cbbees.gametest.dsl.beeTest +import net.minecraft.core.BlockPos +import net.minecraft.gametest.framework.GameTest +import net.minecraft.gametest.framework.GameTestHelper +import net.minecraft.world.item.ItemStack +import net.minecraft.world.item.Items +import net.minecraft.world.level.block.Blocks + +/** + * Integration tests for construction (block placement from materials). + * + * Structure layout (cbbees_construction.nbt, 7x3x8): + * - Extract port at (3,2,6) on vault at (3,1,6) — material source + * - Insert port at (1,2,6) on vault at (1,1,6) — drop-off + * - Beehive at (5,1,6) powered by creative motor + * - Empty area at z=0-4 for building + * + * Tests create placement tasks programmatically (no schematic file needed). + * The bee must gather materials from the extract port, fly to the target, + * and place the block — consuming the item in the process. + */ +object ConstructionTests { + + private const val TEMPLATE = "cbbees:gametest/construction/cbbees_construction" + + /** + * Bees place 9 dirt blocks in a 3x3 grid, consuming dirt from the vault. + */ + @GameTest(template = TEMPLATE, timeoutTicks = 6000, setupTicks = 20) + @JvmStatic + fun construct_placesBlocksFromVault(helper: GameTestHelper) = helper.beeTest { + val scan = scanStructure {} + val jobPool = setupNetwork(scan) + + // Insert 9 dirt into the extract vault (material source) + val extractVault = scan.ports.find { it.isValidForPickup() } + ?: throw net.minecraft.gametest.framework.GameTestAssertException("No extract port found") + insertItems(extractVault.blockPos.below(), Items.DIRT, 9) + + // Create placement tasks: 3x3 dirt grid at y=1, z=2 + val placements = mutableListOf() + for (x in 1..3) { + for (z in 1..3) { + val worldPos = helper.absolutePos(BlockPos(x, 1, z)) + placements.add(GameTestSetup.PlacementTask( + pos = worldPos, + state = Blocks.DIRT.defaultBlockState(), + items = listOf(ItemStack(Items.DIRT, 1)), + )) + } + } + construct(placements) + + awaitSuccess { + // All 9 blocks should be placed + for (placement in placements) { + val state = level.getBlockState(placement.pos) + if (!state.`is`(Blocks.DIRT)) { + throw net.minecraft.gametest.framework.GameTestAssertException( + "Expected dirt at ${placement.pos}, got ${state.block}" + ) + } + } + + // Materials should be consumed from the vault + val remaining = GameTestSetup.countItem(level, extractVault.blockPos.below(), Items.DIRT) + check(remaining == 0) { "Vault should be empty after construction, but has $remaining dirt" } + } + } + + /** + * Bees stall when the vault has insufficient materials. + * Only the blocks with available materials should be placed. + */ + @GameTest(template = TEMPLATE, timeoutTicks = 6000, setupTicks = 20) + @JvmStatic + fun construct_stallsWhenMissingMaterials(helper: GameTestHelper) = helper.beeTest { + val scan = scanStructure {} + val jobPool = setupNetwork(scan) + + // Insert only 4 dirt — not enough for 9 placements + val extractVault = scan.ports.find { it.isValidForPickup() } + ?: throw net.minecraft.gametest.framework.GameTestAssertException("No extract port found") + insertItems(extractVault.blockPos.below(), Items.DIRT, 4) + + val placements = mutableListOf() + for (x in 1..3) { + for (z in 1..3) { + val worldPos = helper.absolutePos(BlockPos(x, 1, z)) + placements.add(GameTestSetup.PlacementTask( + pos = worldPos, + state = Blocks.DIRT.defaultBlockState(), + items = listOf(ItemStack(Items.DIRT, 1)), + )) + } + } + construct(placements) + + // Wait a bit for bees to place what they can, then verify partial progress + awaitSuccess { + // Count how many dirt blocks were placed + val placed = placements.count { level.getBlockState(it.pos).`is`(Blocks.DIRT) } + // Should have placed exactly 4 (the available materials) + check(placed >= 4) { "Expected at least 4 blocks placed with 4 materials, got $placed" } + // Should NOT have placed all 9 + check(placed < 9) { "Placed $placed blocks but only 4 materials were available" } + // Vault should be empty + val remaining = GameTestSetup.countItem(level, extractVault.blockPos.below(), Items.DIRT) + check(remaining == 0) { "Vault should be empty, has $remaining" } + } + } +} diff --git a/src/main/kotlin/de/devin/cbbees/gametest/deconstruction/DeconstructionTests.kt b/src/main/kotlin/de/devin/cbbees/gametest/deconstruction/DeconstructionTests.kt new file mode 100644 index 0000000..77a7ba7 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/gametest/deconstruction/DeconstructionTests.kt @@ -0,0 +1,27 @@ +package de.devin.cbbees.gametest.deconstruction + +import de.devin.cbbees.gametest.dsl.beeTest +import net.minecraft.gametest.framework.GameTest +import net.minecraft.gametest.framework.GameTestHelper +import net.minecraft.world.item.Items +import net.minecraft.world.level.block.Blocks + +object DeconstructionTests { + + private const val TEMPLATE = "cbbees:gametest/cbbees_deconstruct" + + @GameTest(template = TEMPLATE, timeoutTicks = 6000, setupTicks = 20) + @JvmStatic + fun deconstruct_dirtBlocks_endsUpInVault(helper: GameTestHelper) = helper.beeTest { + val scan = scanStructure { + target(Blocks.DIRT) + expect(Items.DIRT, count = 27) + } + setupNetwork(scan) + deconstruct() + + awaitSuccess { + assertTargetsDeconstructed() + } + } +} diff --git a/src/main/kotlin/de/devin/cbbees/gametest/dsl/Assertions.kt b/src/main/kotlin/de/devin/cbbees/gametest/dsl/Assertions.kt new file mode 100644 index 0000000..0ba1f90 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/gametest/dsl/Assertions.kt @@ -0,0 +1,40 @@ +package de.devin.cbbees.gametest.dsl + +import net.minecraft.world.item.Item +import net.minecraft.world.item.ItemStack + +/** Assert an [ItemStack] matches the expected item and count. */ +fun UnitTestScope.assertItemStack( + stack: ItemStack, + expectedItem: Item, + expectedCount: Int, + label: String = "ItemStack", +) { + if (!stack.`is`(expectedItem)) { + fail("$label: expected ${expectedItem.description.string}, got ${stack.item.description.string}") + } + if (stack.count != expectedCount) { + fail("$label: expected count $expectedCount, got ${stack.count}") + } +} + +fun UnitTestScope.assertStackEmpty(stack: ItemStack, label: String = "remainder") { + if (!stack.isEmpty) { + fail("$label should be empty, but has ${stack.count} ${stack.item.description.string}") + } +} + +fun UnitTestScope.assertLongArrayEquals( + expected: LongArray, + actual: LongArray, + label: String = "LongArray", +) { + if (expected.size != actual.size) { + fail("$label: size mismatch - expected ${expected.size}, got ${actual.size}") + } + for (i in expected.indices) { + if (expected[i] != actual[i]) { + fail("$label[$i]: expected ${expected[i]}, got ${actual[i]}") + } + } +} diff --git a/src/main/kotlin/de/devin/cbbees/gametest/dsl/BeeTestDsl.kt b/src/main/kotlin/de/devin/cbbees/gametest/dsl/BeeTestDsl.kt new file mode 100644 index 0000000..257073d --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/gametest/dsl/BeeTestDsl.kt @@ -0,0 +1,318 @@ +package de.devin.cbbees.gametest.dsl + +import com.mojang.authlib.GameProfile +import de.devin.cbbees.content.backpack.PortableBeehiveItem +import de.devin.cbbees.content.beehive.MechanicalBeehiveBlockEntity +import de.devin.cbbees.content.domain.beehive.PortableBeeHive +import de.devin.cbbees.content.domain.job.BeeJob +import de.devin.cbbees.content.domain.network.BeeNetwork +import de.devin.cbbees.content.domain.network.ServerBeeNetworkManager +import de.devin.cbbees.content.logistics.ports.LogisticPortBlockEntity +import de.devin.cbbees.content.logistics.transport.TransportPortBlockEntity +import de.devin.cbbees.gametest.GameTestSetup +import de.devin.cbbees.gametest.TestJobPool +import de.devin.cbbees.items.AllItems +import net.minecraft.core.BlockPos +import net.minecraft.gametest.framework.GameTestAssertException +import net.minecraft.gametest.framework.GameTestHelper +import net.minecraft.server.level.ServerLevel +import net.minecraft.world.entity.player.Player +import net.minecraft.world.item.Item +import net.minecraft.world.item.ItemStack +import net.neoforged.neoforge.capabilities.Capabilities +import net.neoforged.neoforge.common.util.FakePlayerFactory +import net.neoforged.neoforge.items.ItemHandlerHelper +import net.minecraft.world.level.block.state.BlockState +import java.util.UUID + +/** + * Scope for integration tests that require a beehive network, + * structure scanning, and async job dispatch. + */ +@GameTestDsl +class BeeTestScope( + val helper: GameTestHelper, + val level: ServerLevel, +) { + lateinit var scan: GameTestSetup.StructureScan + private set + lateinit var jobPool: TestJobPool + private set + + // ── Structure Scanning ── + + /** + * Scans the test structure using the given config. + * + * ```kotlin + * val scan = scanStructure { + * target(Blocks.DIRT) + * expect(Items.DIRT, count = 27) + * } + * ``` + */ + fun scanStructure(config: GameTestSetup.ScanConfigBuilder.() -> Unit): GameTestSetup.StructureScan { + val builder = GameTestSetup.ScanConfigBuilder() + builder.config() + scan = GameTestSetup.scanStructure(helper, level, builder.build()) + return scan + } + + // ── Network Setup ── + + fun setupNetwork( + scan: GameTestSetup.StructureScan = this.scan, + beeCount: Int = 5, + ): TestJobPool { + val hive = scan.beehive + ?: throw GameTestAssertException("No mechanical beehive found in structure") + jobPool = GameTestSetup.setupBeehiveNetwork(hive, scan.ports, scan.transportPorts, level, beeCount) + return jobPool + } + + // ── Portable Beehive ── + + /** + * Creates a fake player with a portable beehive backpack and registers + * a [PortableBeeHive] in its own isolated network. + * + * @param pos where to place the player in the world + * @param beeCount number of bees to stock in the backpack + * @param joinExistingNetwork if true, adds the portable hive to the existing + * test network (from [setupNetwork]). If false, creates a new isolated network. + * @return the [PortableBeeHive] for assertions + */ + fun setupPortableBeehive( + pos: BlockPos, + beeCount: Int = 5, + joinExistingNetwork: Boolean = false, + ): PortableBeeHive { + val profile = GameProfile(UUID.randomUUID(), "TestPlayer") + val fakePlayer = FakePlayerFactory.get(level, profile) + fakePlayer.setPos(pos.x + 0.5, pos.y.toDouble(), pos.z + 0.5) + + // Equip portable beehive in chestplate slot with bees and honey + val backpack = ItemStack(AllItems.PORTABLE_BEEHIVE.get()) + val backpackItem = backpack.item as PortableBeehiveItem + val beeItem = ItemStack(AllItems.MECHANICAL_BEE.get()) + repeat(beeCount) { backpackItem.addRobot(backpack, beeItem.copy()) } + // Give honey fuel (1000 should be enough for tests) + backpack.set(de.devin.cbbees.registry.AllDataComponents.HONEY_FUEL.get(), 1000) + fakePlayer.inventory.armor[2] = backpack + + val portableHive = PortableBeeHive(fakePlayer) + + if (joinExistingNetwork && ::jobPool.isInitialized) { + // Add to existing test network directly via jobPool reference + val network = jobPool.network + portableHive.networkId = network.id + network.addComponent(portableHive) + ServerBeeNetworkManager.rebuildIndexes() + } else { + // Create isolated portable network + val networkId = ServerBeeNetworkManager.stableNetworkId(fakePlayer.uuid) + portableHive.networkId = networkId + val network = BeeNetwork(networkId) + network.addComponent(portableHive) + ServerBeeNetworkManager.addNetwork(network) + // Only set jobPool if not already initialized (don't overwrite stationary network's pool) + if (!::jobPool.isInitialized) { + jobPool = TestJobPool(network) + } + } + + return portableHive + } + + // ── Job Dispatch ── + + fun deconstruct(positions: List = scan.targetPositions): BeeJob { + if (positions.isEmpty()) { + throw GameTestAssertException("No block positions to deconstruct") + } + return GameTestSetup.dispatchDeconstruction(positions, level, jobPool) + } + + /** + * Dispatches a construction job that places blocks at the given positions. + */ + fun construct(placements: List): de.devin.cbbees.content.domain.job.BeeJob { + if (placements.isEmpty()) { + throw GameTestAssertException("No placement tasks") + } + return GameTestSetup.dispatchConstruction(placements, level, jobPool) + } + + // ── Port Queries ── + + fun portsByPriority(): List { + return scan.ports + .sortedByDescending { it.priority } + .map { PortWithVault(it, it.blockPos.below()) } + } + + data class PortWithVault( + val port: LogisticPortBlockEntity, + val vaultPos: BlockPos, + ) { + val priority: Int get() = port.priority + } + + // ── Transport Port Queries ── + + fun transportPortsByPriority(): List { + return scan.transportPorts + .sortedByDescending { it.priority() } + .map { TransportPortWithVault(it, it.blockPos.below()) } + } + + fun providerPorts(): List { + return scan.transportPorts + .filter { it.isValidProvider() } + .map { TransportPortWithVault(it, it.blockPos.below()) } + } + + fun requesterPorts(): List { + return scan.transportPorts + .filter { it.isValidRequester() } + .sortedByDescending { it.priority() } + .map { TransportPortWithVault(it, it.blockPos.below()) } + } + + data class TransportPortWithVault( + val port: TransportPortBlockEntity, + val vaultPos: BlockPos, + ) { + val priority: Int get() = port.priority() + } + + /** + * Inserts items into the inventory at the given position (e.g., a vault). + */ + fun insertItems(pos: BlockPos, item: Item, count: Int) { + val handler = level.getCapability(Capabilities.ItemHandler.BLOCK, pos, null) + ?: throw GameTestAssertException("No item handler at $pos") + val remainder = ItemHandlerHelper.insertItemStacked(handler, ItemStack(item, count), false) + if (!remainder.isEmpty) { + throw GameTestAssertException("Could not insert all items at $pos, ${remainder.count} remaining") + } + } + + // ── Async Success ── + + /** + * Runs [block] every tick until all assertions pass or the test times out. + * Auto-ticks the [jobPool] before each check. + */ + fun awaitSuccess(block: AssertionScope.() -> Unit) { + helper.succeedWhen { + GameTestSetup.assertOrRetry { + if (::jobPool.isInitialized) { + jobPool.tick(level.gameTime) + } + AssertionScope(level, if (::scan.isInitialized) scan else null).block() + } + } + } +} + +/** + * Scope available inside [BeeTestScope.awaitSuccess] blocks. + * Provides world-aware assertion helpers that retry on failure. + */ +@GameTestDsl +class AssertionScope( + val level: ServerLevel, + private val scan: GameTestSetup.StructureScan?, +) { + + fun assertAllAir(positions: List) { + for (pos in positions) { + if (!level.getBlockState(pos).isAir) { + throw GameTestAssertException("Block at $pos is not air: ${level.getBlockState(pos)}") + } + } + } + + fun assertAllBlocks(positions: List, label: String = "block", predicate: (BlockState) -> Boolean) { + for (pos in positions) { + val state = level.getBlockState(pos) + if (!predicate(state)) { + throw GameTestAssertException("$label failed at $pos: ${state.block}") + } + } + } + + fun assertItemCount(positions: List, item: Item, atLeast: Int) { + var total = 0 + for (pos in positions) { + total += GameTestSetup.countItem(level, pos, item) + } + if (total < atLeast) { + throw GameTestAssertException( + "Expected at least $atLeast ${item.description.string}, found $total" + ) + } + } + + fun assertItemCountAt(pos: BlockPos, item: Item, atLeast: Int) { + val count = GameTestSetup.countItem(level, pos, item) + if (count < atLeast) { + throw GameTestAssertException( + "Expected at least $atLeast ${item.description.string} at $pos, found $count" + ) + } + } + + fun assertNoItems(pos: BlockPos, item: Item) { + val count = GameTestSetup.countItem(level, pos, item) + if (count > 0) { + throw GameTestAssertException( + "Expected no ${item.description.string} at $pos, found $count" + ) + } + } + + /** + * Asserts that all targets from the scan config have been removed (are now air) + * and all expected items are present in inventories. + * + * Shorthand for the most common deconstruction assertion pattern. + */ + fun assertTargetsDeconstructed() { + val s = scan ?: throw GameTestAssertException("No scan available — call scanStructure first") + assertAllAir(s.targetPositions) + for (expected in s.config.expectedItems) { + assertItemCount(s.allPositions, expected.item, atLeast = expected.count) + } + } + + fun check(condition: Boolean, message: () -> String) { + if (!condition) throw GameTestAssertException(message()) + } +} + +/** + * Runs an integration test with beehive network support. + * + * ```kotlin + * @GameTest(template = "cbbees:gametest/cbbees_deconstruct", timeoutTicks = 6000, setupTicks = 20) + * @JvmStatic + * fun myTest(helper: GameTestHelper) = helper.beeTest { + * val scan = scanStructure { + * target(Blocks.DIRT) + * expect(Items.DIRT, count = 27) + * } + * setupNetwork(scan) + * deconstruct() + * + * awaitSuccess { + * assertTargetsDeconstructed() + * } + * } + * ``` + */ +inline fun GameTestHelper.beeTest(block: BeeTestScope.() -> Unit) { + val level = this.level as ServerLevel + BeeTestScope(this, level).block() +} diff --git a/src/main/kotlin/de/devin/cbbees/gametest/dsl/GameTestDsl.kt b/src/main/kotlin/de/devin/cbbees/gametest/dsl/GameTestDsl.kt new file mode 100644 index 0000000..e552f04 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/gametest/dsl/GameTestDsl.kt @@ -0,0 +1,79 @@ +package de.devin.cbbees.gametest.dsl + +import net.minecraft.gametest.framework.GameTestAssertException +import net.minecraft.gametest.framework.GameTestHelper + +@DslMarker +annotation class GameTestDsl + +/** + * Scope for unit tests that don't need world interaction. + * + * Provides assertion methods and automatic succeed/fail lifecycle. + * If the block completes without throwing, [GameTestHelper.succeed] + * is called automatically. + */ +@GameTestDsl +class UnitTestScope(val helper: GameTestHelper) { + + fun fail(message: String): Nothing { + throw GameTestAssertException(message) + } + + fun assertEquals(expected: Any?, actual: Any?, label: String = "") { + if (expected != actual) { + val prefix = if (label.isNotEmpty()) "$label: " else "" + fail("${prefix}expected $expected, got $actual") + } + } + + fun assertNotEquals(unexpected: Any?, actual: Any?, label: String = "") { + if (unexpected == actual) { + val prefix = if (label.isNotEmpty()) "$label: " else "" + fail("${prefix}expected value different from $unexpected") + } + } + + fun assertTrue(condition: Boolean, message: String = "Expected true") { + if (!condition) fail(message) + } + + fun assertFalse(condition: Boolean, message: String = "Expected false") { + if (condition) fail(message) + } + + fun assertNotNull(value: Any?, label: String = "value"): Any { + if (value == null) fail("$label should not be null") + return value + } + + fun assertSize(expected: Int, collection: Collection, label: String = "collection"): Collection { + if (collection.size != expected) { + fail("$label: expected size $expected, got ${collection.size}") + } + return collection + } +} + +/** + * Runs a unit test with automatic succeed/fail handling. + * + * Usage: + * ```kotlin + * @GameTest(template = EMPTY, timeoutTicks = 20) + * @JvmStatic + * fun myTest(helper: GameTestHelper) = helper.unitTest { + * assertEquals(10L, FlightPlan.travelTicks(pos1, pos2, 1.0f), "travel ticks") + * } + * ``` + */ +inline fun GameTestHelper.unitTest(block: UnitTestScope.() -> Unit) { + try { + UnitTestScope(this).block() + succeed() + } catch (e: GameTestAssertException) { + throw e + } catch (e: Exception) { + throw GameTestAssertException("Unexpected error: ${e.message}") + } +} diff --git a/src/main/kotlin/de/devin/cbbees/gametest/flight/FlightPlanTests.kt b/src/main/kotlin/de/devin/cbbees/gametest/flight/FlightPlanTests.kt new file mode 100644 index 0000000..3ab7b0a --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/gametest/flight/FlightPlanTests.kt @@ -0,0 +1,129 @@ +package de.devin.cbbees.gametest.flight + +import de.devin.cbbees.content.bee.flight.Checkpoint +import de.devin.cbbees.content.bee.flight.FlightPlan +import de.devin.cbbees.content.bee.flight.FlyThrough +import de.devin.cbbees.content.bee.server.BeeType +import de.devin.cbbees.gametest.dsl.assertLongArrayEquals +import de.devin.cbbees.gametest.dsl.unitTest +import net.minecraft.core.BlockPos +import net.minecraft.gametest.framework.GameTest +import net.minecraft.gametest.framework.GameTestHelper +import java.util.UUID + +object FlightPlanTests { + + private const val TEMPLATE = "cbbees:gametest/empty3x3" + + // ── travelTicks ── + + @GameTest(template = TEMPLATE, timeoutTicks = 20) + @JvmStatic + fun travelTicks_samePos_returnsOne(helper: GameTestHelper) = helper.unitTest { + val pos = BlockPos(0, 0, 0) + assertEquals(1L, FlightPlan.travelTicks(pos, pos, 1.0f), "same position") + } + + @GameTest(template = TEMPLATE, timeoutTicks = 20) + @JvmStatic + fun travelTicks_straightLine_correctDistance(helper: GameTestHelper) = helper.unitTest { + assertEquals(10L, FlightPlan.travelTicks(BlockPos(0, 0, 0), BlockPos(10, 0, 0), 1.0f), "10 blocks") + } + + @GameTest(template = TEMPLATE, timeoutTicks = 20) + @JvmStatic + fun travelTicks_speedMultiplier(helper: GameTestHelper) = helper.unitTest { + assertEquals(5L, FlightPlan.travelTicks(BlockPos(0, 0, 0), BlockPos(10, 0, 0), 2.0f), "speed 2.0") + } + + @GameTest(template = TEMPLATE, timeoutTicks = 20) + @JvmStatic + fun travelTicks_diagonal_usesEuclidean(helper: GameTestHelper) = helper.unitTest { + assertEquals(5L, FlightPlan.travelTicks(BlockPos(0, 0, 0), BlockPos(3, 4, 0), 1.0f), "3-4-5 triangle") + } + + @GameTest(template = TEMPLATE, timeoutTicks = 20) + @JvmStatic + fun travelTicks_fractionalSpeed_truncates(helper: GameTestHelper) = helper.unitTest { + assertEquals(3L, FlightPlan.travelTicks(BlockPos(0, 0, 0), BlockPos(10, 0, 0), 3.0f), "truncated") + } + + @GameTest(template = TEMPLATE, timeoutTicks = 20) + @JvmStatic + fun travelTicks_veryHighSpeed_clampsToOne(helper: GameTestHelper) = helper.unitTest { + assertEquals(1L, FlightPlan.travelTicks(BlockPos(0, 0, 0), BlockPos(1, 0, 0), 100.0f), "clamped") + } + + // ── estimatedDurationTicks ── + + private fun plan(vararg checkpoints: Checkpoint) = FlightPlan( + beeId = UUID.randomUUID(), type = BeeType.CONSTRUCTION, speed = 1.0f, + checkpoints = checkpoints.toList(), + ) + + @GameTest(template = TEMPLATE, timeoutTicks = 20) + @JvmStatic + fun estimatedDuration_linearPath(helper: GameTestHelper) = helper.unitTest { + val p = plan( + Checkpoint(BlockPos(0, 0, 0), FlyThrough), + Checkpoint(BlockPos(10, 0, 0), FlyThrough), + Checkpoint(BlockPos(20, 0, 0), FlyThrough), + ) + assertEquals(20L, p.estimatedDurationTicks, "linear 20 blocks") + } + + @GameTest(template = TEMPLATE, timeoutTicks = 20) + @JvmStatic + fun estimatedDuration_includesPauseTicks(helper: GameTestHelper) = helper.unitTest { + val p = plan( + Checkpoint(BlockPos(0, 0, 0), FlyThrough, clientPauseTicks = 5), + Checkpoint(BlockPos(10, 0, 0), FlyThrough, clientPauseTicks = 3), + Checkpoint(BlockPos(20, 0, 0), FlyThrough), + ) + assertEquals(28L, p.estimatedDurationTicks, "with pauses") + } + + @GameTest(template = TEMPLATE, timeoutTicks = 20) + @JvmStatic + fun estimatedDuration_singleCheckpoint_isZero(helper: GameTestHelper) = helper.unitTest { + val p = plan(Checkpoint(BlockPos(0, 0, 0), FlyThrough)) + assertEquals(0L, p.estimatedDurationTicks, "single checkpoint") + } + + // ── computeArrivalTicks ── + + @GameTest(template = TEMPLATE, timeoutTicks = 20) + @JvmStatic + fun arrivalTicks_firstCheckpointIsZero(helper: GameTestHelper) = helper.unitTest { + val p = plan( + Checkpoint(BlockPos(0, 0, 0), FlyThrough), + Checkpoint(BlockPos(10, 0, 0), FlyThrough), + ) + assertEquals(0L, p.computeArrivalTicks()[0], "first checkpoint") + } + + @GameTest(template = TEMPLATE, timeoutTicks = 20) + @JvmStatic + fun arrivalTicks_cumulativeWithPauses(helper: GameTestHelper) = helper.unitTest { + val p = plan( + Checkpoint(BlockPos(0, 0, 0), FlyThrough, clientPauseTicks = 5), + Checkpoint(BlockPos(10, 0, 0), FlyThrough, clientPauseTicks = 3), + Checkpoint(BlockPos(20, 0, 0), FlyThrough), + ) + assertLongArrayEquals(longArrayOf(0L, 15L, 28L), p.computeArrivalTicks(), "arrival ticks") + } + + @GameTest(template = TEMPLATE, timeoutTicks = 20) + @JvmStatic + fun arrivalTicks_matchesDuration(helper: GameTestHelper) = helper.unitTest { + val p = FlightPlan( + beeId = UUID.randomUUID(), type = BeeType.CONSTRUCTION, speed = 0.5f, + checkpoints = listOf( + Checkpoint(BlockPos(0, 0, 0), FlyThrough, clientPauseTicks = 2), + Checkpoint(BlockPos(5, 0, 0), FlyThrough, clientPauseTicks = 4), + Checkpoint(BlockPos(5, 10, 0), FlyThrough), + ), + ) + assertEquals(p.estimatedDurationTicks, p.computeArrivalTicks().last(), "last arrival = duration") + } +} diff --git a/src/main/kotlin/de/devin/cbbees/gametest/inventory/BeeInventoryTests.kt b/src/main/kotlin/de/devin/cbbees/gametest/inventory/BeeInventoryTests.kt new file mode 100644 index 0000000..e10067a --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/gametest/inventory/BeeInventoryTests.kt @@ -0,0 +1,160 @@ +package de.devin.cbbees.gametest.inventory + +import de.devin.cbbees.content.bee.server.BeeType +import de.devin.cbbees.content.bee.server.ServerBeeData +import de.devin.cbbees.gametest.dsl.assertItemStack +import de.devin.cbbees.gametest.dsl.assertStackEmpty +import de.devin.cbbees.gametest.dsl.unitTest +import net.minecraft.gametest.framework.GameTest +import net.minecraft.gametest.framework.GameTestHelper +import net.minecraft.world.item.ItemStack +import net.minecraft.world.item.Items +import java.util.UUID + +object BeeInventoryTests { + + private const val TEMPLATE = "cbbees:gametest/empty3x3" + + private fun createBee(type: BeeType = BeeType.CONSTRUCTION) = ServerBeeData( + id = UUID.randomUUID(), type = type, networkId = UUID.randomUUID(), + ) + + // ── Basic add / query ── + + @GameTest(template = TEMPLATE, timeoutTicks = 20) + @JvmStatic + fun inventory_startsEmpty(helper: GameTestHelper) = helper.unitTest { + val bee = createBee() + assertTrue(bee.isInventoryEmpty(), "should be empty") + assertSize(0, bee.getInventoryContents(), "contents") + } + + @GameTest(template = TEMPLATE, timeoutTicks = 20) + @JvmStatic + fun inventory_addItem_returnsEmptyWhenFits(helper: GameTestHelper) = helper.unitTest { + val bee = createBee() + val remainder = bee.addToInventory(ItemStack(Items.COBBLESTONE, 16)) + assertStackEmpty(remainder) + assertFalse(bee.isInventoryEmpty(), "should not be empty after add") + } + + @GameTest(template = TEMPLATE, timeoutTicks = 20) + @JvmStatic + fun inventory_addItem_contentsMatchAdded(helper: GameTestHelper) = helper.unitTest { + val bee = createBee() + bee.addToInventory(ItemStack(Items.COBBLESTONE, 16)) + val contents = bee.getInventoryContents() + assertSize(1, contents, "stacks") + assertItemStack(contents[0], Items.COBBLESTONE, 16) + } + + // ── Slot capacity ── + + @GameTest(template = TEMPLATE, timeoutTicks = 20) + @JvmStatic + fun inventory_constructionBee_has4Slots(helper: GameTestHelper) = helper.unitTest { + assertEquals(4, createBee(BeeType.CONSTRUCTION).inventory.containerSize, "construction slots") + } + + @GameTest(template = TEMPLATE, timeoutTicks = 20) + @JvmStatic + fun inventory_transportBee_has3Slots(helper: GameTestHelper) = helper.unitTest { + assertEquals(3, createBee(BeeType.TRANSPORT).inventory.containerSize, "transport slots") + } + + @GameTest(template = TEMPLATE, timeoutTicks = 20) + @JvmStatic + fun inventory_fullAfterFillingAllSlots(helper: GameTestHelper) = helper.unitTest { + val bee = createBee(BeeType.CONSTRUCTION) + repeat(4) { bee.addToInventory(ItemStack(Items.COBBLESTONE, 64)) } + assertTrue(bee.isInventoryFull(), "should be full") + } + + @GameTest(template = TEMPLATE, timeoutTicks = 20) + @JvmStatic + fun inventory_overflowReturnsRemainder(helper: GameTestHelper) = helper.unitTest { + val bee = createBee(BeeType.CONSTRUCTION) + repeat(4) { bee.addToInventory(ItemStack(Items.COBBLESTONE, 64)) } + val remainder = bee.addToInventory(ItemStack(Items.COBBLESTONE, 32)) + assertEquals(32, remainder.count, "overflow count") + } + + // ── Remove ── + + @GameTest(template = TEMPLATE, timeoutTicks = 20) + @JvmStatic + fun inventory_removeAll_emptiesInventory(helper: GameTestHelper) = helper.unitTest { + val bee = createBee() + bee.addToInventory(ItemStack(Items.COBBLESTONE, 32)) + bee.removeFromInventory(ItemStack(Items.COBBLESTONE), 32) + assertTrue(bee.isInventoryEmpty(), "should be empty after remove all") + } + + @GameTest(template = TEMPLATE, timeoutTicks = 20) + @JvmStatic + fun inventory_removePartial_leavesRemainder(helper: GameTestHelper) = helper.unitTest { + val bee = createBee() + bee.addToInventory(ItemStack(Items.COBBLESTONE, 32)) + bee.removeFromInventory(ItemStack(Items.COBBLESTONE), 10) + val contents = bee.getInventoryContents() + assertSize(1, contents, "stacks") + assertEquals(22, contents[0].count, "remaining count") + } + + @GameTest(template = TEMPLATE, timeoutTicks = 20) + @JvmStatic + fun inventory_removeWrongItem_doesNothing(helper: GameTestHelper) = helper.unitTest { + val bee = createBee() + bee.addToInventory(ItemStack(Items.COBBLESTONE, 32)) + bee.removeFromInventory(ItemStack(Items.DIRT), 32) + assertEquals(32, bee.getInventoryContents()[0].count, "unchanged count") + } + + // ── Multiple item types ── + + @GameTest(template = TEMPLATE, timeoutTicks = 20) + @JvmStatic + fun inventory_multipleTypes_useSeparateSlots(helper: GameTestHelper) = helper.unitTest { + val bee = createBee(BeeType.CONSTRUCTION) + bee.addToInventory(ItemStack(Items.COBBLESTONE, 16)) + bee.addToInventory(ItemStack(Items.OAK_PLANKS, 8)) + bee.addToInventory(ItemStack(Items.IRON_INGOT, 4)) + assertSize(3, bee.getInventoryContents(), "stacks") + assertFalse(bee.isInventoryFull(), "3/4 slots should not be full") + } + + @GameTest(template = TEMPLATE, timeoutTicks = 20) + @JvmStatic + fun inventory_sameItem_stacksInSameSlot(helper: GameTestHelper) = helper.unitTest { + val bee = createBee() + bee.addToInventory(ItemStack(Items.COBBLESTONE, 16)) + bee.addToInventory(ItemStack(Items.COBBLESTONE, 16)) + val contents = bee.getInventoryContents() + assertSize(1, contents, "should stack") + assertEquals(32, contents[0].count, "stacked count") + } + + // ── Edge cases ── + + @GameTest(template = TEMPLATE, timeoutTicks = 20) + @JvmStatic + fun inventory_partialStack_notFull(helper: GameTestHelper) = helper.unitTest { + val bee = createBee(BeeType.CONSTRUCTION) + bee.addToInventory(ItemStack(Items.COBBLESTONE, 32)) + bee.addToInventory(ItemStack(Items.DIRT, 32)) + bee.addToInventory(ItemStack(Items.OAK_PLANKS, 32)) + bee.addToInventory(ItemStack(Items.IRON_INGOT, 32)) + assertFalse(bee.isInventoryFull(), "partial stacks should not be full") + } + + @GameTest(template = TEMPLATE, timeoutTicks = 20) + @JvmStatic + fun inventory_removeAcrossSlots(helper: GameTestHelper) = helper.unitTest { + val bee = createBee() + bee.addToInventory(ItemStack(Items.COBBLESTONE, 64)) + bee.addToInventory(ItemStack(Items.COBBLESTONE, 64)) + bee.removeFromInventory(ItemStack(Items.COBBLESTONE), 96) + val total = bee.getInventoryContents().sumOf { it.count } + assertEquals(32, total, "128 - 96 = 32") + } +} diff --git a/src/main/kotlin/de/devin/cbbees/gametest/logistics/LogisticsPortTests.kt b/src/main/kotlin/de/devin/cbbees/gametest/logistics/LogisticsPortTests.kt new file mode 100644 index 0000000..0337a70 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/gametest/logistics/LogisticsPortTests.kt @@ -0,0 +1,51 @@ +package de.devin.cbbees.gametest.logistics + +import de.devin.cbbees.gametest.dsl.beeTest +import net.minecraft.gametest.framework.GameTest +import net.minecraft.gametest.framework.GameTestHelper +import net.minecraft.world.item.Items +import net.minecraft.world.level.block.Blocks + +object LogisticsPortTests { + + @GameTest( + template = "cbbees:gametest/logistic_ports/cbbees_priorities", + timeoutTicks = 6000, + setupTicks = 20, + ) + @JvmStatic + fun priority_higherPriorityPort_receivesAllItems(helper: GameTestHelper) = helper.beeTest { + val scan = scanStructure { + target(Blocks.DIRT) + expect(Items.DIRT, count = 27) + } + setupNetwork(scan) + deconstruct() + + val highVault = portsByPriority().first().vaultPos + + awaitSuccess { + assertTargetsDeconstructed() + assertItemCountAt(highVault, Items.DIRT, atLeast = 27) + } + } + + @GameTest( + template = "cbbees:gametest/logistic_ports/cbbees_filtering", + timeoutTicks = 6000, + setupTicks = 20, + ) + @JvmStatic + fun filter_itemGoesToMatchingPort_despiteLowerPriority(helper: GameTestHelper) = helper.beeTest { + val scan = scanStructure { + target(Blocks.DIRT) + expect(Items.DIRT, count = 27) + } + setupNetwork(scan) + deconstruct() + + awaitSuccess { + assertTargetsDeconstructed() + } + } +} diff --git a/src/main/kotlin/de/devin/cbbees/gametest/portable/PortableBeehiveTests.kt b/src/main/kotlin/de/devin/cbbees/gametest/portable/PortableBeehiveTests.kt new file mode 100644 index 0000000..d3d2e9d --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/gametest/portable/PortableBeehiveTests.kt @@ -0,0 +1,129 @@ +package de.devin.cbbees.gametest.portable + +import de.devin.cbbees.gametest.GameTestSetup +import de.devin.cbbees.gametest.dsl.beeTest +import net.minecraft.gametest.framework.GameTest +import net.minecraft.gametest.framework.GameTestHelper +import net.minecraft.world.item.Items +import net.minecraft.world.level.block.Blocks + +/** + * Integration tests for the portable beehive (player backpack). + * + * Test 1 - bees_dont_use_player: Bees spawned from a STATIONARY beehive must use + * the logistics port in the beehive's network, not the player's inventory. + * + * Test 2 - portable_ignore_port: Bees spawned from the player's PORTABLE beehive + * must NOT use an isolated logistics port that's not in the player's network. + * + * Test 3 - use_logistics_first: Bees from a portable beehive that's joined a + * stationary network should use the logistics port first (higher priority than + * the player's inventory fallback). + */ +object PortableBeehiveTests { + + @GameTest( + template = "cbbees:gametest/portable/cbbees_bees_dont_use_player", + timeoutTicks = 6000, + setupTicks = 20, + batch = "portable", + ) + @JvmStatic + fun bees_dont_use_player(helper: GameTestHelper) = helper.beeTest { + val scan = scanStructure { + target(Blocks.DIRT) + expect(Items.DIRT, count = 12) + } + + // Set up the stationary beehive network (beehive + logistics port) + setupNetwork(scan) + deconstruct() + + // Add a player with portable beehive nearby (should NOT receive items) + val portableHive = setupPortableBeehive( + pos = helper.absolutePos(net.minecraft.core.BlockPos(3, 1, 2)), + beeCount = 0, + joinExistingNetwork = false, + ) + + awaitSuccess { + assertTargetsDeconstructed() + // Items should be in the vault, NOT in the player's inventory + val playerDirt = portableHive.player.inventory.items.sumOf { + if (it.`is`(Items.DIRT)) it.count else 0 + } + check(playerDirt == 0) { + "Player should have 0 dirt (stationary bees should use the logistics port), but has $playerDirt" + } + } + } + + @GameTest( + template = "cbbees:gametest/portable/cbbees_portable_ignore_port", + timeoutTicks = 6000, + setupTicks = 20, + batch = "portable", + ) + @JvmStatic + fun portable_ignore_isolated_port(helper: GameTestHelper) = helper.beeTest { + val scan = scanStructure { + target(Blocks.DIRT) + expect(Items.DIRT, count = 12) + } + + // No stationary beehive in this structure — only the player's portable beehive + // The logistics port in the structure is ISOLATED (not in the player's network) + val portableHive = setupPortableBeehive( + pos = helper.absolutePos(net.minecraft.core.BlockPos(3, 1, 2)), + beeCount = 5, + ) + deconstruct() + + awaitSuccess { + assertAllAir(scan.targetPositions) + // Items should be in the PLAYER's inventory, not the isolated port's vault + val playerDirt = portableHive.player.inventory.items.sumOf { + if (it.`is`(Items.DIRT)) it.count else 0 + } + check(playerDirt >= 12) { + "Expected 12 dirt in player inventory, found $playerDirt" + } + } + } + + @GameTest( + template = "cbbees:gametest/portable/cbbees_use_logistics_first", + timeoutTicks = 6000, + setupTicks = 20, + batch = "portable", + ) + @JvmStatic + fun portable_useLogisticsFirst(helper: GameTestHelper) = helper.beeTest { + val scan = scanStructure { + target(Blocks.DIRT) + expect(Items.DIRT, count = 12) + } + + // Set up stationary beehive network + setupNetwork(scan, beeCount = 0) + + // Join the player's portable beehive to the SAME network + val portableHive = setupPortableBeehive( + pos = helper.absolutePos(net.minecraft.core.BlockPos(3, 1, 2)), + beeCount = 5, + joinExistingNetwork = true, + ) + deconstruct() + + awaitSuccess { + assertTargetsDeconstructed() + // Items should be in the vault (via logistics port), NOT the player + val playerDirt = portableHive.player.inventory.items.sumOf { + if (it.`is`(Items.DIRT)) it.count else 0 + } + check(playerDirt == 0) { + "Player should have 0 dirt (logistics port has higher priority), but has $playerDirt" + } + } + } +} diff --git a/src/main/kotlin/de/devin/cbbees/gametest/transport/TransportTests.kt b/src/main/kotlin/de/devin/cbbees/gametest/transport/TransportTests.kt new file mode 100644 index 0000000..362782d --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/gametest/transport/TransportTests.kt @@ -0,0 +1,98 @@ +package de.devin.cbbees.gametest.transport + +import de.devin.cbbees.gametest.dsl.beeTest +import de.devin.cbbees.items.AllItems +import net.minecraft.gametest.framework.GameTest +import net.minecraft.gametest.framework.GameTestHelper +import net.minecraft.world.item.ItemStack +import net.minecraft.world.item.Items + +/** + * Integration tests for transport (bumble bee item logistics). + * + * Structure layout (7x3x5): + * - Provider vault at (3,1,3) with provider cargo port at (3,2,3) + * - Requester vault at (1,1,3) with requester cargo port at (1,2,3) + * - Beehive at (5,1,3) powered by creative motor + * + * Priority/filtering structures add a second requester vault at (1,1,1) + * with a second requester cargo port at (1,2,1). + * + * Transport is handled by TransportDispatcher via the server tick loop. + * The test inserts items into the provider vault, then verifies bumble bees + * move them to the requester vault(s). + */ +object TransportTests { + + @GameTest( + template = "cbbees:gametest/transport/cbbees_transport", + timeoutTicks = 6000, + setupTicks = 20, + ) + @JvmStatic + fun transport_movesItemsBetweenPorts(helper: GameTestHelper) = helper.beeTest { + val scan = scanStructure {} + setupNetwork(scan, beeCount = 0) + + // Stock bumble bees instead of construction bees + val bumbleItem = ItemStack(AllItems.MECHANICAL_BUMBLE_BEE.get()) + repeat(3) { scan.beehive!!.addBee(bumbleItem.copy()) } + + // Insert dirt into provider vault + val providerVault = providerPorts().first().vaultPos + insertItems(providerVault, Items.DIRT, 16) + + // Wait for bumble bees to transport items to requester + val requesterVault = requesterPorts().first().vaultPos + + awaitSuccess { + assertItemCount(scan.allPositions, Items.DIRT, atLeast = 16) + assertItemCountAt(requesterVault, Items.DIRT, atLeast = 16) + } + } + + @GameTest( + template = "cbbees:gametest/transport/cbbees_transport_priorities", + timeoutTicks = 6000, + setupTicks = 20, + ) + @JvmStatic + fun transport_priority_higherPriorityRequesterReceivesItems(helper: GameTestHelper) = helper.beeTest { + val scan = scanStructure {} + setupNetwork(scan, beeCount = 0) + + val bumbleItem = ItemStack(AllItems.MECHANICAL_BUMBLE_BEE.get()) + repeat(3) { scan.beehive!!.addBee(bumbleItem.copy()) } + + val providerVault = providerPorts().first().vaultPos + insertItems(providerVault, Items.DIRT, 16) + + // Highest-priority requester should get all items + val highPriorityVault = requesterPorts().first().vaultPos + + awaitSuccess { + assertItemCountAt(highPriorityVault, Items.DIRT, atLeast = 16) + } + } + + @GameTest( + template = "cbbees:gametest/transport/cbbees_transport_filtering", + timeoutTicks = 6000, + setupTicks = 20, + ) + @JvmStatic + fun transport_filter_itemGoesToMatchingRequester(helper: GameTestHelper) = helper.beeTest { + val scan = scanStructure {} + setupNetwork(scan, beeCount = 0) + + val bumbleItem = ItemStack(AllItems.MECHANICAL_BUMBLE_BEE.get()) + repeat(3) { scan.beehive!!.addBee(bumbleItem.copy()) } + + val providerVault = providerPorts().first().vaultPos + insertItems(providerVault, Items.DIRT, 16) + + awaitSuccess { + assertItemCount(scan.allPositions, Items.DIRT, atLeast = 16) + } + } +} diff --git a/src/main/kotlin/de/devin/cbbees/items/AllItems.kt b/src/main/kotlin/de/devin/cbbees/items/AllItems.kt index f5063c7..8ad7529 100644 --- a/src/main/kotlin/de/devin/cbbees/items/AllItems.kt +++ b/src/main/kotlin/de/devin/cbbees/items/AllItems.kt @@ -94,10 +94,22 @@ object AllItems { val PICKUP_PLANNER: ItemEntry = CreateBuzzyBeez.REGISTRATE .item("pickup_planner") { props -> PickupPlannerItem(props) } .model { _, _ -> } + .recipe { c, p -> + ShapedRecipeBuilder.shaped(RecipeCategory.MISC, c.get()) + .define('B', AllItems.BRASS_INGOT.get()) + .define('E', AllItems.ELECTRON_TUBE.get()) + .define('P', Items.PAPER) + .define('H', Items.HOPPER) + .pattern(" E ") + .pattern("BPB") + .pattern(" H ") + .unlockedBy("has_electron_tube", RegistrateRecipeProvider.has(AllItems.ELECTRON_TUBE.get())) + .save(p, CreateBuzzyBeez.asResource("crafting/" + c.name)) + } .properties { it.stacksTo(1).rarity(Rarity.UNCOMMON) } .register() - // Stinger Planner - select areas for removal (alternative to schematic-based deconstruction) + // Deconstruction Planner - select areas for removal val DECONSTRUCTION_PLANNER: ItemEntry = CreateBuzzyBeez.REGISTRATE .item("deconstruction_planner") { props -> DeconstructionPlannerItem(props) @@ -265,26 +277,6 @@ object AllItems { .properties { it.stacksTo(1).rarity(Rarity.UNCOMMON) } .register() - // Reinforced Plating - increases spring efficiency - val REINFORCED_PLATING: ItemEntry = CreateBuzzyBeez.REGISTRATE - .item("reinforced_plating") { props -> - ReinforcedPlatingUpgrade(props) - } - .model { _, _ -> } - .recipe { c, p -> - ShapedRecipeBuilder.shaped(RecipeCategory.MISC, c.get()) - .define('W', UPGRADE_TEMPLATE.get()) - .define('V', AllItems.STURDY_SHEET) - .define('B', AllItems.BRASS_INGOT.get()) - .pattern(" V ") - .pattern("BWB") - .pattern(" V ") - .unlockedBy("has_upgrade_base", RegistrateRecipeProvider.has(UPGRADE_TEMPLATE.get())) - .save(p, CreateBuzzyBeez.asResource("crafting/" + c.name)) - } - .properties { it.stacksTo(1).rarity(Rarity.UNCOMMON) } - .register() - // Drone View - enables top-down drone camera val DRONE_VIEW: ItemEntry = CreateBuzzyBeez.REGISTRATE .item("drone_view") { props -> diff --git a/src/main/kotlin/de/devin/cbbees/network/CCRServerEvents.kt b/src/main/kotlin/de/devin/cbbees/network/CCRServerEvents.kt index 8e39e6e..5bbf90e 100644 --- a/src/main/kotlin/de/devin/cbbees/network/CCRServerEvents.kt +++ b/src/main/kotlin/de/devin/cbbees/network/CCRServerEvents.kt @@ -35,9 +35,13 @@ object CCRServerEvents { val profiler = overworld.profiler + profiler.push("cbbees") + // Drain deferred callbacks (e.g. async task generation chunks) — exactly one // batch per tick. Anything scheduled inside a callback runs next tick. + profiler.push("tickScheduler") ServerTickScheduler.runScheduled() + profiler.pop() // ── Tick non-entity bees EVERY tick ── profiler.push("beeManager") @@ -45,32 +49,52 @@ object CCRServerEvents { ServerBeeManager.tickAll(overworld, gameTime) profiler.pop() + profiler.push("jobEvictions") JobCalculationProgress.tickEvictions(server.tickCount) + profiler.pop() // No per-tick sync needed — bees use checkpoint-based flight plans // ── Core logic every 10 ticks (0.5 seconds) ── tickCounter++ - if (tickCounter < 10) return - tickCounter = 0 - - ServerBeeNetworkManager.getNetworks().forEach { it.purgeStaleComponents(gameTime) } - ServerBeeNetworkManager.rebuildIndexes() - - GlobalJobPool.tick(gameTime) - TransportDispatcher.tick(gameTime) - ServerBeeNetworkManager.getNetworks().forEach { it.cleanupReservations(gameTime) } - DroneViewManager.validateDrones() - - // Sync packets every 40 ticks (2 seconds) - syncCounter++ - if (syncCounter >= 4) { - syncCounter = 0 - server.playerList.players.forEach { player -> - HiveJobsSyncPacket.sendPlayerSnapshotTo(player) - NetworkSyncPacket.sendTo(player) + if (tickCounter >= 10) { + tickCounter = 0 + + profiler.push("networkPurge") + ServerBeeNetworkManager.getNetworks().forEach { it.purgeStaleComponents(gameTime) } + ServerBeeNetworkManager.rebuildIndexes() + profiler.pop() + + profiler.push("jobPool") + GlobalJobPool.tick(gameTime) + profiler.pop() + + profiler.push("transportDispatcher") + TransportDispatcher.tick(gameTime) + profiler.pop() + + profiler.push("reservationCleanup") + ServerBeeNetworkManager.getNetworks().forEach { it.cleanupReservations(gameTime) } + profiler.pop() + + profiler.push("droneValidation") + DroneViewManager.validateDrones() + profiler.pop() + + // Sync packets every 40 ticks (2 seconds) + syncCounter++ + if (syncCounter >= 4) { + syncCounter = 0 + profiler.push("sync") + server.playerList.players.forEach { player -> + HiveJobsSyncPacket.sendPlayerSnapshotTo(player) + NetworkSyncPacket.sendTo(player) + } + profiler.pop() } } + + profiler.pop() // cbbees } /** @@ -99,11 +123,12 @@ object CCRServerEvents { @SubscribeEvent @JvmStatic fun onServerStopping(event: ServerStoppingEvent) { + // Return bees to hives FIRST, before clearing networks/jobs + ServerBeeManager.clear() ServerBeeNetworkManager.getNetworks().forEach { it.clearReservations() } ServerBeeNetworkManager.clear() GlobalJobPool.clear() TransportDispatcher.clear() - ServerBeeManager.clear() BeeDebug.clear() PlannerUploadPacket.shutdown() DroneViewManager.clear() diff --git a/src/main/kotlin/de/devin/cbbees/network/GridRemoveUpgradePacket.kt b/src/main/kotlin/de/devin/cbbees/network/GridRemoveUpgradePacket.kt index be2814f..b66ea92 100644 --- a/src/main/kotlin/de/devin/cbbees/network/GridRemoveUpgradePacket.kt +++ b/src/main/kotlin/de/devin/cbbees/network/GridRemoveUpgradePacket.kt @@ -56,7 +56,6 @@ class GridRemoveUpgradePacket( de.devin.cbbees.content.upgrades.UpgradeType.SOFT_TOUCH -> ItemStack(AllItems.SOFT_TOUCH.get()) de.devin.cbbees.content.upgrades.UpgradeType.DROP_ITEMS -> ItemStack(AllItems.DROP_ITEMS.get()) de.devin.cbbees.content.upgrades.UpgradeType.HONEY_TANK -> ItemStack(AllItems.HONEY_TANK.get()) - de.devin.cbbees.content.upgrades.UpgradeType.REINFORCED_PLATING -> ItemStack(AllItems.REINFORCED_PLATING.get()) de.devin.cbbees.content.upgrades.UpgradeType.DRONE_VIEW -> ItemStack(AllItems.DRONE_VIEW.get()) de.devin.cbbees.content.upgrades.UpgradeType.DRONE_RANGE -> ItemStack(AllItems.DRONE_RANGE.get()) } diff --git a/src/main/kotlin/de/devin/cbbees/network/HiveJobsSyncPacket.kt b/src/main/kotlin/de/devin/cbbees/network/HiveJobsSyncPacket.kt index 69b68f4..f2003c1 100644 --- a/src/main/kotlin/de/devin/cbbees/network/HiveJobsSyncPacket.kt +++ b/src/main/kotlin/de/devin/cbbees/network/HiveJobsSyncPacket.kt @@ -290,18 +290,29 @@ class HiveJobsSyncPacket( val clientJobs = jobs.map { job -> val completed = job.tasks.count { it.status == TaskStatus.COMPLETED } val batches = job.batches.map { b -> + val required = b.tasks.map { it.action } + .filterIsInstance() + .flatMap { it.requiredItems } ClientBatchInfo( - b.status.name, b.targetPosition, emptyList(), emptyList(), + b.status.name, b.targetPosition, required, emptyList(), ghostBlocks = emptyMap() ) } + // Live stall check: find the network for this job and resolve reason + val networkId = job.batches.firstOrNull()?.assignedNetworkId + val network = if (networkId != null) ServerBeeNetworkManager.getNetwork(networkId) else null + val reason = if (network != null) { + StuckReasonResolver.firstReasonOrNull(network, job) + } else { + null + } ClientJobInfo( job.jobId, job.jobId.toString().substring(0, 6).uppercase(), job.status.name, completed, job.tasks.size, - null, + reason, batches, schematicPlacement = job.schematicPlacement ) diff --git a/src/main/kotlin/de/devin/cbbees/ponder/CBBPonderPlugin.kt b/src/main/kotlin/de/devin/cbbees/ponder/CBBPonderPlugin.kt index be37b89..08266b8 100644 --- a/src/main/kotlin/de/devin/cbbees/ponder/CBBPonderPlugin.kt +++ b/src/main/kotlin/de/devin/cbbees/ponder/CBBPonderPlugin.kt @@ -38,7 +38,8 @@ class CBBPonderPlugin : PonderPlugin { helper.forComponents(AllBlocks.SCHEMATIC_DEPLOYER.id) .addStoryBoard("schematic_deployer/intro", { scene, util -> DeployerScenes.schematicDeployerIntro(scene, util) }) - .addStoryBoard("schematic_deployer/automation", { scene, util -> DeployerScenes.selfPopulatingBases(scene, util) }) + .addStoryBoard("schematic_deployer/automation", { scene, util -> DeployerScenes.deployModes(scene, util) }) + .addStoryBoard("schematic_deployer/intro", { scene, util -> DeployerScenes.selfReplicating(scene, util) }) helper.forComponents(AllItems.PROGRAMMED_SCHEMATIC.id) .addStoryBoard("schematic_deployer/intro", { scene, util -> DeployerScenes.schematicDeployerIntro(scene, util) }) diff --git a/src/main/kotlin/de/devin/cbbees/ponder/DeployerScenes.kt b/src/main/kotlin/de/devin/cbbees/ponder/DeployerScenes.kt index c1dfd80..946124f 100644 --- a/src/main/kotlin/de/devin/cbbees/ponder/DeployerScenes.kt +++ b/src/main/kotlin/de/devin/cbbees/ponder/DeployerScenes.kt @@ -15,14 +15,7 @@ import net.minecraft.world.phys.AABB object DeployerScenes { /** - * Ponder scene: Schematic Deployer introduction. - * - * NBT structure required at `ponder/schematic_deployer/intro.nbt`: - * - 5x5x5 base plate - * - (2,1,2): Schematic Deployer (facing north) - * - (3,1,2): Lever (attached to side of deployer) - * - (4,1,2): Mechanical Beehive - * - (4,1,3): Shaft (providing rotation) + * Chapter 1: What is the Schematic Deployer? */ fun schematicDeployerIntro(builder: SceneBuilder, util: SceneBuildingUtil) { val scene = CreateSceneBuilder(builder) @@ -37,56 +30,75 @@ object DeployerScenes { val hivePos = util.grid().at(4, 1, 2) val shaftPos = util.grid().at(4, 1, 3) - // Show the deployer + // Introduce the block scene.world().showSection(util.select().position(deployerPos), Direction.DOWN) scene.idle(10) scene.overlay().showText(80) - .text("The Schematic Deployer automates construction and deconstruction jobs using programmed schematics.") + .text("The Schematic Deployer executes programmed jobs automatically using a redstone signal.") .placeNearTarget() .pointAt(util.vector().blockSurface(deployerPos, Direction.WEST)) scene.idle(100) - // Show inserting a programmed schematic - val programmedSchematic = ItemStack(AllItems.PROGRAMMED_SCHEMATIC.get()) + // How to obtain programmed schematics + scene.overlay().showText(80) + .text("It requires a Programmed Schematic to operate. These can be created using any Planner tool.") + .placeNearTarget() + .attachKeyFrame() + .pointAt(util.vector().blockSurface(deployerPos, Direction.UP)) + + scene.idle(50) + + // Show the programmed schematic item scene.overlay().showControls(util.vector().blockSurface(deployerPos, Direction.UP), Pointing.DOWN, 60) - .withItem(programmedSchematic) + .withItem(ItemStack(AllItems.PROGRAMMED_SCHEMATIC.get())) + + scene.idle(80) + + scene.overlay().showText(80) + .text("Use the Program key while holding a Planner with a selection to create a Programmed Schematic.") + .placeNearTarget() + .pointAt(util.vector().blockSurface(deployerPos, Direction.UP)) + + scene.idle(100) + + // Insert the schematic + scene.overlay().showControls(util.vector().blockSurface(deployerPos, Direction.UP), Pointing.DOWN, 60) + .withItem(ItemStack(AllItems.PROGRAMMED_SCHEMATIC.get())) .rightClick() scene.overlay().showText(80) - .text("Right-click with a Programmed Schematic to insert it. Create one using the Program tool in the Construction or Deconstruction Planner.") + .text("Right-click the Deployer with a Programmed Schematic to load it.") .placeNearTarget() .attachKeyFrame() .pointAt(util.vector().blockSurface(deployerPos, Direction.WEST)) scene.idle(100) - // Show the lever + // Redstone activation scene.world().showSection(util.select().position(leverPos), Direction.DOWN) scene.idle(10) scene.overlay().showText(80) - .text("Apply a redstone signal to deploy the programmed job to nearby beehives.") + .text("Apply a redstone signal to deploy the job. The Deployer activates on a rising edge.") .placeNearTarget() .attachKeyFrame() .pointAt(util.vector().blockSurface(leverPos, Direction.WEST)) scene.idle(60) - // Activate — show powered state scene.world().modifyBlock(deployerPos, { state -> state.setValue(SchematicDeployerBlock.POWERED, true) as BlockState }, false) scene.idle(20) - // Show beehive + shaft + // Beehive picks up the job scene.world().showSection(util.select().position(hivePos), Direction.DOWN) scene.world().showSection(util.select().position(shaftPos), Direction.DOWN) scene.world().setKineticSpeed(util.select().position(hivePos), 64f) scene.world().setKineticSpeed(util.select().position(shaftPos), 64f) - scene.idle(10) scene.overlay().showText(80) @@ -99,18 +111,17 @@ object DeployerScenes { // Comparator output scene.overlay().showText(80) - .text("A comparator reads the deployer's state: empty (0), loaded (1), job active (8), or just deployed (15).") + .text("A comparator can read the Deployer's state: empty (0), loaded (1), active (8), or just deployed (15).") .placeNearTarget() .attachKeyFrame() .pointAt(util.vector().blockSurface(deployerPos, Direction.SOUTH)) scene.idle(100) - // Automation + // Extraction scene.overlay().showText(80) .text("Shift+right-click to extract the schematic. Hoppers and pipes can also insert and extract for full automation.") .placeNearTarget() - .attachKeyFrame() .pointAt(util.vector().blockSurface(deployerPos, Direction.UP)) scene.idle(100) @@ -119,14 +130,9 @@ object DeployerScenes { } /** - * Ponder scene: Deploy modes — Absolute vs Relative. - * - * NBT structure required at `ponder/schematic_deployer/automation.nbt`: - * - 5x3x5 base plate - * - (1,1,2): Schematic Deployer (facing north) — "absolute" example - * - (3,1,2): Schematic Deployer (facing north) — "relative" example + * Chapter 2: Deploy Modes — Absolute vs Relative. */ - fun selfPopulatingBases(builder: SceneBuilder, util: SceneBuildingUtil) { + fun deployModes(builder: SceneBuilder, util: SceneBuildingUtil) { val scene = CreateSceneBuilder(builder) scene.title("schematic_deployer_automation", "Deploy Modes") @@ -137,13 +143,12 @@ object DeployerScenes { val absolutePos = util.grid().at(1, 1, 2) val relativePos = util.grid().at(3, 1, 2) - // Show both deployers scene.world().showSection(util.select().position(absolutePos), Direction.DOWN) scene.world().showSection(util.select().position(relativePos), Direction.DOWN) scene.idle(10) scene.overlay().showText(80) - .text("The Schematic Deployer supports two deploy modes: Absolute and Relative. Right-click to open the settings GUI.") + .text("The Schematic Deployer has two deploy modes. Open the GUI by right-clicking without an item.") .placeNearTarget() .pointAt(util.vector().blockSurface(absolutePos, Direction.UP)) @@ -151,7 +156,7 @@ object DeployerScenes { // Absolute mode scene.overlay().showOutlineWithText(util.select().position(absolutePos), 100) - .text("In Absolute mode, the schematic is built at the exact coordinates stored when it was programmed.") + .text("In Absolute mode, the schematic is always built at the exact coordinates stored when it was programmed.") .placeNearTarget() .attachKeyFrame() .colored(PonderPalette.BLUE) @@ -163,7 +168,7 @@ object DeployerScenes { scene.overlay().chaseBoundingBoxOutline(PonderPalette.BLUE, "abs", absoluteTarget, 100) scene.overlay().showText(80) - .text("The build always happens at the same world position, no matter where the deployer is placed.") + .text("No matter where the Deployer is placed, the build happens at the same world position.") .placeNearTarget() .pointAt(util.vector().blockSurface(absolutePos, Direction.WEST)) @@ -171,7 +176,7 @@ object DeployerScenes { // Relative mode scene.overlay().showOutlineWithText(util.select().position(relativePos), 100) - .text("In Relative mode, the build target is calculated as an offset from the deployer's position.") + .text("In Relative mode, the build target is calculated as an offset from the Deployer's position.") .placeNearTarget() .attachKeyFrame() .colored(PonderPalette.GREEN) @@ -183,15 +188,14 @@ object DeployerScenes { scene.overlay().chaseBoundingBoxOutline(PonderPalette.GREEN, "rel", relativeTarget, 100) scene.overlay().showText(80) - .text("Move the deployer and the build follows — great for repeatable, portable setups.") + .text("Move the Deployer and the build follows. Great for repeatable, portable setups.") .placeNearTarget() .pointAt(util.vector().blockSurface(relativePos, Direction.EAST)) scene.idle(100) - // Rotation/Mirror scene.overlay().showText(80) - .text("Relative mode also lets you override rotation and mirror settings from the GUI, without reprogramming the schematic.") + .text("Relative mode also lets you override rotation and mirror from the GUI, without reprogramming.") .placeNearTarget() .attachKeyFrame() .pointAt(util.vector().blockSurface(relativePos, Direction.UP)) @@ -200,4 +204,66 @@ object DeployerScenes { scene.markAsFinished() } + + /** + * Chapter 3: Self-Replicating Schematics. + * + * Reuses the intro NBT structure since no dedicated structure exists yet. + */ + fun selfReplicating(builder: SceneBuilder, util: SceneBuildingUtil) { + val scene = CreateSceneBuilder(builder) + + scene.title("schematic_deployer_self_replicating", "Self-Replicating Schematics") + scene.configureBasePlate(0, 0, 5) + scene.showBasePlate() + scene.idle(5) + + val deployerPos = util.grid().at(2, 1, 2) + + scene.world().showSection(util.select().position(deployerPos), Direction.DOWN) + scene.idle(10) + + scene.overlay().showControls(util.vector().blockSurface(deployerPos, Direction.UP), Pointing.DOWN, 60) + .withItem(ItemStack(AllItems.PROGRAMMED_SCHEMATIC.get())) + + scene.overlay().showText(80) + .text("When a schematic contains a Schematic Deployer block, something special happens.") + .placeNearTarget() + .pointAt(util.vector().blockSurface(deployerPos, Direction.WEST)) + + scene.idle(100) + + scene.overlay().showText(100) + .text("The parent Deployer's held item and settings are automatically injected into the newly built child Deployer.") + .placeNearTarget() + .attachKeyFrame() + .pointAt(util.vector().blockSurface(deployerPos, Direction.UP)) + + scene.idle(120) + + scene.overlay().showText(100) + .text("The child Deployer will already contain the same Programmed Schematic and deploy mode — ready to trigger.") + .placeNearTarget() + .attachKeyFrame() + .pointAt(util.vector().blockSurface(deployerPos, Direction.EAST)) + + scene.idle(120) + + scene.overlay().showText(100) + .text("This creates a chain reaction: each Deployer builds the next, carrying the same program forward. Perfect for expanding bases automatically.") + .placeNearTarget() + .attachKeyFrame() + .pointAt(util.vector().blockSurface(deployerPos, Direction.WEST)) + + scene.idle(120) + + scene.overlay().showText(80) + .text("Use Relative mode so each child builds at its own position rather than the original coordinates.") + .placeNearTarget() + .pointAt(util.vector().blockSurface(deployerPos, Direction.UP)) + + scene.idle(100) + + scene.markAsFinished() + } } diff --git a/src/main/kotlin/de/devin/cbbees/registry/AllKeys.kt b/src/main/kotlin/de/devin/cbbees/registry/AllKeys.kt index 254d582..bb9baea 100644 --- a/src/main/kotlin/de/devin/cbbees/registry/AllKeys.kt +++ b/src/main/kotlin/de/devin/cbbees/registry/AllKeys.kt @@ -89,6 +89,17 @@ object AllKeys { "key.categories.${CreateBuzzyBeez.ID}" ) + /** + * Modifier key for free-aim selection and AABB resizing in area planners. + * Default key: LEFT CTRL + */ + val FREE_AIM: KeyMapping = KeyMapping( + "key.${CreateBuzzyBeez.ID}.free_aim", + InputConstants.Type.KEYSYM, + GLFW.GLFW_KEY_LEFT_CONTROL, + "key.categories.${CreateBuzzyBeez.ID}" + ) + /** * Keybinding to toggle drone view. * Default key: V @@ -113,6 +124,7 @@ object AllKeys { event.register(DRONE_VIEW) event.register(PROGRAM_ACTION) event.register(SCHEMATIC_MODIFIER) + event.register(FREE_AIM) } } diff --git a/src/main/resources/assets/cbbees/geo/CustomArmor.bbmodel b/src/main/resources/assets/cbbees/geo/CustomArmor.bbmodel new file mode 100644 index 0000000..bb2d53b --- /dev/null +++ b/src/main/resources/assets/cbbees/geo/CustomArmor.bbmodel @@ -0,0 +1 @@ +{"meta":{"format_version":"5.0","model_format":"geckolib_model","box_uv":true},"name":"CustomArmor","model_identifier":"","visible_box":[4,3.5,1.25],"variable_placeholders":"","multi_file_ruleset":"","variable_placeholder_buttons":[],"timeline_setups":[],"unhandled_root_fields":{},"geckolib_modid":"","geckolib_filepath_cache":{"model":"/Users/devin/IdeaProjects/Create-Fusion/src/main/resources/assets/cbbees/geo/portable_beehive_wingsgeo.json","animation":"/Users/devin/Downloads/mechanical_wings.json"},"resolution":{"width":64,"height":64},"elements":[{"name":"backpack","box_uv":false,"render_order":"default","locked":false,"export":true,"scope":0,"allow_mirror_modeling":true,"from":[0.6,20.6,8.3],"to":[2.6,22.6,8.4],"autouv":0,"color":0,"origin":[0,13,5],"faces":{"north":{"uv":[24,29,26,31],"texture":0},"east":{"uv":[26,29,28,31],"texture":0},"south":{"uv":[28,29,34,35],"texture":0},"west":{"uv":[26,29,28,31],"texture":0},"up":{"uv":[28,29,26,31],"texture":0},"down":{"uv":[28,29,26,31],"texture":0}},"type":"cube","uuid":"f7d99853-dceb-61dd-1c48-4b4b15ae94c5"},{"name":"backpack","box_uv":false,"render_order":"default","locked":false,"export":true,"scope":0,"allow_mirror_modeling":true,"from":[2,20.7,8.2],"to":[2.5,21.2,8.7],"autouv":0,"color":0,"origin":[0,13,5],"faces":{"north":{"uv":[24,33,26,35],"texture":0},"east":{"uv":[24,33,26,35],"texture":0},"south":{"uv":[24,33,26,35],"texture":0},"west":{"uv":[24,33,26,35],"texture":0},"up":{"uv":[26,33,24,35],"texture":0},"down":{"uv":[26,33,24,35],"texture":0}},"type":"cube","uuid":"6257b5e9-b7ee-5a0a-1d6b-6b5fe4fd5c84"},{"name":"backpack","box_uv":false,"render_order":"default","locked":false,"export":true,"scope":0,"allow_mirror_modeling":true,"from":[0.4,20.4,8],"to":[2.8,22.8,8.3],"autouv":0,"color":0,"origin":[0,13,5],"faces":{"north":{"uv":[20,32,22,34],"texture":0},"east":{"uv":[20,32,24,34],"texture":0},"south":{"uv":[20,32,22,34],"texture":0},"west":{"uv":[20,32,22,34],"texture":0},"up":{"uv":[22,32,20,33],"texture":0},"down":{"uv":[23,32,21,34],"texture":0}},"type":"cube","uuid":"25c2abac-2fee-30ed-3d08-fad1cd563a0a"},{"name":"dial","box_uv":false,"render_order":"default","locked":false,"export":true,"scope":0,"allow_mirror_modeling":true,"from":[2.11863,20.93492,8.4],"to":[2.41863,22.33492,8.8],"autouv":0,"color":1,"rotation":[0,0,90],"origin":[2.25,20.95,8.45],"faces":{"north":{"uv":[24,31,26,33],"texture":0},"east":{"uv":[24,31,26,33],"texture":0},"south":{"uv":[24,31,26,33],"texture":0},"west":{"uv":[24,31,26,33],"texture":0},"up":{"uv":[26,31,24,33],"texture":0},"down":{"uv":[26,31,24,33],"texture":0}},"type":"cube","uuid":"fc492963-6369-77db-5a96-ec475cd9f2dc"},{"name":"jar","box_uv":true,"render_order":"default","locked":false,"export":true,"scope":0,"allow_mirror_modeling":true,"from":[-2,19,3],"to":[2,20,7],"autouv":0,"color":3,"origin":[0,13,5],"uv_offset":[0,21],"faces":{"north":{"uv":[4,25,8,26],"texture":0},"east":{"uv":[0,25,4,26],"texture":0},"south":{"uv":[12,25,16,26],"texture":0},"west":{"uv":[8,25,12,26],"texture":0},"up":{"uv":[8,25,4,21],"texture":0},"down":{"uv":[12,21,8,25],"texture":0}},"type":"cube","uuid":"40c1666f-02c0-f70d-fbe3-a13ef3fcd316"},{"name":"jar","box_uv":true,"render_order":"default","locked":false,"export":true,"scope":0,"allow_mirror_modeling":true,"from":[-3,13,2],"to":[3,19,8],"autouv":0,"color":3,"origin":[0,13,5],"faces":{"north":{"uv":[6,6,12,12],"texture":0},"east":{"uv":[0,6,6,12],"texture":0},"south":{"uv":[18,6,24,12],"texture":0},"west":{"uv":[12,6,18,12],"texture":0},"up":{"uv":[12,6,6,0],"texture":0},"down":{"uv":[18,0,12,6],"texture":0}},"type":"cube","uuid":"64ae88bc-3996-a5ed-3961-72fe4260bb34"},{"name":"jar","box_uv":false,"render_order":"default","locked":false,"export":true,"scope":0,"allow_mirror_modeling":true,"from":[-2.5,13.5,2.5],"to":[2.5,16.4,7.5],"autouv":0,"color":3,"origin":[0,13,5],"faces":{"north":{"uv":[4,27,9,29],"texture":0},"east":{"uv":[6,28,11,30],"texture":0},"south":{"uv":[6,28,11,30],"texture":0},"west":{"uv":[6,27,11,29],"texture":0},"up":{"uv":[9,31,4,26],"texture":0},"down":{"uv":[12,26,7,31],"texture":0}},"type":"cube","uuid":"dca6bef8-2026-592f-c697-8afc8d2e20db"},{"name":"cube","box_uv":true,"render_order":"default","locked":false,"export":true,"scope":0,"allow_mirror_modeling":true,"from":[6,14,-4],"to":[9,15,8],"autouv":0,"color":5,"origin":[7.5,14.5,8],"uv_offset":[18,40],"faces":{"north":{"uv":[30,52,33,53],"texture":0},"east":{"uv":[18,52,30,53],"texture":0},"south":{"uv":[45,52,48,53],"texture":0},"west":{"uv":[33,52,45,53],"texture":0},"up":{"uv":[33,52,30,40],"texture":0},"down":{"uv":[36,40,33,52],"texture":0}},"type":"cube","uuid":"6431ab45-5647-823c-3acd-05bca91ac253"},{"name":"cube","box_uv":true,"render_order":"default","locked":false,"export":true,"scope":0,"allow_mirror_modeling":true,"from":[5,12,0],"to":[10,14,8],"autouv":0,"color":5,"origin":[6,14,13],"uv_offset":[0,40],"faces":{"north":{"uv":[8,48,13,50],"texture":0},"east":{"uv":[0,48,8,50],"texture":0},"south":{"uv":[21,48,26,50],"texture":0},"west":{"uv":[13,48,21,50],"texture":0},"up":{"uv":[13,48,8,40],"texture":0},"down":{"uv":[18,40,13,48],"texture":0}},"type":"cube","uuid":"6b3dd050-6577-98b0-2310-124394add49f"},{"name":"jar","box_uv":true,"render_order":"default","locked":false,"export":true,"scope":0,"allow_mirror_modeling":true,"from":[-3,12,2],"to":[3,13,8],"autouv":0,"color":3,"origin":[0,12,5],"uv_offset":[36,1],"faces":{"north":{"uv":[42,7,48,8],"texture":0},"east":{"uv":[36,7,42,8],"texture":0},"south":{"uv":[54,7,60,8],"texture":0},"west":{"uv":[48,7,54,8],"texture":0},"up":{"uv":[48,7,42,1],"texture":0},"down":{"uv":[54,1,48,7],"texture":0}},"type":"cube","uuid":"fccf3455-30d6-5b76-4e5f-5054665324f2"},{"name":"backpack","box_uv":true,"render_order":"default","locked":false,"export":true,"scope":0,"allow_mirror_modeling":true,"from":[-3,20,2],"to":[3,23,8],"autouv":0,"color":0,"origin":[0,13,5],"uv_offset":[0,12],"faces":{"north":{"uv":[6,18,12,21],"texture":0},"east":{"uv":[0,18,6,21],"texture":0},"south":{"uv":[18,18,24,21],"texture":0},"west":{"uv":[12,18,18,21],"texture":0},"up":{"uv":[12,18,6,12],"texture":0},"down":{"uv":[18,12,12,18],"texture":0}},"type":"cube","uuid":"ebdeef3a-420f-f1f3-464a-f2d45506030b"},{"name":"cube","box_uv":true,"render_order":"default","locked":false,"export":true,"scope":0,"allow_mirror_modeling":true,"from":[11,18,5],"to":[16,19,6],"autouv":0,"color":5,"origin":[6,21,11],"uv_offset":[28,9],"faces":{"north":{"uv":[29,10,34,11],"texture":0},"east":{"uv":[28,10,29,11],"texture":0},"south":{"uv":[35,10,40,11],"texture":0},"west":{"uv":[34,10,35,11],"texture":0},"up":{"uv":[34,10,29,9],"texture":0},"down":{"uv":[39,9,34,10],"texture":0}},"type":"cube","uuid":"f8da70a3-0804-e0ab-606f-8abce97f6245"},{"name":"cube","box_uv":true,"render_order":"default","locked":false,"export":true,"scope":0,"allow_mirror_modeling":true,"from":[5,18,5],"to":[11,25,6],"autouv":0,"color":5,"origin":[6,21,11],"uv_offset":[28,11],"faces":{"north":{"uv":[29,12,35,19],"texture":0},"east":{"uv":[28,12,29,19],"texture":0},"south":{"uv":[36,12,42,19],"texture":0},"west":{"uv":[35,12,36,19],"texture":0},"up":{"uv":[35,12,29,11],"texture":0},"down":{"uv":[41,11,35,12],"texture":0}},"type":"cube","uuid":"5ff5d721-7dcc-db57-fd58-90f99d1cf226"},{"name":"cube","box_uv":true,"render_order":"default","locked":false,"export":true,"scope":0,"allow_mirror_modeling":true,"from":[11,24,5],"to":[16,25,6],"autouv":0,"color":5,"origin":[6,26,11],"uv_offset":[28,9],"faces":{"north":{"uv":[29,10,34,11],"texture":0},"east":{"uv":[28,10,29,11],"texture":0},"south":{"uv":[35,10,40,11],"texture":0},"west":{"uv":[34,10,35,11],"texture":0},"up":{"uv":[34,10,29,9],"texture":0},"down":{"uv":[39,9,34,10],"texture":0}},"type":"cube","uuid":"087e4eed-4b87-b476-a58a-e5cd2dfb9b62"},{"name":"cube","box_uv":true,"render_order":"default","locked":false,"export":true,"scope":0,"allow_mirror_modeling":true,"from":[16,18,5],"to":[17,25,6],"autouv":0,"color":5,"origin":[11,21,11],"uv_offset":[42,11],"faces":{"north":{"uv":[43,12,44,19],"texture":0},"east":{"uv":[42,12,43,19],"texture":0},"south":{"uv":[45,12,46,19],"texture":0},"west":{"uv":[44,12,45,19],"texture":0},"up":{"uv":[44,12,43,11],"texture":0},"down":{"uv":[45,11,44,12],"texture":0}},"type":"cube","uuid":"6d0672f0-f07a-dfe1-d26e-95f05e3bee4a"},{"name":"cube","box_uv":true,"render_order":"default","locked":false,"export":true,"scope":0,"allow_mirror_modeling":true,"from":[3,20,4],"to":[5,22,7],"autouv":0,"color":5,"origin":[6,20,11],"uv_offset":[54,0],"faces":{"north":{"uv":[57,3,59,5],"texture":0},"east":{"uv":[54,3,57,5],"texture":0},"south":{"uv":[62,3,64,5],"texture":0},"west":{"uv":[59,3,62,5],"texture":0},"up":{"uv":[59,3,57,0],"texture":0},"down":{"uv":[61,0,59,3],"texture":0}},"type":"cube","uuid":"fbe8de4d-64f1-819b-e972-af0d865c8a69"},{"name":"jar","box_uv":true,"render_order":"default","locked":false,"export":true,"scope":0,"allow_mirror_modeling":true,"from":[3,12,2],"to":[5,14,8],"autouv":0,"color":3,"origin":[2,12,5],"uv_offset":[0,56],"faces":{"north":{"uv":[6,62,8,64],"texture":0},"east":{"uv":[0,62,6,64],"texture":0},"south":{"uv":[14,62,16,64],"texture":0},"west":{"uv":[8,62,14,64],"texture":0},"up":{"uv":[8,62,6,56],"texture":0},"down":{"uv":[10,56,8,62],"texture":0}},"type":"cube","uuid":"39965490-c1c7-4a27-6ac8-5df19a30797b"},{"name":"cube","box_uv":true,"render_order":"default","locked":false,"export":true,"scope":0,"allow_mirror_modeling":true,"from":[-10,12,0],"to":[-5,14,8],"autouv":0,"color":5,"origin":[-6,14,13],"uv_offset":[0,40],"faces":{"north":{"uv":[8,48,13,50],"texture":0},"east":{"uv":[0,48,8,50],"texture":0},"south":{"uv":[21,48,26,50],"texture":0},"west":{"uv":[13,48,21,50],"texture":0},"up":{"uv":[13,48,8,40],"texture":0},"down":{"uv":[18,40,13,48],"texture":0}},"type":"cube","uuid":"899d9ef2-c11c-ee39-443c-ffe1dac7b63b"},{"name":"cube","box_uv":true,"render_order":"default","locked":false,"export":true,"scope":0,"allow_mirror_modeling":true,"from":[-9,14,-4],"to":[-6,15,8],"autouv":0,"color":5,"origin":[-7,14,4],"uv_offset":[18,40],"faces":{"north":{"uv":[30,52,33,53],"texture":0},"east":{"uv":[18,52,30,53],"texture":0},"south":{"uv":[45,52,48,53],"texture":0},"west":{"uv":[33,52,45,53],"texture":0},"up":{"uv":[33,52,30,40],"texture":0},"down":{"uv":[36,40,33,52],"texture":0}},"type":"cube","uuid":"7029bf13-5440-ad59-b3e1-ee4d396388fb"},{"name":"jar","box_uv":true,"render_order":"default","locked":false,"export":true,"scope":0,"allow_mirror_modeling":true,"from":[-5,12,2],"to":[-3,14,8],"autouv":0,"color":3,"origin":[-2,12,5],"uv_offset":[0,56],"faces":{"north":{"uv":[6,62,8,64],"texture":0},"east":{"uv":[0,62,6,64],"texture":0},"south":{"uv":[14,62,16,64],"texture":0},"west":{"uv":[8,62,14,64],"texture":0},"up":{"uv":[8,62,6,56],"texture":0},"down":{"uv":[10,56,8,62],"texture":0}},"type":"cube","uuid":"64e8eb17-bb0a-dbee-6042-3b64c5613076"},{"name":"cube","box_uv":true,"render_order":"default","locked":false,"export":true,"scope":0,"allow_mirror_modeling":true,"from":[-5,20,4],"to":[-3,22,7],"autouv":0,"color":5,"origin":[-6,20,11],"uv_offset":[54,0],"faces":{"north":{"uv":[57,3,59,5],"texture":0},"east":{"uv":[54,3,57,5],"texture":0},"south":{"uv":[62,3,64,5],"texture":0},"west":{"uv":[59,3,62,5],"texture":0},"up":{"uv":[59,3,57,0],"texture":0},"down":{"uv":[61,0,59,3],"texture":0}},"type":"cube","uuid":"e7a149c7-8fa5-98e0-2566-ea91fa9a3bf6"},{"name":"cube","box_uv":true,"render_order":"default","locked":false,"export":true,"scope":0,"allow_mirror_modeling":true,"from":[-16,19,5.5],"to":[-11,24,5.5],"autouv":0,"color":5,"mirror_uv":true,"origin":[-11,21,10.5],"uv_offset":[38,19],"faces":{"north":{"uv":[43,19,38,24],"texture":0},"east":{"uv":[43,19,43,24],"texture":0},"south":{"uv":[48,19,43,24],"texture":0},"west":{"uv":[38,19,38,24],"texture":0},"up":{"uv":[38,19,43,19],"texture":0},"down":{"uv":[43,19,48,19],"texture":0}},"type":"cube","uuid":"32154676-f88c-c76c-26cc-d8857352f47d"},{"name":"cube","box_uv":true,"render_order":"default","locked":false,"export":true,"scope":0,"allow_mirror_modeling":true,"from":[-16,18,5],"to":[-11,19,6],"autouv":0,"color":5,"mirror_uv":true,"origin":[-6,21,11],"uv_offset":[28,9],"faces":{"north":{"uv":[34,10,29,11],"texture":0},"east":{"uv":[35,10,34,11],"texture":0},"south":{"uv":[40,10,35,11],"texture":0},"west":{"uv":[29,10,28,11],"texture":0},"up":{"uv":[29,10,34,9],"texture":0},"down":{"uv":[34,9,39,10],"texture":0}},"type":"cube","uuid":"38f3b407-8a80-4fb4-b3c1-4a69866add02"},{"name":"cube","box_uv":true,"render_order":"default","locked":false,"export":true,"scope":0,"allow_mirror_modeling":true,"from":[-16,24,5],"to":[-11,25,6],"autouv":0,"color":5,"mirror_uv":true,"origin":[-6,26,11],"uv_offset":[28,9],"faces":{"north":{"uv":[34,10,29,11],"texture":0},"east":{"uv":[35,10,34,11],"texture":0},"south":{"uv":[40,10,35,11],"texture":0},"west":{"uv":[29,10,28,11],"texture":0},"up":{"uv":[29,10,34,9],"texture":0},"down":{"uv":[34,9,39,10],"texture":0}},"type":"cube","uuid":"e2e9a41e-5356-f99e-32d1-9f697c4dc408"},{"name":"cube","box_uv":true,"render_order":"default","locked":false,"export":true,"scope":0,"allow_mirror_modeling":true,"from":[-17,18,5],"to":[-16,25,6],"autouv":0,"color":5,"mirror_uv":true,"origin":[-11,21,11],"uv_offset":[42,11],"faces":{"north":{"uv":[44,12,43,19],"texture":0},"east":{"uv":[45,12,44,19],"texture":0},"south":{"uv":[46,12,45,19],"texture":0},"west":{"uv":[43,12,42,19],"texture":0},"up":{"uv":[43,12,44,11],"texture":0},"down":{"uv":[44,11,45,12],"texture":0}},"type":"cube","uuid":"e45af33f-1975-7ec6-00df-041cac18c6cf"},{"name":"cube","box_uv":true,"render_order":"default","locked":false,"export":true,"scope":0,"allow_mirror_modeling":true,"from":[11,19,5.5],"to":[16,24,5.5],"autouv":0,"color":5,"origin":[11,21,10.5],"uv_offset":[38,19],"faces":{"north":{"uv":[38,19,43,24],"texture":0},"east":{"uv":[38,19,38,24],"texture":0},"south":{"uv":[43,19,48,24],"texture":0},"west":{"uv":[43,19,43,24],"texture":0},"up":{"uv":[43,19,38,19],"texture":0},"down":{"uv":[48,19,43,19],"texture":0}},"type":"cube","uuid":"10cbc454-1676-e64c-cb32-389359d3c12e"},{"name":"cube","box_uv":true,"render_order":"default","locked":false,"export":true,"scope":0,"allow_mirror_modeling":true,"from":[-11,18,5],"to":[-5,25,6],"autouv":0,"color":5,"mirror_uv":true,"origin":[-6,21,11],"uv_offset":[28,11],"faces":{"north":{"uv":[35,12,29,19],"texture":0},"east":{"uv":[36,12,35,19],"texture":0},"south":{"uv":[42,12,36,19],"texture":0},"west":{"uv":[29,12,28,19],"texture":0},"up":{"uv":[29,12,35,11],"texture":0},"down":{"uv":[35,11,41,12],"texture":0}},"type":"cube","uuid":"22636c62-0f76-cafd-f765-fa76fa4aa70a"}],"groups":[{"name":"backpack","uuid":"f5508479-b269-fa2e-7e13-bb7c40318cc9","export":true,"locked":false,"scope":0,"selected":false,"_static":{"properties":{},"temp_data":{}},"origin":[0,0,0],"rotation":[0,0,0],"color":0,"children":[],"reset":false,"shade":true,"mirror_uv":false,"visibility":true,"autouv":0,"isOpen":true,"primary_selected":false},{"name":"dial","uuid":"a9743fd3-5020-8434-6935-440b2f079a98","export":true,"locked":false,"scope":0,"selected":false,"_static":{"properties":{},"temp_data":{}},"origin":[2.25,20.95,8.45],"rotation":[0,0,-42.5],"color":1,"children":[],"reset":false,"shade":true,"mirror_uv":false,"visibility":true,"autouv":0,"isOpen":false,"primary_selected":false},{"name":"jar","uuid":"0748091c-6e09-7777-0d4a-412cd219351f","export":true,"locked":false,"scope":0,"selected":false,"_static":{"properties":{},"temp_data":{}},"origin":[0,0,0],"rotation":[0,0,0],"color":3,"children":[],"reset":false,"shade":true,"mirror_uv":false,"visibility":true,"autouv":0,"isOpen":false,"primary_selected":false},{"name":"wingr","uuid":"456b89ca-81c6-3d1f-18ad-8e8906c83e2a","export":true,"locked":false,"scope":0,"selected":false,"_static":{"properties":{},"temp_data":{}},"origin":[5,21.5,5.6],"rotation":[90,-45,-90],"color":0,"children":[],"reset":false,"shade":true,"mirror_uv":false,"visibility":true,"autouv":0,"isOpen":true,"primary_selected":false},{"name":"wingl","uuid":"476fae50-e1a9-2de7-23be-cefacbe1051d","export":true,"locked":false,"scope":0,"selected":false,"_static":{"properties":{},"temp_data":{}},"origin":[-5,21.5,5.6],"rotation":[90,45,90],"color":0,"children":[],"reset":false,"shade":true,"mirror_uv":false,"visibility":true,"autouv":0,"isOpen":true,"primary_selected":false},{"name":"controlls","uuid":"f32b0e3d-b2bc-c57f-12a6-8de709539554","export":true,"locked":false,"scope":0,"selected":false,"_static":{"properties":{},"temp_data":{}},"origin":[7,14,-1],"rotation":[0,0,0],"color":0,"children":[],"reset":false,"shade":true,"mirror_uv":false,"visibility":true,"autouv":0,"isOpen":true,"primary_selected":false},{"name":"joints","uuid":"81fdaef2-1f3b-609d-033c-07f23461fad3","export":true,"locked":false,"scope":0,"selected":false,"_static":{"properties":{},"temp_data":{}},"origin":[6,20,11],"rotation":[0,0,0],"color":0,"children":[],"reset":false,"shade":true,"mirror_uv":false,"visibility":true,"autouv":0,"isOpen":false,"primary_selected":false},{"name":"l","uuid":"630148de-765c-356c-5164-e8e6d4f843b6","export":true,"locked":false,"scope":0,"selected":false,"_static":{"properties":{},"temp_data":{}},"origin":[-7,14,4],"rotation":[0,0,0],"color":0,"children":[],"reset":false,"shade":true,"mirror_uv":false,"visibility":true,"autouv":0,"isOpen":false,"primary_selected":false},{"name":"r","uuid":"267a72e3-2e67-8573-59c4-1a24fa1d0740","export":true,"locked":false,"scope":0,"selected":false,"_static":{"properties":{},"temp_data":{}},"origin":[7,14,-1],"rotation":[0,0,0],"color":0,"children":[],"reset":false,"shade":true,"mirror_uv":false,"visibility":true,"autouv":0,"isOpen":false,"primary_selected":false},{"name":"propeller","uuid":"94a96480-21f2-5714-a076-9a0b48e9de42","export":true,"locked":false,"scope":0,"selected":false,"_static":{"properties":{},"temp_data":{}},"origin":[5,21.5,5.6],"rotation":[0,0,0],"color":0,"children":[],"reset":false,"shade":true,"mirror_uv":false,"visibility":true,"autouv":0,"isOpen":true,"primary_selected":false},{"name":"propeller2","uuid":"5135ebf4-11d0-155f-e390-c1cc3671bed5","export":true,"locked":false,"scope":0,"selected":false,"_static":{"properties":{},"temp_data":{}},"origin":[-5,21.5,5.6],"rotation":[0,0,0],"color":0,"children":[],"reset":false,"shade":true,"mirror_uv":false,"visibility":true,"autouv":0,"isOpen":true,"primary_selected":false}],"outliner":[{"uuid":"f5508479-b269-fa2e-7e13-bb7c40318cc9","isOpen":true,"children":["ebdeef3a-420f-f1f3-464a-f2d45506030b","f7d99853-dceb-61dd-1c48-4b4b15ae94c5","6257b5e9-b7ee-5a0a-1d6b-6b5fe4fd5c84","25c2abac-2fee-30ed-3d08-fad1cd563a0a",{"uuid":"a9743fd3-5020-8434-6935-440b2f079a98","isOpen":false,"children":["fc492963-6369-77db-5a96-ec475cd9f2dc"]},{"uuid":"0748091c-6e09-7777-0d4a-412cd219351f","isOpen":false,"children":["40c1666f-02c0-f70d-fbe3-a13ef3fcd316","64ae88bc-3996-a5ed-3961-72fe4260bb34","fccf3455-30d6-5b76-4e5f-5054665324f2","39965490-c1c7-4a27-6ac8-5df19a30797b","64e8eb17-bb0a-dbee-6042-3b64c5613076","dca6bef8-2026-592f-c697-8afc8d2e20db"]},{"uuid":"81fdaef2-1f3b-609d-033c-07f23461fad3","isOpen":false,"children":["fbe8de4d-64f1-819b-e972-af0d865c8a69","e7a149c7-8fa5-98e0-2566-ea91fa9a3bf6"]},{"uuid":"476fae50-e1a9-2de7-23be-cefacbe1051d","isOpen":true,"children":["22636c62-0f76-cafd-f765-fa76fa4aa70a","38f3b407-8a80-4fb4-b3c1-4a69866add02","e2e9a41e-5356-f99e-32d1-9f697c4dc408","e45af33f-1975-7ec6-00df-041cac18c6cf",{"uuid":"5135ebf4-11d0-155f-e390-c1cc3671bed5","isOpen":true,"children":["32154676-f88c-c76c-26cc-d8857352f47d"]}]},{"uuid":"f32b0e3d-b2bc-c57f-12a6-8de709539554","isOpen":true,"children":["6b3dd050-6577-98b0-2310-124394add49f","899d9ef2-c11c-ee39-443c-ffe1dac7b63b",{"uuid":"630148de-765c-356c-5164-e8e6d4f843b6","isOpen":false,"children":["7029bf13-5440-ad59-b3e1-ee4d396388fb"]},{"uuid":"267a72e3-2e67-8573-59c4-1a24fa1d0740","isOpen":false,"children":["6431ab45-5647-823c-3acd-05bca91ac253"]}]},{"uuid":"456b89ca-81c6-3d1f-18ad-8e8906c83e2a","isOpen":true,"children":["f8da70a3-0804-e0ab-606f-8abce97f6245","6d0672f0-f07a-dfe1-d26e-95f05e3bee4a","087e4eed-4b87-b476-a58a-e5cd2dfb9b62","5ff5d721-7dcc-db57-fd58-90f99d1cf226",{"uuid":"94a96480-21f2-5714-a076-9a0b48e9de42","isOpen":true,"children":["10cbc454-1676-e64c-cb32-389359d3c12e"]}]}]}],"textures":[{"name":"portabel_beehive_wings_texture.png","relative_path":"../textures/armor/portabel_beehive_wings_texture.png","folder":"","namespace":"","id":"0","group":"","scope":0,"width":64,"height":64,"uv_width":64,"uv_height":64,"particle":true,"use_as_default":false,"layers_enabled":false,"sync_to_project":"","file_format":"png","render_mode":"default","render_sides":"auto","wrap_mode":"limited","pbr_channel":"color","fps":7,"frame_time":1,"frame_order_type":"loop","frame_order":"","frame_interpolate":false,"visible":true,"internal":true,"saved":true,"uuid":"a599f4dd-b7c6-8b54-84e1-cbdb86f48191","source":"data:image/png;base64,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"}],"animations":[{"uuid":"0cc3abdf-7f48-32c4-7b54-239b50377221","name":"start_flying","loop":"once","override":false,"length":0.5,"snapping":24,"selected":true,"saved":false,"path":"","scope":0,"anim_time_update":"","blend_weight":"","start_delay":"","loop_delay":"","animators":{"f5508479-b269-fa2e-7e13-bb7c40318cc9":{"name":"backpack","type":"bone","rotation_global":false,"quaternion_interpolation":false},"a9743fd3-5020-8434-6935-440b2f079a98":{"name":"dial","type":"bone","rotation_global":false,"quaternion_interpolation":false},"0748091c-6e09-7777-0d4a-412cd219351f":{"name":"jar","type":"bone","rotation_global":false,"quaternion_interpolation":false},"456b89ca-81c6-3d1f-18ad-8e8906c83e2a":{"name":"wingr","type":"bone","rotation_global":false,"quaternion_interpolation":false,"keyframes":[{"channel":"rotation","data_points":[{"x":"0","y":"0","z":"0"}],"uuid":"aa7a5845-cb49-db8d-630e-54c25690c345","time":0,"color":-1,"interpolation":"linear"},{"channel":"rotation","data_points":[{"x":"0","y":"17.5","z":"89.9999"}],"uuid":"fdfc92ca-0871-a324-7412-a9403239830f","time":0.5,"color":-1,"interpolation":"linear"}]},"476fae50-e1a9-2de7-23be-cefacbe1051d":{"name":"wingl","type":"bone","rotation_global":false,"quaternion_interpolation":false,"keyframes":[{"channel":"rotation","data_points":[{"x":"0","y":"0","z":"0"}],"uuid":"e63af43e-ec1a-4477-ff5f-14af17eda4c5","time":0,"color":-1,"interpolation":"linear"},{"channel":"rotation","data_points":[{"x":"0","y":"-17.5","z":"-90"}],"uuid":"c48dec0f-9bd5-51d3-e6de-5e4c0874eed3","time":0.5,"color":-1,"interpolation":"linear"}]},"f32b0e3d-b2bc-c57f-12a6-8de709539554":{"name":"controlls","type":"bone","rotation_global":false,"quaternion_interpolation":false},"81fdaef2-1f3b-609d-033c-07f23461fad3":{"name":"joints","type":"bone","rotation_global":false,"quaternion_interpolation":false},"630148de-765c-356c-5164-e8e6d4f843b6":{"name":"l","type":"bone","rotation_global":false,"quaternion_interpolation":false,"keyframes":[{"channel":"position","data_points":[{"x":"0","y":"0","z":"0"}],"uuid":"97dcd516-3dd0-13f5-185d-92db7d5e75ee","time":0,"color":-1,"interpolation":"linear"},{"channel":"position","data_points":[{"x":"0","y":"0","z":"-3"}],"uuid":"462636f8-c621-ef31-631b-2ef019756ecb","time":0.5,"color":-1,"interpolation":"linear"}]},"267a72e3-2e67-8573-59c4-1a24fa1d0740":{"name":"r","type":"bone","rotation_global":false,"quaternion_interpolation":false,"keyframes":[{"channel":"position","data_points":[{"x":"0","y":"0","z":"0"}],"uuid":"205e46c8-b6a7-e87a-509c-fdbcef487d7d","time":0,"color":-1,"interpolation":"linear"},{"channel":"position","data_points":[{"x":"0","y":"0","z":"-3"}],"uuid":"f05da105-b37d-dde1-c5b5-65bb702a80e2","time":0.5,"color":-1,"interpolation":"linear"}]},"94a96480-21f2-5714-a076-9a0b48e9de42":{"name":"propeller","type":"bone","rotation_global":false,"quaternion_interpolation":false},"5135ebf4-11d0-155f-e390-c1cc3671bed5":{"name":"propeller2","type":"bone","rotation_global":false,"quaternion_interpolation":false}}}],"geckolib_model_type":"Armor"} \ No newline at end of file diff --git a/src/main/resources/assets/cbbees/geo/mechanical_bee.geo.json b/src/main/resources/assets/cbbees/geo/mechanical_bee.geo.json index c41eab7..b75d2fe 100644 --- a/src/main/resources/assets/cbbees/geo/mechanical_bee.geo.json +++ b/src/main/resources/assets/cbbees/geo/mechanical_bee.geo.json @@ -3,47 +3,158 @@ "minecraft:geometry": [ { "description": { - "identifier": "geometry.unknown", + "identifier": "geometry.cogwheel - Converted", "texture_width": 64, "texture_height": 64, - "visible_bounds_width": 3, + "visible_bounds_width": 2, "visible_bounds_height": 2.5, "visible_bounds_offset": [0, 0.75, 0] }, "bones": [ { - "name": "bb_main", - "pivot": [0, 0, 0], + "name": "bee", + "pivot": [0, 8, 0] + }, + { + "name": "gear2", + "parent": "bee", + "pivot": [0.6, 6.83433, 0.13248], "cubes": [ - {"origin": [-4, 3, -5.1], "size": [8, 3, 1], "uv": [0, 37]}, - {"origin": [-1.5, 7, -8], "size": [0, 2, 3], "uv": [30, 26]}, - {"origin": [1.5, 7, -8], "size": [0, 2, 3], "uv": [30, 21]}, - {"origin": [-3.5, 2, -5], "size": [7, 7, 10], "uv": [0, 0]}, - {"origin": [-3.5, 0, -2], "size": [7, 2, 0], "uv": [30, 19]}, - {"origin": [-3.5, 0, 0], "size": [7, 2, 0], "uv": [30, 17]}, - {"origin": [-3.5, 0, 2], "size": [7, 2, 0], "uv": [16, 29]} + { + "origin": [-2.472, 6.09193, -2.93952], + "size": [6.144, 1.4848, 6.144], + "pivot": [0.6, 6.83433, 0.13248], + "rotation": [0, 0, 90], + "uv": { + "north": {"uv": [0, 12], "uv_size": [12, 3]}, + "east": {"uv": [0, 12], "uv_size": [12, 3]}, + "south": {"uv": [0, 12], "uv_size": [12, 3]}, + "west": {"uv": [0, 12], "uv_size": [12, 3]}, + "up": {"uv": [20, 12], "uv_size": [-12, -12]}, + "down": {"uv": [20, 12], "uv_size": [-12, -12]} + } + }, + { + "origin": [-1.448, 5.81033, -1.91552], + "size": [4.096, 2.048, 4.096], + "pivot": [0.6, 6.83433, 0.13248], + "rotation": [0, 0, 90], + "uv": { + "north": {"uv": [0, 8], "uv_size": [8, 4]}, + "east": {"uv": [0, 8], "uv_size": [8, 4]}, + "south": {"uv": [0, 8], "uv_size": [8, 4]}, + "west": {"uv": [0, 8], "uv_size": [8, 4]}, + "up": {"uv": [8, 8], "uv_size": [-8, -8]}, + "down": {"uv": [8, 8], "uv_size": [-8, -8]} + } + }, + { + "origin": [-4.008, 6.06633, -0.63552], + "size": [9.216, 1.536, 1.536], + "pivot": [0.6, 6.83433, 0.13248], + "rotation": [0, 0, 90], + "uv": { + "north": {"uv": [14, 16], "uv_size": [18, 3]}, + "east": {"uv": [10, 16], "uv_size": [3, 3]}, + "south": {"uv": [14, 16], "uv_size": [18, 3]}, + "west": {"uv": [10, 16], "uv_size": [3, 3]}, + "up": {"uv": [32, 15], "uv_size": [-18, -3]}, + "down": {"uv": [32, 15], "uv_size": [-18, -3]} + } + }, + { + "origin": [-4.008, 6.06633, -0.63552], + "size": [9.216, 1.536, 1.536], + "pivot": [0.6, 6.83433, 0.13248], + "rotation": [0, -45, 90], + "uv": { + "north": {"uv": [14, 16], "uv_size": [18, 3]}, + "east": {"uv": [10, 16], "uv_size": [3, 3]}, + "south": {"uv": [14, 16], "uv_size": [18, 3]}, + "west": {"uv": [10, 16], "uv_size": [3, 3]}, + "up": {"uv": [32, 15], "uv_size": [-18, -3]}, + "down": {"uv": [32, 15], "uv_size": [-18, -3]} + } + }, + { + "origin": [-4.008, 6.06633, -0.63552], + "size": [9.216, 1.536, 1.536], + "pivot": [0.6, 6.83433, 0.13248], + "rotation": [0, 45, 90], + "uv": { + "north": {"uv": [14, 16], "uv_size": [18, 3]}, + "east": {"uv": [10, 16], "uv_size": [3, 3]}, + "south": {"uv": [14, 16], "uv_size": [18, 3]}, + "west": {"uv": [10, 16], "uv_size": [3, 3]}, + "up": {"uv": [32, 15], "uv_size": [-18, -3]}, + "down": {"uv": [32, 15], "uv_size": [-18, -3]} + } + }, + { + "origin": [-0.168, 6.06633, -4.47552], + "size": [1.536, 1.536, 9.216], + "pivot": [0.6, 6.83433, 0.13248], + "rotation": [0, 0, 90], + "uv": { + "north": {"uv": [10, 16], "uv_size": [3, 3]}, + "east": {"uv": [14, 16], "uv_size": [18, 3]}, + "south": {"uv": [10, 16], "uv_size": [3, 3]}, + "west": {"uv": [14, 16], "uv_size": [18, 3]}, + "up": {"uv": [32, 15], "uv_size": [-18, -3], "uv_rotation": 90}, + "down": {"uv": [32, 15], "uv_size": [-18, -3], "uv_rotation": 90} + } + } ] }, { - "name": "rwing", - "pivot": [-3, 7, -2], + "name": "bone", + "parent": "bee", + "pivot": [8, 0, -8], "cubes": [ - {"origin": [-11, 9, -3], "size": [9, 0, 6], "uv": [0, 23]} + {"origin": [-3, 2, -5], "size": [7, 7, 10], "uv": [0, 19]}, + {"origin": [0, 5, 5], "size": [1, 1, 2], "pivot": [2, 5, 5], "rotation": [22.5, 0, 0], "uv": [0, 16]} ] }, { - "name": "lwing", - "pivot": [-3, 7, -2], + "name": "leftwing_bone", + "parent": "bee", + "pivot": [2, 9, -1], "cubes": [ - {"origin": [2, 9, -3], "size": [9, 0, 6], "uv": [0, 17]} + { + "origin": [2.3, 9.05, -4], + "size": [6, 0.1, 5], + "pivot": [2.3, 8.15, -1], + "rotation": [0, 0, -22.5], + "uv": { + "north": {"uv": [25, 28], "uv_size": [6, 0]}, + "east": {"uv": [20, 28], "uv_size": [5, 0]}, + "south": {"uv": [36, 28], "uv_size": [6, 0]}, + "west": {"uv": [31, 28], "uv_size": [5, 0]}, + "up": {"uv": [25, 23], "uv_size": [4, 6]}, + "down": {"uv": [25, 29], "uv_size": [4, -6]} + } + } ] }, { - "name": "lights", - "pivot": [0, 0, 0], + "name": "rightwing_bone", + "parent": "bee", + "pivot": [-1, 9, -1], "cubes": [ - {"origin": [0, 3, 5], "size": [0, 5, 4], "pivot": [0, 5.5, 7], "rotation": [0, 0, 90], "uv": [0, 28]}, - {"origin": [0, 3, 5], "size": [0, 5, 4], "uv": [0, 28]} + { + "origin": [-7, 9, -4], + "size": [6, 0.1, 5], + "pivot": [-1, 9.1, -1.5], + "rotation": [0, 0, 22.5], + "uv": { + "north": {"uv": [26.75, 26.5], "uv_size": [-1.5, -1]}, + "east": {"uv": [26, 26.5], "uv_size": [-1.25, 1]}, + "south": {"uv": [26, 26.5], "uv_size": [-1.5, 1]}, + "west": {"uv": [27.25, 25.5], "uv_size": [-1.25, 1]}, + "up": {"uv": [29, 23], "uv_size": [-4, 6], "uv_rotation": 270}, + "down": {"uv": [29, 29], "uv_size": [-4, -6], "uv_rotation": 90} + } + } ] } ] diff --git a/src/main/resources/assets/cbbees/geo/mechanical_bumble_bee.geo.json b/src/main/resources/assets/cbbees/geo/mechanical_bumble_bee.geo.json index c0867dc..63dbe32 100644 --- a/src/main/resources/assets/cbbees/geo/mechanical_bumble_bee.geo.json +++ b/src/main/resources/assets/cbbees/geo/mechanical_bumble_bee.geo.json @@ -3,46 +3,179 @@ "minecraft:geometry": [ { "description": { - "identifier": "geometry.unknown", + "identifier": "geometry.cogwheel - Converted", "texture_width": 64, "texture_height": 64, - "visible_bounds_width": 3, + "visible_bounds_width": 2, "visible_bounds_height": 2.5, "visible_bounds_offset": [0, 0.75, 0] }, "bones": [ { - "name": "bb_main", - "pivot": [0, 0, 0], + "name": "bee", + "pivot": [0, 8, 0] + }, + { + "name": "gear2", + "parent": "bee", + "pivot": [0.6, 6.83433, 0.13248], + "cubes": [ + { + "origin": [-2.472, 6.09193, -2.93952], + "size": [6.144, 1.4848, 6.144], + "pivot": [0.6, 6.83433, 0.13248], + "rotation": [0, 0, 90], + "uv": { + "north": {"uv": [0, 12], "uv_size": [12, 3]}, + "east": {"uv": [0, 12], "uv_size": [12, 3]}, + "south": {"uv": [0, 12], "uv_size": [12, 3]}, + "west": {"uv": [0, 12], "uv_size": [12, 3]}, + "up": {"uv": [20, 12], "uv_size": [-12, -12]}, + "down": {"uv": [20, 12], "uv_size": [-12, -12]} + } + }, + { + "origin": [-1.448, 5.81033, -1.91552], + "size": [4.096, 2.048, 4.096], + "pivot": [0.6, 6.83433, 0.13248], + "rotation": [0, 0, 90], + "uv": { + "north": {"uv": [0, 8], "uv_size": [8, 4]}, + "east": {"uv": [0, 8], "uv_size": [8, 4]}, + "south": {"uv": [0, 8], "uv_size": [8, 4]}, + "west": {"uv": [0, 8], "uv_size": [8, 4]}, + "up": {"uv": [8, 8], "uv_size": [-8, -8]}, + "down": {"uv": [8, 8], "uv_size": [-8, -8]} + } + }, + { + "origin": [-4.008, 6.06633, -0.63552], + "size": [9.216, 1.536, 1.536], + "pivot": [0.6, 6.83433, 0.13248], + "rotation": [0, 0, 90], + "uv": { + "north": {"uv": [14, 16], "uv_size": [18, 3]}, + "east": {"uv": [10, 16], "uv_size": [3, 3]}, + "south": {"uv": [14, 16], "uv_size": [18, 3]}, + "west": {"uv": [10, 16], "uv_size": [3, 3]}, + "up": {"uv": [32, 15], "uv_size": [-18, -3]}, + "down": {"uv": [32, 15], "uv_size": [-18, -3]} + } + }, + { + "origin": [-4.008, 6.06633, -0.63552], + "size": [9.216, 1.536, 1.536], + "pivot": [0.6, 6.83433, 0.13248], + "rotation": [0, -45, 90], + "uv": { + "north": {"uv": [14, 16], "uv_size": [18, 3]}, + "east": {"uv": [10, 16], "uv_size": [3, 3]}, + "south": {"uv": [14, 16], "uv_size": [18, 3]}, + "west": {"uv": [10, 16], "uv_size": [3, 3]}, + "up": {"uv": [32, 15], "uv_size": [-18, -3]}, + "down": {"uv": [32, 15], "uv_size": [-18, -3]} + } + }, + { + "origin": [-4.008, 6.06633, -0.63552], + "size": [9.216, 1.536, 1.536], + "pivot": [0.6, 6.83433, 0.13248], + "rotation": [0, 45, 90], + "uv": { + "north": {"uv": [14, 16], "uv_size": [18, 3]}, + "east": {"uv": [10, 16], "uv_size": [3, 3]}, + "south": {"uv": [14, 16], "uv_size": [18, 3]}, + "west": {"uv": [10, 16], "uv_size": [3, 3]}, + "up": {"uv": [32, 15], "uv_size": [-18, -3]}, + "down": {"uv": [32, 15], "uv_size": [-18, -3]} + } + }, + { + "origin": [-0.168, 6.06633, -4.47552], + "size": [1.536, 1.536, 9.216], + "pivot": [0.6, 6.83433, 0.13248], + "rotation": [0, 0, 90], + "uv": { + "north": {"uv": [10, 16], "uv_size": [3, 3]}, + "east": {"uv": [14, 16], "uv_size": [18, 3]}, + "south": {"uv": [10, 16], "uv_size": [3, 3]}, + "west": {"uv": [14, 16], "uv_size": [18, 3]}, + "up": {"uv": [32, 15], "uv_size": [-18, -3], "uv_rotation": 90}, + "down": {"uv": [32, 15], "uv_size": [-18, -3], "uv_rotation": 90} + } + } + ] + }, + { + "name": "bone", + "parent": "bee", + "pivot": [8, 0, -8], "cubes": [ - {"origin": [-1.5, 7, -8], "size": [0, 2, 3], "uv": [24, -3]}, - {"origin": [1.5, 7, -8], "size": [0, 2, 3], "uv": [24, -3]}, - {"origin": [-3.5, 2, -5], "size": [7, 7, 10], "uv": [0, 0]}, - {"origin": [-3.5, 0, -2], "size": [7, 2, 0], "uv": [30, 28]}, - {"origin": [-3.5, 0, 0], "size": [7, 2, 0], "uv": [30, 30]}, - {"origin": [-3.5, 0, 2], "size": [7, 2, 0], "uv": [30, 32]}, - {"origin": [-4, 3, -5.1], "size": [8, 3, 1], "uv": [0, 29]} + { + "origin": [-3, 2, -5], + "size": [7, 7, 10], + "uv": { + "north": {"uv": [10, 29], "uv_size": [7, 7]}, + "east": {"uv": [0, 29], "uv_size": [10, 7]}, + "south": {"uv": [27, 29], "uv_size": [7, 7]}, + "west": {"uv": [17, 29], "uv_size": [10, 7]}, + "up": {"uv": [10, 19], "uv_size": [7, 10]}, + "down": {"uv": [17, 29], "uv_size": [7, -10]} + } + }, + {"origin": [0, 5, 5], "size": [1, 1, 2], "pivot": [2, 5, 5], "rotation": [22.5, 0, 0], "uv": [0, 16]} ] }, { - "name": "light", - "pivot": [4, 2, 6], + "name": "leftwing_bone", + "parent": "bee", + "pivot": [2, 9, -1], "cubes": [ - {"origin": [-2, 3.5, 5], "size": [4, 4, 4], "uv": [15, 17]} + { + "origin": [2.3, 9.05, -3], + "size": [5, 0.1, 5], + "pivot": [2.3, 8.15, 0], + "rotation": [0, 0, -22.5], + "uv": { + "north": {"uv": [25, 28], "uv_size": [6, 0]}, + "east": {"uv": [20, 28], "uv_size": [5, 0]}, + "south": {"uv": [36, 28], "uv_size": [6, 0]}, + "west": {"uv": [31, 28], "uv_size": [5, 0]}, + "up": {"uv": [25, 23], "uv_size": [4, 6]}, + "down": {"uv": [25, 29], "uv_size": [4, -6]} + } + } ] }, { - "name": "rwing", - "pivot": [-3, 7, -2], + "name": "rightwing_bone", + "parent": "bee", + "pivot": [-1, 9, -1], "cubes": [ - {"origin": [-11, 9, -3], "size": [9, 0, 6], "uv": [4, 35], "mirror": true} + { + "origin": [-6, 9, -3], + "size": [5, 0.1, 5], + "pivot": [-1, 9.1, -0.5], + "rotation": [0, 0, 22.5], + "uv": { + "north": {"uv": [26.75, 26.5], "uv_size": [-1.5, -1]}, + "east": {"uv": [26, 26.5], "uv_size": [-1.25, 1]}, + "south": {"uv": [26, 26.5], "uv_size": [-1.5, 1]}, + "west": {"uv": [27.25, 25.5], "uv_size": [-1.25, 1]}, + "up": {"uv": [29, 23], "uv_size": [-4, 6], "uv_rotation": 270}, + "down": {"uv": [29, 29], "uv_size": [-4, -6], "uv_rotation": 90} + } + } ] }, { - "name": "lwing", - "pivot": [-3, 7, -2], + "name": "legs", + "pivot": [3.75, 0.5, 3.25], "cubes": [ - {"origin": [2, 9, -3], "size": [9, 0, 6], "uv": [4, 35]} + {"origin": [2.75, 1, -3], "size": [1, 2, 1], "pivot": [2.75, 1, -2], "rotation": [37.5, 0, 0], "uv": [0, 15]}, + {"origin": [-2.75, 1, -3], "size": [1, 2, 1], "pivot": [-2.75, 1, -2], "rotation": [37.5, 0, 0], "uv": [0, 15]}, + {"origin": [-2.75, 0.5, 2.25], "size": [1, 2, 1], "pivot": [-2.75, 0.5, 3.25], "rotation": [-37.5, 0, 0], "uv": [0, 15]}, + {"origin": [2.75, 0.5, 2.25], "size": [1, 2, 1], "pivot": [3.75, 0.5, 3.25], "rotation": [-37.5, 0, 0], "uv": [0, 15]} ] } ] diff --git a/src/main/resources/assets/cbbees/lang/en_us.json b/src/main/resources/assets/cbbees/lang/en_us.json index 0bf2de0..ec27d46 100644 --- a/src/main/resources/assets/cbbees/lang/en_us.json +++ b/src/main/resources/assets/cbbees/lang/en_us.json @@ -76,9 +76,9 @@ "tooltip.cbbees.bee.consumed_on_deploy": "Bees leave the hive to work, and return when done", "tooltip.cbbees.mechanical_bumble_bee.description": "Place in beehive for logistics transport", "tooltip.cbbees.mechanical_bumble_bee.consumed_on_deploy": "Shuttles items between Cargo Ports", - "tooltip.cbbees.stinger_planner.line1": "Right-click to select corners", - "tooltip.cbbees.stinger_planner.line2": "Scroll (with CTRL) to resize", - "tooltip.cbbees.stinger_planner.line3": "Shift+Right-click to cancel", + "tooltip.cbbees.deconstruction_planner.line1": "Right-click to select corners", + "tooltip.cbbees.deconstruction_planner.line2": "Scroll (with CTRL) to resize", + "tooltip.cbbees.deconstruction_planner.line3": "Shift+Right-click to cancel", "tooltip.cbbees.planner.pos1": "Corner 1: %d, %d, %d", "tooltip.cbbees.planner.pos2": "Corner 2: %d, %d, %d", "tooltip.cbbees.planner.description": "Right-click to set Corner 1. Shift+Right-click to set Corner 2.", @@ -107,7 +107,7 @@ "cbbees.construction.load_failed": "Failed to load schematic!", "cbbees.progress.processing_schematic": "Processing schematic...", "cbbees.progress.processing_area": "Processing area...", - "cbbees.deconstruction.title": "Stinger Planner", + "cbbees.deconstruction.title": "Deconstruction Planner", "cbbees.deconstruction.start": "Start Deconstruction", "cbbees.deconstruction.area_info": "Area: %dx%dx%d (%d blocks)", "cbbees.deconstruction.position_info": "Starting at: %d, %d, %d", @@ -119,10 +119,21 @@ "cbbees.deconstruction.already_active": "This area is already being deconstructed!", "cbbees.deconstruction.started": "Deconstruction started", "cbbees.deconstruction.no_blocks": "No blocks to remove in selected area!", + "cbbees.pickup.title": "Pickup Planner", "cbbees.pickup.started": "Item pickup started", "cbbees.pickup.no_items": "No loose items found in selected area!", + "cbbees.pickup.dimensions": "Scan area: %dx%dx%d", + "cbbees.pickup.first_pos": "First corner set", + "cbbees.pickup.second_pos": "[%s] to deploy pickup swarm", + "cbbees.pickup.no_target": "No block targeted", + "cbbees.pickup.abort": "Selection cancelled", "cbbees.progress.scanning_items": "Scanning for items...", "cbbees.program.pickup": "Item Pickup", + "cbbees.stall.no_network": "Swarm stalled: No bee network found!", + "cbbees.stall.out_of_range": "Swarm stalled: Target area is out of range!", + "cbbees.stall.no_bees": "Swarm stalled: No bees available in hives!", + "cbbees.stall.no_drop_off_port": "Swarm stalled: No logistics port to deliver items!", + "cbbees.stall.missing_materials": "Swarm stalled: Missing required materials!", "key.categories.cbbees": "Buzzy Bees", "key.cbbees.start_action": "Deconstruct", "key.cbbees.stop_action": "Recall All Bees", @@ -170,8 +181,9 @@ "cbbees.ponder.mechanical_bee.text_4": "The Mechanical Bumble Bee can pick up items from cargo ports and transport them to other Cargo Ports.", "item.cbbees.construction_planner": "Construction Planner", "item.cbbees.deconstruction_planner": "Deconstruction Planner", + "item.cbbees.pickup_planner": "Pickup Planner", "gui.cbbees.construction_planner.no_schematics": "No schematics available", - "gui.cbbees.construction_planner.hint_alt": "Hold [Alt] to browse", + "gui.cbbees.construction_planner.hint_alt": "Hold [%s] to browse", "gui.cbbees.construction_planner.hint_schematic": "Scroll cycle | [RMB] Deploy | [Shift+RMB] Instant", "gui.cbbees.construction_planner.hint_group": "Scroll cycle | [RMB] Enter group", "gui.cbbees.construction_planner.hint_back": "Scroll cycle | [RMB] Go back", @@ -212,7 +224,6 @@ "gui.cbbees.construction_planner.select_hint": "Select a schematic to preview", "gui.cbbees.construction_planner.group_hint": "Double-click to enter group", "gui.cbbees.construction_planner.materials": "Materials", - "gui.cbbees.schematic_prompt.group": "Set Group", "gui.cbbees.construction_planner.back": "\u25C0 Back", "gui.cbbees.construction_planner.rename": "Rename", "gui.cbbees.construction_planner.delete": "Delete", @@ -223,11 +234,16 @@ "gui.cbbees.schematic_delete.confirm": "Delete", "gui.cbbees.schematic_delete.message": "Delete \"%s\"?", "gui.cbbees.schematic_delete.hint": "This cannot be undone.", - "gui.cbbees.deconstruction.hint_alt": "Hold [Alt] for controls", + "gui.cbbees.deconstruction.hint_alt": "Hold [%s] for controls", "gui.cbbees.deconstruction.hint_select": "[RMB] Set corner | [Shift+RMB] Cancel", "gui.cbbees.deconstruction.hint_ready": "[%s] Deploy | [Shift+RMB] Cancel", "gui.cbbees.deconstruction.hint_scroll": "[Ctrl+Scroll] Resize selection", "gui.cbbees.deconstruction.hint_program": "[%s] Program into item", + "gui.cbbees.pickup.hint_alt": "Hold [%s] for controls", + "gui.cbbees.pickup.hint_select": "[RMB] Set corner | [Shift+RMB] Cancel", + "gui.cbbees.pickup.hint_ready": "[%s] Deploy | [Shift+RMB] Cancel", + "gui.cbbees.pickup.hint_scroll": "[Ctrl+Scroll] Resize selection", + "gui.cbbees.pickup.hint_program": "[%s] Program into item", "tooltip.cbbees.beehive.honey": "Honey: %d/%d", "cbbees.beehive.honey_loaded": "Loaded honey (%d/%d)", "cbbees.beehive.honey_full": "Honey tank is full!", @@ -256,7 +272,7 @@ "advancement.cbbees.cargo_port_it_bee.description": "Craft your first Cargo Port", "item.cbbees.construction_planner.tooltip": "Schematic Construction Tool", - "item.cbbees.construction_planner.tooltip.summary": "A reusable tool for selecting and deploying _Schematics_ for bee construction. Hold [Alt] to browse available schematics.", + "item.cbbees.construction_planner.tooltip.summary": "A reusable tool for selecting and deploying _Schematics_ for bee construction. Hold the modifier key to browse available schematics.", "item.cbbees.construction_planner.tooltip.condition1": "When Held", "item.cbbees.construction_planner.tooltip.behaviour1": "Displays an inline _HUD_ for browsing schematics. Use _Scroll_ to cycle, _R-Click_ to select.", "item.cbbees.construction_planner.tooltip.condition2": "With Schematic Selected", @@ -273,6 +289,15 @@ "item.cbbees.deconstruction_planner.tooltip.condition3": "Shift + R-Click", "item.cbbees.deconstruction_planner.tooltip.behaviour3": "_Cancels_ the current selection.", + "item.cbbees.pickup_planner.tooltip": "Area Item Pickup Tool", + "item.cbbees.pickup_planner.tooltip.summary": "Selects an area to scan for _loose items_. Bumble bees will collect them and deliver to a _Logistics Port_.", + "item.cbbees.pickup_planner.tooltip.condition1": "When Held", + "item.cbbees.pickup_planner.tooltip.behaviour1": "_R-Click_ to set corners of the scan area. _Ctrl+Scroll_ to resize.", + "item.cbbees.pickup_planner.tooltip.condition2": "When Area Selected", + "item.cbbees.pickup_planner.tooltip.behaviour2": "Press the _Deploy_ key to send bumble bees. Requires a _Logistics Port_ on the network.", + "item.cbbees.pickup_planner.tooltip.condition3": "Shift + R-Click", + "item.cbbees.pickup_planner.tooltip.behaviour3": "_Cancels_ the current selection.", + "item.cbbees.upgrade_template.tooltip": "Upgrade Crafting Base", "item.cbbees.upgrade_template.tooltip.summary": "A base component used to craft _Bee Upgrades_. Combine with specialized materials on a crafting table to create upgrades for your _Portable Beehive_.", @@ -307,8 +332,9 @@ "gui.cbbees.portable_beehive.no_jobs": "No active construction tasks", "gui.cbbees.portable_beehive.job_count": "%d active task(s)", - "gui.cbbees.job_detail.title": "Construction Job", - "gui.cbbees.job_detail.cancel": "Cancel Job", + "gui.cbbees.job_detail.title": "Job Details", + "gui.cbbees.job_detail.cancel": "Cancel", + "gui.cbbees.job_detail.highlight": "Highlight", "gui.cbbees.job_detail.not_found": "Job not found", "item.cbbees.honey_tank.tooltip": "Capacity Upgrade", @@ -366,6 +392,7 @@ "gui.cbbees.tool.program.desc": "Right-click to program schematic into a reusable item", "key.cbbees.program_action": "Program Schematic", "key.cbbees.schematic_modifier": "Schematic Modifier", + "key.cbbees.free_aim": "Free-Aim Selection", "gui.cbbees.deployer.mode.absolute": "Absolute Mode", "gui.cbbees.deployer.mode.relative": "Relative Mode", @@ -377,16 +404,25 @@ "gui.cbbees.deployer.mirror": "Mirror", "cbbees.ponder.schematic_deployer.header": "The Schematic Deployer", - "cbbees.ponder.schematic_deployer.text_1": "The Schematic Deployer automates construction and deconstruction jobs using programmed schematics.", - "cbbees.ponder.schematic_deployer.text_2": "Right-click with a Programmed Schematic to insert it. Create one using the Program tool in the Construction or Deconstruction Planner.", - "cbbees.ponder.schematic_deployer.text_3": "Apply a redstone signal to deploy the programmed job to nearby beehives.", - "cbbees.ponder.schematic_deployer.text_4": "Nearby Mechanical Beehives will pick up the job and dispatch bees to complete it.", - "cbbees.ponder.schematic_deployer.text_5": "A comparator reads the deployer's state: empty (0), loaded (1), job active (8), or just deployed (15).", - "cbbees.ponder.schematic_deployer.text_6": "Shift+right-click to extract the schematic. Hoppers and pipes can also insert and extract for full automation.", + "cbbees.ponder.schematic_deployer.text_1": "The Schematic Deployer executes programmed jobs automatically using a redstone signal.", + "cbbees.ponder.schematic_deployer.text_2": "It requires a Programmed Schematic to operate. These can be created using any Planner tool.", + "cbbees.ponder.schematic_deployer.text_3": "Use the Program key while holding a Planner with a selection to create a Programmed Schematic.", + "cbbees.ponder.schematic_deployer.text_4": "Right-click the Deployer with a Programmed Schematic to load it.", + "cbbees.ponder.schematic_deployer.text_5": "Apply a redstone signal to deploy the job. The Deployer activates on a rising edge.", + "cbbees.ponder.schematic_deployer.text_6": "Nearby Mechanical Beehives will pick up the job and dispatch bees to complete it.", + "cbbees.ponder.schematic_deployer.text_7": "A comparator can read the Deployer's state: empty (0), loaded (1), active (8), or just deployed (15).", + "cbbees.ponder.schematic_deployer.text_8": "Shift+right-click to extract the schematic. Hoppers and pipes can also insert and extract for full automation.", "cbbees.ponder.schematic_deployer_automation.header": "Deploy Modes", - "cbbees.ponder.schematic_deployer_automation.text_1": "The Schematic Deployer supports two deploy modes: Absolute and Relative. Right-click to open the settings GUI.", - "cbbees.ponder.schematic_deployer_automation.text_2": "In Absolute mode, the schematic is built at the exact coordinates stored when it was programmed.", - "cbbees.ponder.schematic_deployer_automation.text_3": "In Relative mode, the build target is calculated as an offset from the deployer's position.", - "cbbees.ponder.schematic_deployer_automation.text_4": "Move the deployer and the build follows — great for repeatable, portable setups.", - "cbbees.ponder.schematic_deployer_automation.text_5": "Relative mode also lets you override rotation and mirror settings from the GUI, without reprogramming the schematic." + "cbbees.ponder.schematic_deployer_automation.text_1": "The Schematic Deployer has two deploy modes. Open the GUI by right-clicking without an item.", + "cbbees.ponder.schematic_deployer_automation.text_2": "In Absolute mode, the schematic is always built at the exact coordinates stored when it was programmed.", + "cbbees.ponder.schematic_deployer_automation.text_3": "No matter where the Deployer is placed, the build happens at the same world position.", + "cbbees.ponder.schematic_deployer_automation.text_4": "In Relative mode, the build target is calculated as an offset from the Deployer's position.", + "cbbees.ponder.schematic_deployer_automation.text_5": "Move the Deployer and the build follows. Great for repeatable, portable setups.", + "cbbees.ponder.schematic_deployer_automation.text_6": "Relative mode also lets you override rotation and mirror from the GUI, without reprogramming.", + "cbbees.ponder.schematic_deployer_self_replicating.header": "Self-Replicating Schematics", + "cbbees.ponder.schematic_deployer_self_replicating.text_1": "When a schematic contains a Schematic Deployer block, something special happens.", + "cbbees.ponder.schematic_deployer_self_replicating.text_2": "The parent Deployer's held item and settings are automatically injected into the newly built child Deployer.", + "cbbees.ponder.schematic_deployer_self_replicating.text_3": "The child Deployer will already contain the same Programmed Schematic and deploy mode — ready to trigger.", + "cbbees.ponder.schematic_deployer_self_replicating.text_4": "This creates a chain reaction: each Deployer builds the next, carrying the same program forward. Perfect for expanding bases automatically.", + "cbbees.ponder.schematic_deployer_self_replicating.text_5": "Use Relative mode so each child builds at its own position rather than the original coordinates." } diff --git a/src/main/resources/assets/cbbees/models/item/pickup_planner.json b/src/main/resources/assets/cbbees/models/item/pickup_planner.json new file mode 100644 index 0000000..af5efa1 --- /dev/null +++ b/src/main/resources/assets/cbbees/models/item/pickup_planner.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/handheld", + "textures": { + "layer0": "cbbees:item/pickup_planner" + } +} diff --git a/src/main/resources/assets/cbbees/textures/entity/mechanical_bee.png b/src/main/resources/assets/cbbees/textures/entity/mechanical_bee.png index f8b0757a69095049d59e5871d329cd13dd290335..a5e6a608ca4fc5c2cc0129ded155ea1549fc94e5 100644 GIT binary patch delta 1694 zcmV;P24VU946Y53B!6Q`L_t(|oa|X`Xj@enetL6rlcq_N)wWCQs#1qal}%;1Uk*1I zq649_bxt>MzczFe!BO1Ya5z*LBC=UnL58ApjB1^32-+Whf(+*eQ}(45WkR}kZId)h zbMtjQ@4Y$qp2Vb0-A7WB2X5{??>YD6zR!E!=e)U*pM7+Z=6`Z3;E2T&;BC4lP+PQ% z;A2EgH{Tlrr|Nb*zC`)h|C00N8$H=-4t2%bqic zJHR8#38E+#ihnXEOT359E~jzrbSV10l>n*G3djd{M0A`EYo%C@(tP=mr-P>xhj?!} zk)r`T@?vy&KRs|VmSjiR9LL*jey>S@mDd)r)JG}9Nr$9>aAdlcwriP82JvK~?mJ(7 z=Cr7USy1FDdOXTkB4Src-qnP-{HUbSH?u%Xi^lxL=;)}~|Ma~#YfU%QBe1}OR7yiA z6vw6ENu;yOy;xuGkAGFP&WMGOxs$JqGY2FIp9-A~ZUsADx*uv%M*Tf&asIvixP3`8 zdVjVKLQ!l*`zjp;E5W6clUUBqA_4Q7U2rzK@z{pLNTdfak5x3Uw=csy7J0=X7wxNb zn9H+;E&Cgo4h)Qq;ft*8KvNTjZHDS?xvv%H7%?MbQ?SbtT=h1@rqWo}F&~LUx@h0_ zk50{*6hKSH5V+;p_S>5EOF5a$Kux63z<>6%MB1|7)#1m9UoI5wYiS8;ZEbDvcs%g= ze9+jO$B!Sc7*b3ENbp9X*8vtLCWJ<_=1^p$5|(l&S!pcWjq0#ipWSZ9;>C-ZIHu6u z-3`CrPXwS+U=lznAgaD-pKgL*hlZ!^qlnYd)zyU+D^|c}v!Rk8&1UeSeXH~{MSqtf zKKS`IL?Y_6eX=Yg7!1PYVj}8vLXu|nmDe0ODNtwtw?{&aTH4o%qX(Ijx2}afGYR#~ zF^rGKU}H`e>!au^ih{Fe&!VHF1EdF)gpyx~q*)I4tbdb5lBs;-NlZ;fbq8naQ&m+L zzl94I!t3?wnovooqYBT?3K@MrXMbl>ZW1rbUs}T45vl_S6PH+2VeFrxt&}-9`~f$H zgHiKLC|AWOlP2xmzY^mYFJt2igZTQ7i+UatD38?(LwN3~&-C`rmzSaa;Q{mJ;kO>b zi_d?B*~~$t(L98azbCPO-w!&0<$~Po_u$;gOL+D0_j&MH-BtCzz6+Xu=B}xP%cP5pA)yO z---$LQje-P@u12sDS1%uaho^vENI6WCVFFnnwL69IAr`2BKBkK0zwnz`-g<>QgxX6 z=?D~C9WJvs84V6=pH|q~wto*DK`atAZFjQBQ^j~oDkV%(+8n&Fr1TKSIGwY8Iepb2 z1z8?C>?s_5cO6=ph{=ki|MH{b=)o?1dyBD`qm;WNgR8Npu@QX@4cN{Uo!Vrd%^bUW zzQkbv2Hj2vwOem=qg>E;0eVn+xP+S0Ykm=tP(t74M8)S2ZKci;Xn$kQW;6_)ABV50 z9v92J3zYn&eSLlQO`A3)MceM(yQM%NV9U49C0rBM*K|VZKaDx@KUwXyII0{q0#N0s z5r8U3jQ~_RY6PIlQ6m6Vjv4`|a?}Vwm7_)gsvI=}Q01r*fGS6g08}|@1fa_C4*&oF o|No4J9##MV00v1!K^Ig50D&xsCxn2aoB#j-07*qoM6N<$f&^?g-~a#s delta 1643 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 KMNUMnLSTaQZxR6j diff --git a/src/main/resources/assets/cbbees/textures/item/pickup_planner.png b/src/main/resources/assets/cbbees/textures/item/pickup_planner.png new file mode 100644 index 0000000000000000000000000000000000000000..55c8abdcf1a1d61195037d1e3842545e5195d903 GIT binary patch literal 437 zcmV;m0ZRUfP)0f3RFX0s{n zc3Xfnb~c5mgA@Rm3D5J`6ru;a)Ikb>D2gOW66y7N0)+GB>xQ+bzbZ%p(CKs>AdJvz zwS=8f%=>3buxlCe2Uxs5G=%NZTVNaxhothn)t5Sm0x&wuOG0!fH^MErK5q82m$BB! ziSN57`3i42iUOvi3qe%HF44+FFa<#%zVFLu`#@ad*otVMnjc~n6d*Q11j39b_m9$F z?W!SL$f?suf2OAZvJzx{e5C&cCB`5xo2mT+$SNSN>*^R)W-LVjN)AH0wztbB`D>>_ zy3PX7e<9ghug*_pFc|2!K{+4Q06Hurd+XcB)B%EAj(R|;6G%zY^alU{|Nn+-J3asa f00v1!K~w_(x0S>pF5qQC00000NkvXXu0mjfwY{}l literal 0 HcmV?d00001 diff --git a/src/main/resources/cbbees.mixins.json b/src/main/resources/cbbees.mixins.json index 696aefd..37a7f34 100644 --- a/src/main/resources/cbbees.mixins.json +++ b/src/main/resources/cbbees.mixins.json @@ -10,7 +10,6 @@ "SchematicEditScreenMixin", "SchematicHandlerFindMixin", "SchematicHandlerHudMixin", - "SchematicPromptScreenMixin", "SchematicToolBaseMixin", "ToolSelectionScreenMixin" ], diff --git a/src/main/resources/data/cbbees/structure/gametest/cbbees_deconstruct.nbt b/src/main/resources/data/cbbees/structure/gametest/cbbees_deconstruct.nbt new file mode 100644 index 0000000000000000000000000000000000000000..4adfc8eebc46b59f53942c2ee6ed6c46eeae72d8 GIT binary patch literal 1109 zcmV-b1giTViwFP!00002|BaWwkK9BQ$De1$d)^=Ur4XS|MF^-6U7D*nF3H_;(Q#-m zK}Zp+S$lTZIL$3x%|G$Bx_i(RZN6u%}k28j+2Yzr^CP2icfwbA^? z!M9F2Raq?4cmxe2XgEP52pXTDX%IAP=V&|vACJJtBk=JEd^`dlkHE(x@bL(I7=aHX z@L>c#jKGHx_%H$=M&QE;d^mv*C-C6}KAgaZ6ZmifA5P%I348>Bk09_71U`bmM-cc3 z0v|!(BZ&RSTgcs8pZFTnUXrPwTN$ zlm4O16W4+!TH(u|HbMV_GRT?&) zWQpn@VD2ss+o;sJ2~~xwkGP%{xK=cqv*GiF@PC~}ba5ke@u7N0sZE`yt{~BZ242oU z&)L?nTGgruEAiWfCebsyI`F|jDZ4&V;aH||D3d;3FDHfRZ0m_CNPzh8_Lp0~!Om+> ze)#*=k3W6!n1a!V_C^$`sKi$ktkZD>|u zm0{;6*Y`xaWj@*c=a)ZjzTR0@qNhtr#Fvyv%=d<$Wc_$H|Hn5TeE!#G+c&-yudd7Q z-}oA&W&qZQNoiU;XnQx z-#vNd)19}z`SWED#Jy}^uj=?`Bln4W!-K2a(r!2>!!fG#B+H9bXtlmDt93nm=lp6v zPX~)oRkl-Ytck{cu;Tf0HnT>R8HdaJMy7VC%%;{!gl((ZEc0@#fPbO#ILy;jB`cuv zC9c;{vzEz3Ij4$^7X%=754~p&q_?IvmBuVT`RkqcdfS zkKX{-mNX*;{!k`Xp^bbPTksyu&0p-Gi!-#RIBsw;ja0Na&(VCIOVvD~9!Ht$YrQw6xMbl!_caCV3K0MRSW_YP literal 0 HcmV?d00001 diff --git a/src/main/resources/data/cbbees/structure/gametest/construction/cbbees_construction.nbt b/src/main/resources/data/cbbees/structure/gametest/construction/cbbees_construction.nbt new file mode 100644 index 0000000000000000000000000000000000000000..abfda6dfa6693dff88099742aa59e46118a96584 GIT binary patch literal 1146 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 literal 0 HcmV?d00001 diff --git a/src/main/resources/data/cbbees/structure/gametest/portable/cbbees_bees_dont_use_player.nbt b/src/main/resources/data/cbbees/structure/gametest/portable/cbbees_bees_dont_use_player.nbt new file mode 100644 index 0000000000000000000000000000000000000000..d625296c87d7015e8ece23308f561b70aa2dc392 GIT binary patch literal 983 zcmV;|11S6-iwFP!00002|BaTC+cQE(B&>t4L=X+Z@40U4{3bkA%i zJ-$GbPStz$KCj;QRT2SoA<*K8`vBm6YxSLWp~G_{jNlsF)rD}J%6Lyl_TjBA_=VI9 zr{(1X)rJ{fd$iej2pfX1@eww`1vVan*+VdU2xbq#>>-#5f|($g34)m*mzbAVtD5X=FBIY2N62xf|4rU+(=V5SIWieL^A%prm~L@f3rug-2dW(;e$V4e(?AAKfD>i+734d zQteUbY_fvI!W?du&G1c#_Vp(=r=gsym{+Cn?w_2T z{A&Nd+HwpuB}^J6`uVth-w<0v+BX8u7D{ou*oGEak@C@=$hi@58~(!!@XxByukm}Z zev4_doy&v#w$zP{c&!7yp^=JB`JKapLvK@NV;ejv6&K#(`P9r7(yHZG5kFfK3#-J@ zKAWc|g0A(lpJP%To|-?uP-}f^wUeLijR3jv9z3_jbl(^j&v;_Ni@D(nF!iADOexjH z^v#uGtq=a*Fjg{6@3M667PwOF!Fs(1nY1~#WY#Ek>f0>~|kOIm-X}@S|^k zd;Q)YpFFx|8~&z9T_WeA+L85YuAkK{|IX8Wp314vRycZ8NM)Mc@y%D)ZoKi!_U?~w zzu0^H>BdVQ1iQr@>8&6?Ta(b*tQ9Qf#&CD+taTvRVHt<3nN>@y*u-p@`D8L0D`CIB zVKIH_bUG2rEXP;3wB4fUwvq)`?xWty^S}})o7j{mKqoBK+%CdC6B@k3<$9OvO2l${ zFylOJu0m6$ro8EYwDdo(=H?3F#4079LXg--TKX;ew!B&TQ6+_jZ(x00@Kj2*BBQP= z*vPmZ3`MT3=V?f)<=o6V5S2DnN$5UHZQ)Nj+63Sv^d2+pE?3%~sQ{NX{0D)`o6b!Q F000R2_A>wg literal 0 HcmV?d00001 diff --git a/src/main/resources/data/cbbees/structure/gametest/portable/cbbees_portable_ignore_port.nbt b/src/main/resources/data/cbbees/structure/gametest/portable/cbbees_portable_ignore_port.nbt new file mode 100644 index 0000000000000000000000000000000000000000..aec4a1006c89ab8940b083031a8c826668d424a5 GIT binary patch literal 627 zcmV-(0*w71iwFP!00002|BaT-ZqzUk$H#V(CfN#v)V>1;IB?w)s0gW|Us6kjkkH~} zy{T1i9NFI8wlBZ~fOr8UZd^HWMdHE{o`EB8P$u1vO1q^@q$p1OpPAo`?bta$29ehn zN&rB6ug#uu2C1rp4qAC=uMCpH*z(lp`0^+N(O5q>T3iBRA7oH7i<>lzpy33KAZVgn zG>pK^2+WMY%m~bkz|0BEoWRTp%$&f?3Cx1PEC|ekz$^&Ng1{URm?HvnL|~2x%n^Y( zCNReY=9s`76PRNHb3$NF2+RqAIUz77#Qz3c;$i>LaDqnMqTz&CoDho>VzIf7py33~ z|MKF5yf`7R)mS-*x(FRu-;q8%ukEBhuzqU6-dtW@e#7>I6gr2&I$5cs^F~2u%MJ@& ztNwO97Le?yx(X*#!srmU$F`XQ*L}EGI)#zCI;df9EJr5fkf9ZBme!qL%QrHJw^8F~ zb82;UERP6ZkjJL!VJMu4pI-gGsQ>KlfBgRN<&W3D?&pv^(7GPI*Q;Frf!)qG! z`=9#^q8H7PWjBy7*Ib}xt&v8BpxWa>ltQ#8hYB`_SkuzULFk6jV9+a^#uEh0yYF0g z_q7X{e}3R>qg;FHNU;pkRSM#PG+v>NJkuW7#ln1bD3(1$16j5XjI|C&e1d9+Rj5>d z;WcY((F^S)NObMdGKB=qbX{SBb`(xh$gechY~!&sSkpy@H`cZtou@L$W97WIHNagD Ne*q)*Td!;h008cBHDCY$ literal 0 HcmV?d00001 diff --git a/src/main/resources/data/cbbees/structure/gametest/portable/cbbees_use_logistics_first.nbt b/src/main/resources/data/cbbees/structure/gametest/portable/cbbees_use_logistics_first.nbt new file mode 100644 index 0000000000000000000000000000000000000000..dc511c8c7ec0390c40347c3c660d7e1f339badab GIT binary patch literal 968 zcmV;(12_C1iwFP!00002|BaTgLwuAo9(k&r;Z0cj7YCqkE{SxQ+c z+iHb`vJ-ofjB0zVnMt$lg(5CU9JuiZfP~6{3kNuWggCH52(EkKAK-$-kqdO6V<+vp zNqLqc$Da4&^WJ=)?GT^|zL7`51AyYUQk_{78X~jON@1Wlnh^98744f4FK#r!%axfq z%?}TlvD}KvW08$RuyF}C9>K=H#Ks{oI|OEj!0Zs19RjmUV0H=2E`ix4FuMe1kHG8^ zm^}isM_~2{%szqHCoua2W}m?96POu+nGu*7fteAQ8G$(T-!>RE& zOKe<%jdzKSOUUICa=CS;2P2qioZURe1WpTrEtQH+T*&0# z^wEp=KYXiu^2c*0e|`PUt0AoH2s=@FpFv}j=RA`3aBB)MOW`qSwc}XCU8{vLo5#g; zyG6KrSj&$O83g6Zo5xe>g>&%PYk0Mj8LP*5TV>_F2DmbYXQO@}tu|5-jd&&_p7ii~ zgw8E!R#+wW$46`5eLmSf`S#a;p6PtG{t4F9Rbw5AvaQbj)6>(R@&8K;M!GIXx7O~T zju-ch&>DAfBhHn$9%+HWHjGU3MD+G$CajDW;Xk|t|Gc8S3cm%b+uYcTAM2EWAb>ao?u&KLpv%PGnZ0He7z!;sH)xpA15}1CVJtgmC}c2 z=1)JVyd~u4b?y@z@4!=A+_bIb(MZGyo{z22K-mulSEf>(E#F!y*6JbN>PA{6$sSL} zMS!)k2dh;NQiU}ysH{HaY`!Nf$$N4t*)QMw^C(jCE;IrROKE3_JN8i78`rqy6KXAa`&F?B_ z3HkXw3D9OG=ZUaZ6raf}4e)n(DquZDwM3c^?1mi=2ED$P_$3Xq<*R4QvD9{cfB8sb zi^4lvAkF<|b2~jDeXo>4&DTfYB6*^eUQ$uB*sz{0W-ySMLC>?4%&4&) qH6WbYlv~0Mc!C>$#<55Mg@kT$%lCvf_)G=3X5caIR^J2}4FCXYhuz`; literal 0 HcmV?d00001 diff --git a/src/main/resources/data/cbbees/structure/gametest/transport/cbbees_transport.nbt b/src/main/resources/data/cbbees/structure/gametest/transport/cbbees_transport.nbt new file mode 100644 index 0000000000000000000000000000000000000000..ef68e224db9e43846b019a7a9afaacf3366aa180 GIT binary patch literal 1040 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