diff --git a/gradle.properties b/gradle.properties index 644187f..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.1 +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/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/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..815b802 100644 --- a/src/main/java/de/devin/cbbees/mixin/SchematicHandlerHudMixin.java +++ b/src/main/java/de/devin/cbbees/mixin/SchematicHandlerHudMixin.java @@ -2,9 +2,12 @@ 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; +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; @@ -75,9 +78,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; } @@ -104,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); } } @@ -119,8 +143,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/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/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 a8e0620..da603ba 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; @@ -46,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 }; /* ------------------------------------------------------------------ */ @@ -66,8 +68,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 b87942a..cee3e79 100644 --- a/src/main/kotlin/de/devin/cbbees/CreateBuzzyBeez.kt +++ b/src/main/kotlin/de/devin/cbbees/CreateBuzzyBeez.kt @@ -19,6 +19,9 @@ 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 import de.devin.cbbees.content.schematics.client.DeconstructionRenderer import de.devin.cbbees.content.schematics.external.CreateModSchematicSource @@ -102,16 +105,26 @@ 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) 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 -> @@ -127,6 +140,12 @@ object CreateBuzzyBeez { ) { guiGraphics, deltaTracker -> DeconstructionRenderer.renderHUD(guiGraphics, deltaTracker) } + event.registerAbove( + VanillaGuiLayers.CHAT, + asResource("drone_view_hud") + ) { guiGraphics, deltaTracker -> + DroneViewHUD.renderHUD(guiGraphics, deltaTracker) + } } } @@ -144,10 +163,15 @@ 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.PortableBeehiveFluidHandler(stack) }, - AllItems.PORTABLE_BEEHIVE.get() + { stack, _ -> de.devin.cbbees.content.backpack.BeehiveFluidHandler(stack) }, + de.devin.cbbees.items.AllItems.PORTABLE_BEEHIVE.get() ) } diff --git a/src/main/kotlin/de/devin/cbbees/blocks/AllBlocks.kt b/src/main/kotlin/de/devin/cbbees/blocks/AllBlocks.kt index 4e69913..a78ee7a 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 @@ -57,6 +58,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 d691b32..dcbd054 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 @@ -28,6 +28,12 @@ object CBBeesConfig { val honeyEfficiencyBreakSpeedReduction: ModConfigSpec.DoubleValue val honeyEfficiencyCarryBonus: ModConfigSpec.IntValue val honeyEfficiencyFuelReduction: ModConfigSpec.DoubleValue + val honeyTankCapacityBonus: ModConfigSpec.IntValue + + // Drone view settings + val droneBaseRange: ModConfigSpec.DoubleValue + val droneRangeBonus: ModConfigSpec.DoubleValue + val droneMoveSpeed: ModConfigSpec.DoubleValue // Spring (clockwork) settings val springDrainPlace: ModConfigSpec.DoubleValue @@ -37,6 +43,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 @@ -60,28 +71,28 @@ 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) - minActiveRobotsAtRpm = 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) + minActiveBeesAtRpm = builder + .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) - hiveRpmRobotDivisor = builder - .comment("RPM divisor for workforce scaling: extra bees = RPM / this value") - .defineInRange("hiveRpmRobotDivisor", 8.0, 1.0, 256.0) + hiveRpmBeeDivisor = builder + .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() @@ -92,12 +103,12 @@ object CBBeesConfig { .comment("Whether bees pick up item drops when breaking blocks") .define("beePickupItems", true) - defaultMaxActiveRobots = builder - .comment("Default max active robots before RPM/upgrade scaling") - .defineInRange("defaultMaxActiveRobots", 4, 1, 64) + defaultMaxActiveBees = builder + .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() @@ -125,6 +136,27 @@ 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) + + 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") @@ -143,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 @@ -181,6 +213,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/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..ee627e0 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 @@ -48,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). @@ -75,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!! } @@ -93,11 +95,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 +173,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 +246,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 { @@ -208,41 +268,18 @@ 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) ) 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) ) - - 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 { @@ -260,21 +297,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 +378,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 +392,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..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 @@ -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,87 @@ 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.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/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 new file mode 100644 index 0000000..9367f01 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/backpack/client/UpgradeGridWidget.kt @@ -0,0 +1,264 @@ +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.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.DRONE_VIEW -> 0xFF9933FF.toInt() + UpgradeType.DRONE_RANGE -> 0xFF00CCCC.toInt() + } +} 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 5ae611d..3ecca39 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> = @@ -69,8 +74,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() @@ -83,13 +92,22 @@ 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 val hiveId: UUID? get() = homeId override var networkId: UUID = UUID.randomUUID() /** Tick when spring recharge completes at hive. -1 = not recharging. */ @@ -100,6 +118,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)) @@ -108,9 +136,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. @@ -169,11 +206,13 @@ 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) + ConstructionBeeStateMachine.tick( + this, this.level() as ServerLevel, (this.level() as ServerLevel).gameTime + ) this.level().profiler.pop() - - super.customServerAiStep() } override fun getAnimatableInstanceCache(): AnimatableInstanceCache? { @@ -181,18 +220,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) { @@ -201,6 +240,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,10 +250,10 @@ 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) + val batch = currentTask if (batch != null && batch.status != TaskStatus.COMPLETED) { val tick = (level() as? ServerLevel)?.gameTime ?: 0L batch.release(gameTick = tick) @@ -223,6 +263,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)) } @@ -233,35 +287,111 @@ class MechanicalBeeEntity(entityType: EntityType, level: Leve super.tick() if (level().isClientSide) return - syncTargetPos() - if (rechargeFinishTick < 0) { - BeeSeparation.applyFlightOffset(this) + // 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() + 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 } + if (isDrone) { + tickDrone() + return + } + + syncTargetPos() + if (beeContext == null || tickCount % 100 == 0) { beeContext = beehive()?.getBeeContext() } } - 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())) + /** + * 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() 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 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 var lastSyncedTargetPos: BlockPos? = null + + private fun syncTargetPos() { + 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) @@ -275,6 +405,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) @@ -283,6 +414,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 +448,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) @@ -333,16 +476,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) { @@ -367,7 +510,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) @@ -376,7 +519,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 @@ -384,6 +527,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) { @@ -395,7 +545,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 @@ -407,13 +557,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 938cdac..a77f1b3 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,41 @@ 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 + // Shadow radius for the bee 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 + + // 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..03ef1cd 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,61 @@ 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 — return to hive or drop as item + val beeItem = beeItemStack() + 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() } + 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 +250,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/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/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..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 @@ -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,44 +30,54 @@ 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 = mc.isPaused + BeeClientTracker.setPaused(paused) + if (!paused) { + profiler?.push("cbbees_beeClientTick") + BeeClientTracker.tickClient() + profiler?.pop() + } + 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) + profiler?.push("cbbees_targetLines") 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) } + 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 new file mode 100644 index 0000000..f40cbae --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/bee/client/BeeWorldRenderer.kt @@ -0,0 +1,173 @@ +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("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 + fun onRenderLevel(event: RenderLevelStageEvent) { + if (event.stage != RenderLevelStageEvent.Stage.AFTER_ENTITIES) return + + val mc = Minecraft.getInstance() + val level = mc.level ?: return + val profiler = level.profiler + 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 + + profiler.push("cbbees_beeRender") + 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() + profiler.pop() + } + + private fun renderModel( + poseStack: PoseStack, + bufferSource: MultiBufferSource, + modelRes: ResourceLocation, + textureRes: ResourceLocation, + time: Float, + ) { + val bakedModel: BakedGeoModel = GeckoLibCache.getBakedModels()[modelRes] ?: return + val renderType = RenderType.entityCutoutNoCull(textureRes) + val buffer = bufferSource.getBuffer(renderType) + val packedLight = FULL_BRIGHT_LIGHT + + // 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 + + bakedModel.topLevelBones.forEach { bone -> + renderBone(poseStack, buffer, bone, packedLight, wingAngle, gearAngle) + } + } + + private fun renderBone( + poseStack: PoseStack, + buffer: VertexConsumer, + bone: GeoBone, + packedLight: Int, + wingAngle: Float, + gearAngle: Float, + ) { + if (bone.isHidden) return + + poseStack.pushPose() + + val px = bone.pivotX / 16f + 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)) + } + + // 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 matrix = poseStack.last() + bone.cubes.forEach { cube -> + cube.quads().forEach { quad -> + val normal = quad.normal() + quad.vertices().forEach { vertex -> + buffer.addVertex(matrix.pose(), vertex.position().x(), vertex.position().y(), vertex.position().z()) + .setColor(1f, 1f, 1f, 1f) + .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, gearAngle) + } + + 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..136a960 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/bee/flight/CheckpointActions.kt @@ -0,0 +1,350 @@ +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 + + var allGathered = true + items.forEach { item -> + if (bee.isInventoryFull()) return@forEach + val searchStack = item.copyWithCount(1) + 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 + } + } + + 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 + } +} + +/** + * 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 + private var failTicks = 0 + + override fun onArrival(bee: ServerBeeData, level: ServerLevel, gameTime: Long): Boolean { + 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) { + failTicks = 0 + val drain = if (beeAction is RemoveBlockAction) CBBeesConfig.springDrainBreak.get() + else CBBeesConfig.springDrainPlace.get() + bee.consumeSpring(drain) + + 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. */ +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 -> + 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 + 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 + } + 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(), bee.hiveId) + 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..e9372d0 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/bee/flight/FlightPlanComputer.kt @@ -0,0 +1,390 @@ +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) + 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 { + val finalCheckpoints = insertObstacleWaypointsFromSnapshot(rawCheckpoints, collisionSnapshot, level) + val plan = FlightPlan(bee.id, bee.type, DEFAULT_SPEED, finalCheckpoints) + 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) ?: return null + val checkpoints = if (level != null) insertObstacleWaypoints(raw, level) else raw + return FlightPlan(bee.id, bee.type, 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, bee.type, DEFAULT_SPEED, checkpoints) + } + + private fun buildRawConstructionCheckpoints(bee: ServerBeeData, batch: TaskBatch, network: BeeNetwork): List? = buildList { + add(Checkpoint(bee.blockPosition(), FlyThrough)) + val missing = computeMissingItems(bee, batch) + if (missing.isNotEmpty()) { + 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, bee.hiveId) + val dropPos = dropPort?.pos?.above() ?: task.targetPos + add(Checkpoint(dropPos, ExecuteBeeAction(action, task), clientPauseTicks = DROP_OFF_PAUSE_TICKS)) + lastCheckpointPos = dropPos + } + + is RemoveBlockAction -> { + add( + Checkpoint( + task.targetPos, + ExecuteBeeAction(action, task), + clientPauseTicks = action.getWorkTicks(bee.getBeeContext()) + ) + ) + lastCheckpointPos = task.targetPos + } + + else -> { + add(Checkpoint(task.targetPos, ExecuteBeeAction(action, task))) + lastCheckpointPos = task.targetPos + } + } + } + // 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())) + } + + 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..41fa48e --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/bee/server/BeeWorker.kt @@ -0,0 +1,52 @@ +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 + /** 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 + + 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..bf601fc --- /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, + override 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..d65d24e --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/bee/server/ServerBeeManager.kt @@ -0,0 +1,383 @@ +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.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 +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() { + val currentLevel = level + for (bee in bees.values) { + 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. */ + private val maxCheckpointsPerTick: Int get() = CBBeesConfig.maxCheckpointsPerTick.get() + + 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 + + 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("arrival") + 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() // arrival + + 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 { 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) { + 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 -> + 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 + 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 + } + 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..756932b --- /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(), bee.hiveId) + 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(), bee.homeId) + + 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/MechanicalBeehiveBlock.kt b/src/main/kotlin/de/devin/cbbees/content/beehive/MechanicalBeehiveBlock.kt index 00dcbe1..f3af963 100644 --- a/src/main/kotlin/de/devin/cbbees/content/beehive/MechanicalBeehiveBlock.kt +++ b/src/main/kotlin/de/devin/cbbees/content/beehive/MechanicalBeehiveBlock.kt @@ -7,16 +7,10 @@ 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.Block 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 { @@ -29,43 +23,7 @@ class MechanicalBeehiveBlock(properties: Properties) : KineticBlock(properties), return Axis.Y } - @Suppress("OVERRIDE_DEPRECATION") - override fun onRemove(state: BlockState, level: Level, pos: BlockPos, newState: BlockState, movedByPiston: Boolean) { - if (!state.`is`(newState.block)) { - withBlockEntityDo(level, pos) { be -> - for (i in 0 until be.beeInventory.slots) { - val stack = be.beeInventory.getStackInSlot(i) - if (!stack.isEmpty) { - Block.popResource(level, pos, stack) - } - } - } - } - super.onRemove(state, level, pos, newState, movedByPiston) - } - - 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 0af10ba..feb52e0 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,59 @@ 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 (getActiveBeeCount() >= getBeeContext().maxActiveBees) return false - this.setChanged() + // Pickup batches use bumble bees; everything else uses construction bees + val beeItemClass = if (batch.beeType == BeeType.TRANSPORT) + MechanicalBumbleBeeItem::class.java + else + MechanicalBeeItem::class.java - // Only consume regular Mechanical Bees for construction batches — - // Bumble Bees are transport-only and dispatched by TransportDispatcher - val beeItem = consumeBeeOfType(MechanicalBeeItem::class.java) + if (getAvailableBeeCountOfType(beeItemClass) <= 0) return false + + this.setChanged() + 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 +159,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 +269,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 +407,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..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,119 +118,147 @@ 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 candidateNetworks = 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 } - }.sortedBy { network -> - network.hives.minOfOrNull { it.pos.distSqr(batch.targetPosition) } ?: Double.MAX_VALUE + 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 targetNetwork = candidateNetworks.firstOrNull() ?: 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 - @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 - fun dispatchNewJob(job: BeeJob) { + /** + * 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. + */ + override 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/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/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..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 @@ -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, worker.hiveId) + // 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, worker.hiveId) + 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..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 @@ -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,11 @@ class PlaceBlockAction( } } - consumeItems(bee) + 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, @@ -68,7 +72,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..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 @@ -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, worker.hiveId) != 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..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,14 +1,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,13 +29,34 @@ 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 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 = @@ -52,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/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..c743ad6 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/domain/job/client/JobProgressToast.kt @@ -0,0 +1,139 @@ +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() + + /** 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..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 @@ -29,29 +29,45 @@ 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 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() } } @@ -59,13 +75,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 +90,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 +97,20 @@ 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() + 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? { 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 +137,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..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 @@ -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 @@ -188,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? { @@ -195,19 +233,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 +258,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 +292,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 +305,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..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 @@ -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,23 @@ class TaskBatch( } } - fun assignToRobot(bee: MechanicalBeeEntity) { + /** + * 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 = 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 new file mode 100644 index 0000000..c4f1c91 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/drone/DroneViewManager.kt @@ -0,0 +1,147 @@ +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) ?: return + + val beehiveItem = backpack.item as PortableBeehiveItem + + // Check upgrade + if (beehiveItem.getUpgradeCount(backpack, UpgradeType.DRONE_VIEW) <= 0) return + + // Check bees + if (beehiveItem.getTotalRobotCount(backpack) <= 0) 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/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 new file mode 100644 index 0000000..43f9e04 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/drone/client/DroneViewClientEvents.kt @@ -0,0 +1,248 @@ +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 + + /** 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) { + // 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 + + 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 (up) dz -= 1f + if (down) dz += 1f + if (left) dx -= 1f + if (right) 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)) + + // 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) + } + } + + /** + * 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 + // 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() + } + } + 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 + prevUp = false; prevDown = false; prevLeft = false; prevRight = false + } +} 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/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/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 b776fdf..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 @@ -1,8 +1,11 @@ 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 @@ -26,11 +29,12 @@ 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) { 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 +43,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()) } } @@ -115,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 33292dd..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 @@ -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 293e6a6..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 @@ -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) @@ -414,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 @@ -440,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/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/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..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,273 +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.content.deployer.SchematicProgram 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 + private val handler = AreaSelectionHandler( + keyPrefix = "cbbees.deconstruction", + isActive = ::isActive, + createPacket = { pos1, pos2 -> StartDeconstructionPacket(pos1, pos2) }, + createProgram = { pos1, pos2 -> SchematicProgram.Deconstruction(pos1, pos2) }, + ) - /** - * Handles mouse scroll input for resizing the selection. - * @return true if the scroll was handled - */ - fun mouseScrolled(delta: Double): Boolean { - if (!isActive()) return false + 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() - // 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) - - 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("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 - } - - 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 c1624b9..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,29 +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 = Component.translatable("gui.cbbees.deconstruction.hint_scroll") + val secondHint = if (second != null) { + Component.translatable( + "$hintPrefix.hint_program", + AllKeys.PROGRAM_ACTION.translatedKeyMessage + ) + } else { + 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 @@ -126,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) @@ -147,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 @@ -164,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(), @@ -172,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, @@ -188,10 +190,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/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 new file mode 100644 index 0000000..309ec01 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/schematics/client/PickupHandler.kt @@ -0,0 +1,31 @@ +package de.devin.cbbees.content.schematics.client + +import de.devin.cbbees.content.deployer.SchematicProgram +import de.devin.cbbees.items.AllItems +import de.devin.cbbees.network.StartPickupPacket +import net.minecraft.client.Minecraft + +/** + * Client-side handler for the Pickup Planner tool. + * Delegates all selection logic to [AreaSelectionHandler]. + */ +object PickupHandler { + + 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) = 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) + } +} 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..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 @@ -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 @@ -301,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 da8a1ac..1c936db 100644 --- a/src/main/kotlin/de/devin/cbbees/content/upgrades/BeeContext.kt +++ b/src/main/kotlin/de/devin/cbbees/content/upgrades/BeeContext.kt @@ -3,20 +3,26 @@ 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 + 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..ef5f0b5 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,40 @@ 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 -> - ctx.maxActiveRobots += count * CBBeesConfig.swarmIntelligenceBeeBonus.get() + SWARM_INTELLIGENCE(3, "tooltip.cbbees.upgrade.swarm_intelligence", UpgradeShape.T_SHAPE, IUpgrade { ctx, count -> + ctx.maxActiveBees += 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() + }), + 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 +85,9 @@ 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 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..384addc --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/content/upgrades/UpgradeShape.kt @@ -0,0 +1,85 @@ +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)) + + /** 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 ed05550..8ad7529 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,7 +90,26 @@ object AllItems { .properties { it.stacksTo(1).rarity(Rarity.UNCOMMON) } .register() - // Stinger Planner - select areas for removal (alternative to schematic-based deconstruction) + // Pickup Planner - select areas for item collection by bumble bees + 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() + + // Deconstruction Planner - select areas for removal val DECONSTRUCTION_PLANNER: ItemEntry = CreateBuzzyBeez.REGISTRATE .item("deconstruction_planner") { props -> DeconstructionPlannerItem(props) @@ -109,6 +130,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 @@ -227,6 +257,66 @@ 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() + + // 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..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, @@ -78,5 +83,72 @@ object AllPackets { RequestPlayerJobsPacket.STREAM_CODEC, 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, + GridPlaceUpgradePacket.STREAM_CODEC, + GridPlaceUpgradePacket.Companion::handle + ) + registrar.playToServer( + GridRemoveUpgradePacket.TYPE, + GridRemoveUpgradePacket.STREAM_CODEC, + 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, + 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/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 4480d76..5bbf90e 100644 --- a/src/main/kotlin/de/devin/cbbees/network/CCRServerEvents.kt +++ b/src/main/kotlin/de/devin/cbbees/network/CCRServerEvents.kt @@ -1,10 +1,16 @@ 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 import net.neoforged.neoforge.event.entity.player.PlayerEvent import net.neoforged.neoforge.event.server.ServerStoppingEvent import net.neoforged.neoforge.event.tick.ServerTickEvent @@ -18,35 +24,77 @@ 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) { + val server = net.neoforged.neoforge.server.ServerLifecycleHooks.getCurrentServer() ?: return + val overworld = server.overworld() + val gameTime = overworld.gameTime + + 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") + ServerBeeManager.init(overworld) + 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) { + tickCounter = 0 - // Core logic every 10 ticks (0.5 seconds) - if (tickCounter < 10) return - tickCounter = 0 + profiler.push("networkPurge") + ServerBeeNetworkManager.getNetworks().forEach { it.purgeStaleComponents(gameTime) } + ServerBeeNetworkManager.rebuildIndexes() + profiler.pop() - val server = net.neoforged.neoforge.server.ServerLifecycleHooks.getCurrentServer() ?: return - val gameTime = server.overworld().gameTime - - GlobalJobPool.tick(gameTime) - TransportDispatcher.tick(gameTime) - ServerBeeNetworkManager.getNetworks().forEach { it.cleanupReservations(gameTime) } - - // Sync packets every 40 ticks (2 seconds) to reduce network and serialization overhead - syncCounter++ - if (syncCounter >= 4) { - syncCounter = 0 - for (player in server.playerList.players) { - HiveJobsSyncPacket.sendPlayerSnapshotTo(player) - NetworkSyncPacket.sendTo(player) + 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 } /** @@ -55,20 +103,35 @@ 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. */ @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() 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/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..b66ea92 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/network/GridRemoveUpgradePacket.kt @@ -0,0 +1,70 @@ +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.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/HiveJobsSyncPacket.kt b/src/main/kotlin/de/devin/cbbees/network/HiveJobsSyncPacket.kt index 57f38a3..f2003c1 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, @@ -278,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/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/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/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/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/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 bb41750..18c77b7 100644 --- a/src/main/kotlin/de/devin/cbbees/ponder/CBBPonderPlugin.kt +++ b/src/main/kotlin/de/devin/cbbees/ponder/CBBPonderPlugin.kt @@ -36,6 +36,14 @@ 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.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) }) + } 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..946124f --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/ponder/DeployerScenes.kt @@ -0,0 +1,269 @@ +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 { + + /** + * Chapter 1: What is the Schematic Deployer? + */ + 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) + + // Introduce the block + scene.world().showSection(util.select().position(deployerPos), Direction.DOWN) + scene.idle(10) + + scene.overlay().showText(80) + .text("The Schematic Deployer executes programmed jobs automatically using a redstone signal.") + .placeNearTarget() + .pointAt(util.vector().blockSurface(deployerPos, Direction.WEST)) + + scene.idle(100) + + // 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(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 the Deployer with a Programmed Schematic to load it.") + .placeNearTarget() + .attachKeyFrame() + .pointAt(util.vector().blockSurface(deployerPos, Direction.WEST)) + + scene.idle(100) + + // 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 job. The Deployer activates on a rising edge.") + .placeNearTarget() + .attachKeyFrame() + .pointAt(util.vector().blockSurface(leverPos, Direction.WEST)) + + scene.idle(60) + + scene.world().modifyBlock(deployerPos, { state -> + state.setValue(SchematicDeployerBlock.POWERED, true) as BlockState + }, false) + + scene.idle(20) + + // 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) + .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 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) + + // 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() + .pointAt(util.vector().blockSurface(deployerPos, Direction.UP)) + + scene.idle(100) + + scene.markAsFinished() + } + + /** + * Chapter 2: Deploy Modes — Absolute vs Relative. + */ + fun deployModes(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) + + 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 has two deploy modes. Open the GUI by right-clicking without an item.") + .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 always 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("No matter where the Deployer is placed, the build happens at the same world position.") + .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) + + scene.overlay().showText(80) + .text("Relative mode also lets you override rotation and mirror from the GUI, without reprogramming.") + .placeNearTarget() + .attachKeyFrame() + .pointAt(util.vector().blockSurface(relativePos, Direction.UP)) + + scene.idle(100) + + 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/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 cea06bc..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,8 @@ 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 import net.minecraft.network.codec.ByteBufCodecs @@ -21,6 +23,18 @@ object AllDataComponents { .networkSynchronized(ByteBufCodecs.VAR_INT) } + val UPGRADE_GRID: DeferredHolder, DataComponentType> = + REGISTER.registerComponentType("upgrade_grid") { builder -> + builder.persistent(UpgradeGrid.CODEC) + .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 ca4dbd1..bb9baea 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,51 @@ 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}" + ) + + /** + * 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 + */ + 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 +121,10 @@ object AllKeys { event.register(OPEN_SCHEMATIC_BROWSER) event.register(ROTATE_PREVIEW) event.register(MIRROR_PREVIEW) + event.register(DRONE_VIEW) + event.register(PROGRAM_ACTION) + event.register(SCHEMATIC_MODIFIER) + event.register(FREE_AIM) } } 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/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,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAQAElEQVR4AexbCXxVxdX/z31r9j0sCSRACCBb0iAiVaugESpBQRQEwajIrrZW/RS1oiK20k/cAAGj9GMXLWCIIoIgikrgNWAWIAsQErLvyUveljffnJvc+LK8EJoQbH+9v/zvzJw598zMmZkzZ2ZeJDh53jl6km89dYbvSsuQ8Q8RrvzWINN2n87kP+Re4t/n5PIdP6fzjw1p3ImYZuS5c+fzefMW8iVLnuQUf+ihR/nChY936Ntmgq4w8XDsIzxWgMKHRZmOnztVgItGhZkjB7Mp1w2UMVWEIb7u0Kol3DMkjI0NDmI39Qlm00eEM0+9xlGm07jdbkddXR1KSkogSRJsNiuqq6uc8nckY/Gdg/ntw3vJWHDHYP7Y+HD+yG0DmymVEmq1FmqNDkylaiZW2r90AneEkuuua92osqpq9Pf3VFhwsqiQZKN/wC+0psw2Inq9Hj4+PvD29obRaIRWq4Wrqys681TVWaFWSTBZ63GhuBqUvlRmbCaSelliDHZ7PcAFHHKl6BX7mCOUPEliSrQp9PX0QPKlMjlNjY8I7CEzyS+Z2vq1YMFiPm3aDL5o0eO8srISVVVVqKmpASlDo9GAMQmxsY/y2bNj+YwZs2SFtpbinMI5R4i/O/RaFertHDYBDzEi5z82j8+bKzBvEVdrdYBQgEqlhkqtwbzHFvJHHpnHaTqQctDWU2extSJnl9XAaLbKdKXxlEjLL6egTXzwwWr26afb2Zo17zEXFxe512kEMMZgNlsgSRI2boxjmzZtZNu3b2lPl23K14ghTQ2nRus0EvQCZpsddi6axlTUbvFdg1iaggADaZnKhXgEl3i38VdSY0LciRT+9uGf+EZDMv/o2Cn6TnAybEhM5vFns/jhCxf5Z8I4erloBf3yf1SBrrYBoj3ILKxEYWUdbPUctWYbqk0WbPjwA7Z+w1q2bt0atn59Az4UtA2CRqD4x3+PY9JnqRmcrDoN6YPns/n+rAucLL6bVg2NmAYhgX4YGOiL64IDMKy3L/r6uiHUzwN9fNzh7arDAH9PBHm7gWQkFRbwrYZTnGQlXrrEP/s5tVFpDcqhYd/VNsBkqYdF9DgTmjDb6sUItYlRoGoosANvaYAwYGTVaUiP7xfCogeEMjKAvbzcMOc3Q+UV4Ld9gtiYoN6sp6crxMjFHYKH+BVQOa6izMgePdnMqJGM6KODgpjJYkVX2YB7ru/LJ0YGy1Y+9taBfOroED4pKphvO3qO/XC2mB09W8QOpRSw/afy2L6TeWyBWBHuGxPKCRQntFwdqN4SWU+KUK9RSCivNaOmca470qnx7jotschQ8rKFbVCrhQZkKqDQNWJ+kg3Q63WoFAZwy5b/YzTfN2/+O4uLWy+G5loxRFfLtMvZgN3HL7Ivk3LZhoPpbOPhDPaPxGy215DLGotsFXzw9Rm286cLMihO+OhQRit+qazWJH9MvaZUnIZ3oIdLK3qOaCjZBsogXvqG4hVCYa5aDUXlxit0P083kLPj4eEJtbC+itPz5uSR/LlbwmWsuGs4f3XC0GZLsbIsywKv8kvKLa9tKkKpeLEwgI6GTaHTEuPvrpf5FRolMnNyUVDe4NA40i1iTirOjhISf15BFaxi3ZaEjamsMgmfwIJosRwfPJKFpOQ8pJwphOHUJbw1JbKZDaFvuxqSu06NT1PSZcNFvZqQfo6brTbQNKD0rrRMYRQzhTucySvqzDhXWoXvLuZyQ0EBX/7V9/zl+IPcXl+PPgE+2CYM4M6Tyfyr9Ex+6Nx53svHU172yNlxdHq8PPXQaFRiCWRwEWs2Neqvk0fy8bcMQOTw3hg2uAeiRgZh2JAeoNFA+YRnbxrICRTvKkjk7k4bFs6o5wh3hfdn9w8fxAYeexb+iUtxQ87fcJtxA0YkLcXNp5fh5rRl6Pfzy+h1ahkW+8ZjTtU6PFz7EaJ69mIPCAN4X8Rwdmd4GLutfz9ZpuLsKCFVXKtVw91NC08PHdzdtWASUFRSQ1k4mpiNo8eycSajGMlphUg9W4SXxg3mj48KlUfD7UJJf40ZIcflDzr5EkW3LcE7NBiTFu3E7D99hrvnfIh7n98HohHaorctBU3OjuL0pMZN5VPvC0fM5DDE3B2GOyf2w6yZQ+Ht3mBztGJkTJ02CLfe3gfRk0IRPTEUU6YOgp+/C6xSQ7tVWoaueiRngurKKhDoo0eAtx5B/m7wdNXCWFQGZ3RncpS5r4RrPk3B8o8NeG2TAcs3GvDG5iThnqpQ17jqQIIYEQwvfWjAM+8fwx/e+xGrd6chINANXLi5EE9dXWsvVZD/pT9RXNvfeYUEgZwM8q6IQ6uR0GPEIDijE09bUOa+EpqtHJW1FlgavTayNybhdquEQaTvrRY7VComvDo76oShtAknp6CsDrVGK6ycE0un0PJjpwqoOJ+L6lorKo0WlFaZUVZtRm1JGZzRWwpW0prGDY8S2sWWmIkRXF9vl50q6lS7eJFdoG8oTjbBLhJasfX2cNGgX4AHzMLj89CoBRVCQU6rLedfycupJEuNEV9+NBs71s/BtrWz8MnyaJRlZsMZ3Vmh69evZUZjjVjqjFi3bjV7ZnYEXn10NFY9PhYr5t+ANwT08k6ucZsqermqyoI/z4rAm/NHY1lsFB6ZFA6b1Q5yeakcvb5BERTvLKTMzffxtlBZZgU3VqIuPw+mogKUF5vBOYPfiLFQW6tRmpENW3kJvIJDhHEcgOJjK3ipYaXA33jRj6/x9E3TeMGBRbylIzQ4tFTzybY0xMWdwuZNqdiyJRVfJJxDjRji1Bga5Ie+zsbX+y8g4fMsfL4nA3v2pMNYbQEXSiCegvyGFYPinYWUW1SD+17cj2kvNIDiJLRnXzc8+NI3uOupfZj0x31YuOaYmJccFaePI2ZJAu5/6WtMfnIvJs2NgzH/AvRu9Yh5cCXGT3kV0dNXYobIt9aKHVrjqY+tMTSc8bQWmEzItZlwj1gNnnh9DhY+NQpFehvWHUrB0y+PRcTwIPz5q1Q2MPYFLPs6jb1y4DRbeTSTrTp2Tj67ePP7DEZ17ApI7mKO0VBcOiUSb8y5Af8bOxZlwuhUlJiw/KEoJLwzEfHvTsTeVdHy3HMJ8MXeHfOx/S/R2PDc77Bj7QIwvQ/qSouxYt4ovPXMrYh7Phrrn74NxnIjFOOnhFTpIrNZtim0j6d0VnopcgqNyCupRdbZUrh5aIiM6TMeYJnpSTQo8MmObXIoZ3ThSxq18HM28fn97P63D7OJr+1n45Z9yUY/sZcNnfsZu/GpeBY0eQsLjtnCek7axMJn72A9x73P/KJWsEEzPmEj5u5ioXe8wfrGrGXn4gsQWOKDngUeMKaYMGrJbjb4oU+YYvyUkOpeKwya0dw45wWB5revlxa9A1xgr+e4OSZEUIGzp0/w3j39cS7jJL9/+gNd1uuy8MaX1Bh2SVBSUQlChTinUwQqDpASEr23jwv6+rtCWfqIppEkcf4gwS98BODXB1u3buKDhoxil/KK0H9gxFVpPMTTaQWkvTCL57y6hAd5BsLf2wtaF1eE9fIH0Y8snsRtjXNfCUWZEJ2MOrEMUrwleg67HlDrMHPmbLnRAwdHse1bt1yV4U9lS/TqLKrN5SBUNXpzNAoINBKUua+EVFZNtRXlwq+guIL80loUVwhfo6wIsNQpZDmcMXOWrAw50cUvab/YucWnneG7klP5gcws/sXpdHGUlcI3Jf6THz5/gdPujsLdyWmceHeJY64tJ07xLcdP8s9TT3NqtAJLXS2KyipxqbxGBtVVmftKSDQzsyPA14WiMiSVWl5h5PO8vGzA2lwBMtNVeknRYucWc91gNmX4UHZ72AAWbhiU0OPj4Qneb0YlVD/TLyEyZUSC+t1+CapXhiaYlw5MmDJiKJs1aiSbdX0Emzx0iNwz1HAC9bjRZIabOAEiUJ3fmO+Dp24rxpLRBXh+mieREChOlZjDoB4cMxt9hK8f0sMNJek/40JipszXHa9WU6DAAOSfBkw1gE8foGW6ZaWUhjs2XuHxdtGg5YpBeZKaQeWggNLMFBRVmJBT3HChkX+xIUQ3PK0UUJ4D6N0b0DMKaJluWSdquEJTep0aTlDoLUNJeJTy0GnMsBWlgtIuYiv8w9FL8PDWNuZc/aCVAqjXCcHXN/Q+xQlKur0qUaMJpBQCrQi3j/sdd4T3+AI89ZYn5t47DrZcb2Q8exz575pxMKWYHRB4YrOBDZ+3k/TRXlFdlif2Amzv939ke+OnNYB6naTnHm/o/ZbpzM3N+YmXGk2gODU80NcLBEq3RGl9L7gJH8DnRgNMvgzSTR64lo/Uco5faXryR1+xW1bvbcLbWUYsPZ7XhND+A9E/bBAOfPMtI2TVh6ClEq6mAi4nW2o5x680fbkCysvLxd6irIktWXMRjkqwerFrOgokmt8EZY5TnNDRNC7zWCwWu9lspvMNmTPb1BukhN0SQ4o5Aqv3VuAvqQ1H6jJDN7+km1bxSYTI/2kIKU7oaLoj9aVbIUc+UgIh1RaInOxsVFRUOGZ3a7zVKkClt3VA0haNeA3v3sMdsfJefzgiPj5edeTIERXxEraOXMMUvBC5nBmSktjhw4e7zepTHRzRpgKI4cUPfkJ7IJ7/BDhVwH9C4zrSBqcKWL5gDNpDR4T/O/DICjixdjJ3BB2IdgT5CQ9yJqz5kCh/XHe9P7x8NU0IjorA0OmxMJfEy4euhnfu5vtfmMCPLP+9wy7g2qtIoirQsZgjop7YzRQsef8oFhPeO4p573wHSsftPYMN8WfwytsnsH5PGl5bmQiXGz9kYQ/ubEKP3y5j+h73MJ1/jEyLenIPi359H7vlxS8YlRkZ6s0j+/nyyBAvvn/phCZQXndCVkB7BdJFhehk0AmeJLYsHFzc7FiRKa64z+ZVICO/CobzJe2JaDuPSejlqwMj4W1zdAtVulwpdE2lF7s0d50KapUEnVqNMqMZdt4wkulkV4lfTlazfG6HWjRexVgzcncnLqsAuo2pNlkb7wnrIYkKS6LOXuKyNMjXFXYxInzcdFdebyHHauNgogbPbU/EmgPJeG5b4pXL6eQXovj2JahEa6mzmWg0F69ai1VcWtrgopVQK462ld/ntS+ljVwh1EUnihehkiuKUKLdFooatF8W1S8y1A+P3zkM/QPc4apVg34eV1hpgjjJhqtODccfTrUvrXlunbgJttqZPKogHi7Q3X/S5QqsFxoY1Msbfu46eLnqRO/Xo7TahBoxLfLKa0E2wGIjE4krfnRqUbyQr3xIo02Jd1coatB+UdTjZ/MrsPPYOXFya0d9fUM/aVQStGoV6H6fbET7UtrOpZ+0ihkmG1TSA42otjk7Tr1STml0mL+8HkcN8ONjBwXwMeEB/OYhPfj44b34TSK0WOuRdKEMKTnlSMktRx8/NzQ0mImpoIWrVosCMRKmjA7h9GNG+kHi5FF9+LhhPfmEiCB+12+C5XiriokW6zWSWFgh8liFPAAAAgxJREFUbp0FWjF0D6GhBqIsJqqi16iFcVNDp1GJFAP9OoP6m36RbRE3OfUCBZV1YvmSRO9L8n099SBNk3KjSe7JPHEhWlFrEY3iqKg1NykLLR9hUIlE8iWKiwgjQjdDSswoYUnny9iJrBL2TUo+OyRw4Oc8diA5j/2UUcwSM0uY4VwpM2SVsn8Kvh/OFsk04v0iKZft+PG8/FPVw6mF7PMTOTKOpBWyQyL9U3oJozjxtmqXGAEKjRquUimp7g2l7i3ul9JWPX0zFs8YiT/MHInli0dj2aIxWLFkzC8M3RTrMgXIfj359h2AIW4KLxEXIb3E6XBYX29xnVaL2JcP4E+rvkfTHqEdORMigoWdCuS/jwwWE6dzmuoyBdA06iiiHt3Fpr10gA2d/Sm7cf4eNuf1b1nShcpGVMhTsj1Z+07msu9OFzGagp1rPtBlCuhsRa7V9/9VgDPNT4gM4vfeEMof+l0Ynz42tNNzzVk515rudAQYTTaxzteLe/5aETYd61/r+nZ5+U4VoFYx2dnRCpeXdXmxvx6BkrOqWG12lNWYYTRb4f2v7PedCf6V0Z0qgCY9ubhW4f5mFV67q6urrS+nCtCpVWI/AHlv4OnSfT9YuNoNbinfqQICvVwQ1sMLB5PzWbwhp5UZmHPLAPnf1loK/HdLO1WAm06Ni6U1TttTVWf9VawOTivYwYz/BwAA//8jmDrUAAAABklEQVQDAMPwgY5Pxl57AAAAAElFTkSuQmCC"}],"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/lang/en_us.json b/src/main/resources/assets/cbbees/lang/en_us.json index e2f822a..13bdd97 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", @@ -76,9 +78,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.", @@ -91,6 +93,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!", @@ -100,10 +104,12 @@ "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.deconstruction.title": "Stinger Planner", + "cbbees.progress.processing_schematic": "Processing schematic...", + "cbbees.progress.processing_area": "Processing area...", + "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", @@ -113,8 +119,23 @@ "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.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", @@ -164,8 +185,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", @@ -206,7 +228,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", @@ -217,10 +238,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!", @@ -249,7 +276,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", @@ -266,6 +293,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_.", @@ -300,7 +336,97 @@ "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.not_found": "Job not found" + "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", + "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 | 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.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", + "key.cbbees.free_aim": "Free-Aim Selection", + + "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 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 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/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/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/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 0000000..f812481 Binary files /dev/null and b/src/main/resources/assets/cbbees/ponder/schematic_deployer/automation.nbt differ 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 0000000..7582999 Binary files /dev/null and b/src/main/resources/assets/cbbees/ponder/schematic_deployer/intro.nbt differ 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 0000000..521f5af Binary files /dev/null and b/src/main/resources/assets/cbbees/textures/block/schematic_deployer.png differ 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 0000000..4db670d Binary files /dev/null and b/src/main/resources/assets/cbbees/textures/block/schematic_deployer_powered.png differ 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 dd7daf3..4fe1ec6 100644 Binary files a/src/main/resources/assets/cbbees/textures/gui/portable_beehive.png and b/src/main/resources/assets/cbbees/textures/gui/portable_beehive.png differ diff --git a/src/main/resources/assets/cbbees/textures/gui/sprites/toast/background.png b/src/main/resources/assets/cbbees/textures/gui/sprites/toast/background.png new file mode 100644 index 0000000..0963134 Binary files /dev/null and b/src/main/resources/assets/cbbees/textures/gui/sprites/toast/background.png differ 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 0000000..55c8abd Binary files /dev/null and b/src/main/resources/assets/cbbees/textures/item/pickup_planner.png differ 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 0000000..64692d7 Binary files /dev/null and b/src/main/resources/assets/cbbees/textures/item/upgrades/drone_range.png differ diff --git a/src/main/resources/assets/cbbees/textures/item/upgrades/drone_view.bbmodel b/src/main/resources/assets/cbbees/textures/item/upgrades/drone_view.bbmodel new file mode 100644 index 0000000..df8165c --- /dev/null +++ b/src/main/resources/assets/cbbees/textures/item/upgrades/drone_view.bbmodel @@ -0,0 +1 @@ +{"meta":{"format_version":"5.0","model_format":"image","box_uv":false},"name":"drone_view","visible_box":[1,1,0],"variable_placeholders":"","multi_file_ruleset":"","variable_placeholder_buttons":[],"unhandled_root_fields":{},"resolution":{"width":16,"height":16},"elements":[],"groups":[],"outliner":[],"textures":[{"name":"upgrade_base.png","relative_path":"upgrade_base.png","folder":"","namespace":"","id":"0","group":"","scope":0,"width":16,"height":16,"uv_width":16,"uv_height":16,"particle":false,"use_as_default":false,"layers_enabled":true,"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":"46d5156e-c3b2-f08a-87d8-4f60fd322b93","layers":[{"name":"layer","offset":[0,0],"scale":[1,1],"opacity":100,"visible":true,"blend_mode":"default","width":16,"height":16,"data_url":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAACiklEQVR4AZxSS2sTURT+5s4jMzdJk4xKjNUWWvoCRVpbCy50p+BWEPwLoqUoKv6IIurGhYgKIuLGpTtxV0lfNjTS1tKKTZXSpHm1ec3keu5MjS268s6cM8yc73HOYRj2nU83LPF1okd8e3JaJMfDYvK6KZJjXMzdCYnUPYr7YTFzKyj2UXBAQGEq4gO9iB4/2sJ09ocwMBxF75ko2rs5RPMAHy2BKXIcvHYZlh2GwlqfAcI3HQEhBCyuoX8kiuSYRV99jxZSURTIy6k3kFvL+FXKq+kSvkznsTRbwG7ZQa3igDGFKv7tCczctcWpK5fQdBz8SC2hkPkJl4QkRDrXqy4qJQeb67sAddN1MoTkTe514QkIIkpgbm0DW8urqBbLknsgaAKUcg1kN8oobZah6n6ZTY1zEetIIDOdQmY2Tc4OhNv0q3uZpoMMUOdCUWFEYug+14eZ21wwTfeawP8ctyFoH8TnWg4Wy8Kp1f6pwwijqgDnCsz4MbQNXYBmt1NDJODSUux4AIapQtXYXwJ/yEDkCEesrx+BwwkPJ1QDbHBiR1lLlyFnDEU0ryCTfJdk70nuzNDBO3tgUbjFbSy8+4DhB0XfspCtIRTVycGA3IkkaToQMIG2CBAMAod6uxAdOg9G61dMKigM8nh55FFFWZzKg4d0HEoYMAwitgERIpuWilBHB+zRi1BItVHOI/XiDc4+LEkf1xOQSjoBA5zBjpvoHAjDTljeWMzQYMVPgMl2XIdEOBqVKn6flsD8QoGnJ7e9RZpc9+rSQg+GoYVjcKsVuDsFLLx8hdHHO/RHeBC0BK6+RUWhra2kilj+XMD6Sg3ZLeD7Yh5L7z9i/ulzzD17jWa97jP38i8AAAD//+kEbY8AAAAGSURBVAMAiXrgNXeYSJYAAAAASUVORK5CYII="},{"name":"layer #2","offset":[0,0],"scale":[1,1],"opacity":100,"visible":true,"blend_mode":"default","width":16,"height":16,"data_url":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAoElEQVR4AczPuw3CQBAEUJuUUiiHOqmBKojogYzEBKAXrHS7Xpw4saWRb766O007v4MOXC/nb4futasnKN4e77kDr46kAQHFCOEQnDdyehogQA390+jtwGtZpnHEmaZQ0Q7cn595LDjTahlvBxgKiuBM65AGhF03gooQnCcT3D8NCAsIduDJKAbSAFFAsANPZsRqgCnYgVfRDtTQFt898AMAAP//dvJOSQAAAAZJREFUAwD2eJAhcqWBuQAAAABJRU5ErkJggg=="}],"source":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAACrUlEQVR4AVxSX0hTURj/3Xu2uZ1tOUdhYhQkmUIRlhb0kPUg9uclCAIxiIgko0SUqRH0UFD+wcKE6CHMhIooqcdRqdmTMRNzKFgJRfUSqdvc3Nzu3emcc91l61y+757zne/3+77fx1GRtT5edrBvvTvYjwd7WKDZzSYu2VmgibJpn4sF27lddbOpFifLgiCHQFEJCstL4dmy2czZVuZCeaUHpfs8KC6hYOkcPEyCSV6xou44HF43FNUMAzw/rTEwxuCgFpRVeRBocvCoUcPMVBQF4tOSKZzzfUDn2zR6xgka78dw5lYI9TdCWI1qWItrUFXFQHMvCabavGz3qVqkNQ31jX5cP+mEr1rPsbbDOhr6ogDvZvsuFwJXqOxCEjAOFC3WXfSjo0ZBIsITObvoQBjfcglA+xEdF/pXsfInCmIVUUCdbKasYGsRfn8Kyoie1NA9qsh9xnF1Ug7XKEO2/AKUHNyJqVbKVItVNoHstZRMGoD1YPd7AhFbP5o/PcX4PDieWpbgUBfNi9s1uQAB7jlGQKnR2Ya91bB4i3lDnEDnQ/EW5sFmJyaB2GRIBLj3hAAD+ZuouELexiL5Z8QGtaI3pnyfi0LoFMmZoYlzVy1B91EClXOrNiuaHyWkFD2yjNnXY6i8G7Gogiq8uAaXx4qnTfkyQZAI3V1jBJ2jBDf9BNeG0/Ju5E0rFLsdUCQU0lfdiyvzkyFQlxUD550yUXTzv428a0MqGkLw8XPs71tR+ER0SQC+rA6CPKrCW2jHsM+Dly0uDJ4lGGqgeHXnEEZHOgBdg2KhSMUTyCyTYGY2TOcmlkG4Kjs1XokoYXW6YXEXQE/EocfCmB16ggP9MV7coDAJTr9AXFFVLAQj+Po5jF8La1j8C/ycD+GLfxwzDwcxPfAMaf5GDKjh/wEAAP///ir/qQAAAAZJREFUAwDCgxpESMmXaAAAAABJRU5ErkJggg=="}]} \ No newline at end of file diff --git a/src/main/resources/assets/cbbees/textures/item/upgrades/drone_view.png b/src/main/resources/assets/cbbees/textures/item/upgrades/drone_view.png new file mode 100644 index 0000000..12d36ae Binary files /dev/null and b/src/main/resources/assets/cbbees/textures/item/upgrades/drone_view.png differ diff --git a/src/main/resources/assets/cbbees/textures/item/upgrades/fuel.png b/src/main/resources/assets/cbbees/textures/item/upgrades/fuel.png new file mode 100644 index 0000000..9d870b3 Binary files /dev/null and b/src/main/resources/assets/cbbees/textures/item/upgrades/fuel.png differ diff --git a/src/main/resources/assets/cbbees/textures/item/upgrades/upgrade_base.png b/src/main/resources/assets/cbbees/textures/item/upgrades/upgrade_base.png index 5e63db0..64692d7 100644 Binary files a/src/main/resources/assets/cbbees/textures/item/upgrades/upgrade_base.png and b/src/main/resources/assets/cbbees/textures/item/upgrades/upgrade_base.png differ diff --git a/src/main/resources/cbbees.mixins.json b/src/main/resources/cbbees.mixins.json index d1e30f8..37a7f34 100644 --- a/src/main/resources/cbbees.mixins.json +++ b/src/main/resources/cbbees.mixins.json @@ -10,7 +10,7 @@ "SchematicEditScreenMixin", "SchematicHandlerFindMixin", "SchematicHandlerHudMixin", - "SchematicPromptScreenMixin", + "SchematicToolBaseMixin", "ToolSelectionScreenMixin" ], "injectors": { 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 0000000..4adfc8e Binary files /dev/null and b/src/main/resources/data/cbbees/structure/gametest/cbbees_deconstruct.nbt differ 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 0000000..abfda6d Binary files /dev/null and b/src/main/resources/data/cbbees/structure/gametest/construction/cbbees_construction.nbt differ diff --git a/src/main/resources/data/cbbees/structure/gametest/empty3x3.nbt b/src/main/resources/data/cbbees/structure/gametest/empty3x3.nbt new file mode 100644 index 0000000..e692925 Binary files /dev/null and b/src/main/resources/data/cbbees/structure/gametest/empty3x3.nbt differ diff --git a/src/main/resources/data/cbbees/structure/gametest/logistic_ports/cbbees_filtering.nbt b/src/main/resources/data/cbbees/structure/gametest/logistic_ports/cbbees_filtering.nbt new file mode 100644 index 0000000..ca27932 Binary files /dev/null and b/src/main/resources/data/cbbees/structure/gametest/logistic_ports/cbbees_filtering.nbt differ diff --git a/src/main/resources/data/cbbees/structure/gametest/logistic_ports/cbbees_priorities.nbt b/src/main/resources/data/cbbees/structure/gametest/logistic_ports/cbbees_priorities.nbt new file mode 100644 index 0000000..95a5006 Binary files /dev/null and b/src/main/resources/data/cbbees/structure/gametest/logistic_ports/cbbees_priorities.nbt differ 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 0000000..d625296 Binary files /dev/null and b/src/main/resources/data/cbbees/structure/gametest/portable/cbbees_bees_dont_use_player.nbt differ 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 0000000..aec4a10 Binary files /dev/null and b/src/main/resources/data/cbbees/structure/gametest/portable/cbbees_portable_ignore_port.nbt differ 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 0000000..dc511c8 Binary files /dev/null and b/src/main/resources/data/cbbees/structure/gametest/portable/cbbees_use_logistics_first.nbt differ 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 0000000..ef68e22 Binary files /dev/null and b/src/main/resources/data/cbbees/structure/gametest/transport/cbbees_transport.nbt differ diff --git a/src/main/resources/data/cbbees/structure/gametest/transport/cbbees_transport_filtering.nbt b/src/main/resources/data/cbbees/structure/gametest/transport/cbbees_transport_filtering.nbt new file mode 100644 index 0000000..322f48b Binary files /dev/null and b/src/main/resources/data/cbbees/structure/gametest/transport/cbbees_transport_filtering.nbt differ diff --git a/src/main/resources/data/cbbees/structure/gametest/transport/cbbees_transport_priorities.nbt b/src/main/resources/data/cbbees/structure/gametest/transport/cbbees_transport_priorities.nbt new file mode 100644 index 0000000..f456cfa Binary files /dev/null and b/src/main/resources/data/cbbees/structure/gametest/transport/cbbees_transport_priorities.nbt differ