diff --git a/README.md b/README.md index 44de239..921c868 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,8 @@ A powerful and intuitive WorldEdit-like plugin for the Endstone Minecraft server - **Advanced Clipboard**: Copy (`/copy`), cut (`/cut`), and paste (`/paste`) complex structures relative to your position. - **Reliable History**: Easily undo (`/undo`) and redo (`/redo`) your actions to correct mistakes without hassle. - **Procedural Generation**: Create perfect solid (`/sphere`, `/cyl`) and hollow (`/hsphere`, `/hcyl`) shapes like spheres and cylinders. -- **Cross-Edition Schematics**: Save and load structures to and from `.schem` files. The plugin is designed to handle compatibility between Java Edition and Bedrock Edition formats. -- **Flexible Configuration**: Customize plugin behavior and, most importantly, add custom block name translations via `config.json` to resolve schematic compatibility issues on the fly. +- **Cross-Edition Schematics & Structures**: Save and load structures to and from `.schem` and `.mcstructure` files. +- **Flexible Configuration**: Customize plugin behavior and add custom block name translations via `config.yml` to resolve compatibility issues. - **Granular Permissions**: Fine-grained permission nodes for every command (e.g., `worldedit.command.set`) for precise server management. ## Installation @@ -34,30 +34,28 @@ A powerful and intuitive WorldEdit-like plugin for the Endstone Minecraft server ## Configuration -Upon first launch, the plugin will create a `config.json` file in `plugins/WorldEdit/`. This file allows you to customize the plugin's behavior. - -**Note:** When updating the plugin to a new version, it is recommended to delete your existing `config.json` file. This allows the plugin to generate a new one with the latest default settings and translation rules. - -```json -{ - "async-threshold": 5000, - "particle-type": "minecraft:endrod", - "particle-density-step": 5, - "schematic-path": "plugins/WorldEdit/schematics", - "block_translation_map": { - "cobblestone_stairs": "stone_stairs", - "rooted_dirt": "dirt", - "sugar_cane": "reeds", - "slime_block": "slime", - "oak_sign": "standing_sign", - "oak_wall_sign": "wall_sign" - } -} +Upon first launch, the plugin will create a `config.yml` file in `plugins/WorldEdit/`. This file allows you to customize the plugin's behavior. + +**Note:** When updating the plugin to a new version, it is recommended to delete your existing `config.yml` file to let the plugin generate a new one with the latest default settings. + +```yaml +async-threshold: 5000 +particle-type: minecraft:endrod +particle-density-step: 5 +schematic-path: plugins/WorldEdit/schematics +mcstructures-path: plugins/WorldEdit/mcstructures +block_translation_map: + cobblestone_stairs: stone_stairs + rooted_dirt: dirt + sugar_cane: reeds + slime_block: slime + oak_sign: standing_sign + oak_wall_sign: wall_sign ``` - **`async-threshold`**: The number of blocks at which an operation will be processed in smaller chunks to prevent server lag. - **`particle-type`**: The particle used to visualize the selection box. -- **`block_translation_map`**: A powerful feature for resolving compatibility issues when loading Java Edition schematics. If a schematic fails to load because a block name isn't found (e.g., Java's `minecraft:slime_block`), you can add an entry here to translate it to the correct Bedrock name (e.g., `"slime_block": "slime"`). You can add, remove, or modify these rules at any time to handle new or custom block types. +- **`block_translation_map`**: A powerful feature for resolving compatibility issues when loading Java Edition schematics. If a schematic fails to load because a block name isn't found, add an entry to translate it to the correct Bedrock name. ## Usage Guide @@ -98,11 +96,13 @@ Create perfect geometric shapes. - **/cyl `` `` `[height]`**: Creates a solid cylinder. - **/hcyl `` `` `[height]`**: Creates a hollow cylinder. -### 6. Schematics +### 6. Schematics & Structures Save and load your creations. - **/schem save ``**: Saves the selection to `.schem`. - **/schem load ``**: Loads a schematic file at your location. - **/schem list**: Lists all available schematics. +- **/mcs load ``**: Loads a Bedrock `.mcstructure` file at your location. +- **/mcs list**: Lists all available mcstructures. ## Contributing diff --git a/pyproject.toml b/pyproject.toml index 98d49fe..5516a53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,8 @@ authors = [ ] dependencies = [ "endstone", - "nbtlib" + "nbtlib", + "ruamel.yaml" ] [project.entry-points."endstone"] diff --git a/src/endstone_worldedit/commands/brush.py b/src/endstone_worldedit/commands/brush.py new file mode 100644 index 0000000..c0ff97e --- /dev/null +++ b/src/endstone_worldedit/commands/brush.py @@ -0,0 +1,284 @@ +from endstone.inventory import ItemStack +from endstone.nbt import StringTag, CompoundTag, IntTag +from endstone_worldedit.utils import command_executor +import math + +command = { + "brush": { + "description": "Binds a brush to the current item player is holding.", + "usages": [ + "/brush sphere [-h: bool]", + "/brush cylinder [-h: bool]", + "/brush cube [-h: bool]", + "/brush none" + ], + "permissions": ["worldedit.command.brush"] + } +} + +@command_executor("brush") +def handler(plugin, sender, args): + if len(args) < 1: + sender.send_message("Usage: /brush [args...]") + return False + + brush_type = args[0].lower() + if brush_type == "none": + item = sender.inventory.item_in_main_hand + if item and item.nbt and "worldedit" in item.nbt and item.nbt["worldedit"].value == "brush": + item.nbt = CompoundTag() + sender.send_message("Brush unbound from current item.") + else: + sender.send_message("No brush bound to current item.") + return True + + if brush_type not in ("sphere", "cylinder", "cube"): + sender.send_message("Invalid brush type. Use: sphere, cylinder, cube, or none.") + return False + + hollow = False + filtered_args = [] + for arg in args[1:]: + if arg.lower() == "true": + hollow = True + elif arg.lower() == "false": + continue + else: + filtered_args.append(arg) + + if brush_type == "sphere": + if len(filtered_args) < 2: + sender.send_message("Usage: /brush sphere [-h]") + return False + try: + radius = int(filtered_args[0]) + except ValueError: + sender.send_message("Radius must be an integer.") + return False + if radius < 1: + sender.send_message("Radius must be a positive integer.") + return False + block_type = filtered_args[1] + _bind_brush_to_item(sender, brush_type, radius, block_type, hollow) + + elif brush_type == "cylinder": + if len(filtered_args) < 3: + sender.send_message("Usage: /brush cylinder [-h]") + return False + try: + radius = int(filtered_args[0]) + height = int(filtered_args[1]) + except ValueError: + sender.send_message("Radius and height must be integers.") + return False + if radius < 1 or height < 1: + sender.send_message("Radius and height must be positive integers.") + return False + block_type = filtered_args[2] + _bind_brush_to_item(sender, brush_type, radius, block_type, hollow, height) + + elif brush_type == "cube": + if len(filtered_args) < 2: + sender.send_message("Usage: /brush cube [-h]") + return False + try: + radius = int(filtered_args[0]) + except ValueError: + sender.send_message("Radius must be an integer.") + return False + if radius < 1: + sender.send_message("Radius must be a positive integer.") + return False + block_type = filtered_args[1] + _bind_brush_to_item(sender, brush_type, radius, block_type, hollow) + + return True + +def _bind_brush_to_item(sender, brush_type, radius, block_type, hollow, height=None): + item = sender.inventory.item_in_main_hand + if not item or item.type == "minecraft:air": + sender.send_message("You must be holding an item.") + return False + + if not block_type.startswith("minecraft:"): + block_type = f"minecraft:{block_type}" + + brush_compound = CompoundTag({ + "type": StringTag(brush_type), + "radius": IntTag(radius), + "block": StringTag(block_type), + "hollow": StringTag("true" if hollow else "false"), + "mask": StringTag("none") + }) + if height is not None: + brush_compound["height"] = IntTag(height) + + nbt = CompoundTag({ + "worldedit": StringTag("brush"), + "brush": brush_compound + }) + + item.nbt = nbt + + meta = item.item_meta + meta.display_name = f"§bBrush ({brush_type})" + lore = [ + f"§7Type: {brush_type}", + f"§7Radius: {radius}", + f"§7Block: {block_type}", + f"§7Hollow: {hollow}", + f"§7Mask: none" + ] + if height is not None: + lore.insert(2, f"§7Height: {height}") + meta.lore = lore + item.set_item_meta(meta) + slot = sender.inventory.held_item_slot + sender.inventory.set_item(slot, None) + sender.inventory.set_item(slot, item) + + msg = f"{brush_type.capitalize()} brush bound to current item (radius: {radius}, block: {block_type}, hollow: {hollow})" + if height is not None: + msg = f"{brush_type.capitalize()} brush bound to current item (radius: {radius}, height: {height}, block: {block_type}, hollow: {hollow})" + sender.send_message(msg) + return True + +def execute_brush(plugin, player, block, brush_data): + brush_type = brush_data["type"].value + radius = brush_data["radius"].value + block_type = brush_data["block"].value + hollow = brush_data["hollow"].value == "true" + mask = None + if "mask" in brush_data and brush_data["mask"].value != "none": + mask = brush_data["mask"].value + if "height" in brush_data: + height = brush_data["height"].value + else: + height = 1 + + player_uuid = player.unique_id + dimension = player.dimension + cx, cy, cz = block.x, block.y, block.z + blocks_to_change = [] + if brush_type == "sphere": + for x in range(cx - radius, cx + radius + 1): + for y in range(cy - radius, cy + radius + 1): + for z in range(cz - radius, cz + radius + 1): + distance = math.sqrt((x - cx) ** 2 + (y - cy) ** 2 + (z - cz) ** 2) + if hollow: + if not (radius - 1 < distance <= radius): + continue + elif distance > radius: + continue + if mask: + try: + existing = dimension.get_block_at(x, y, z) + if str(existing.type) != mask: + continue + except RuntimeError: + continue + if block_type.startswith("minecraft:"): + blocks_to_change.append((x, y, z, block_type, None)) + else: + blocks_to_change.append((x, y, z, f"minecraft:{block_type}", None)) + + elif brush_type == "cylinder": + for y in range(cy, cy + height): + for x in range(cx - radius, cx + radius + 1): + for z in range(cz - radius, cz + radius + 1): + distance = math.sqrt((x - cx) ** 2 + (z - cz) ** 2) + if hollow: + if not (radius - 1 < distance <= radius): + continue + elif distance > radius: + continue + + if mask: + try: + existing = dimension.get_block_at(x, y, z) + if existing.type != mask: + continue + except RuntimeError: + continue + + if block_type.startswith("minecraft:"): + blocks_to_change.append((x, y, z, block_type, None)) + else: + blocks_to_change.append((x, y, z, f"minecraft:{block_type}", None)) + + elif brush_type == "cube": + for x in range(cx - radius, cx + radius + 1): + for y in range(cy - radius, cy + radius + 1): + for z in range(cz - radius, cz + radius + 1): + if hollow: + is_surface = (x == cx - radius or x == cx + radius or + y == cy - radius or y == cy + radius or + z == cz - radius or z == cz + radius) + if not is_surface: + continue + + if mask: + try: + existing = dimension.get_block_at(x, y, z) + if existing.type != mask: + continue + except RuntimeError: + continue + + if block_type.startswith("minecraft:"): + blocks_to_change.append((x, y, z, block_type, None)) + else: + blocks_to_change.append((x, y, z, f"minecraft:{block_type}", None)) + + if not blocks_to_change: + player.send_message("No blocks to change.") + return + + undo_entry = [] + for x, y, z, _, _ in blocks_to_change: + try: + b = dimension.get_block_at(x, y, z) + undo_entry.append((x, y, z, b.type, b.data)) + except RuntimeError: + continue + + if player_uuid not in plugin.undo_history: + plugin.undo_history[player_uuid] = [] + plugin.undo_history[player_uuid].append(undo_entry) + plugin.redo_history[player_uuid] = [] + if len(blocks_to_change) > plugin.plugin_config["async-threshold"]: + plugin.tasks[player_uuid] = {"dimension": dimension, "blocks": blocks_to_change} + player.send_message(f"Placing {len(blocks_to_change)} blocks asynchronously...") + else: + for x, y, z, bt, _ in blocks_to_change: + try: + b = dimension.get_block_at(x, y, z) + b.set_type(bt) + except RuntimeError: + continue + player.send_message(f"Brush placed {len(blocks_to_change)} blocks.") + +def _get_target_block(player, max_distance=100): + """Raycast from player's eyes to find the first solid block they're looking at.""" + loc = player.location + direction = loc.direction + dimension = player.dimension + x, y, z = loc.x, loc.y + 1.62, loc.z + dx = direction.x + dy = direction.y + dz = direction.z + step = 0.5 + for i in range(int(max_distance / step)): + x += dx * step + y += dy * step + z += dz * step + block_x = int(math.floor(x)) + block_y = int(math.floor(y)) + block_z = int(math.floor(z)) + try: + block = dimension.get_block_at(block_x, block_y, block_z) + if block.type != "minecraft:air": + return block + except RuntimeError: + return None + return None \ No newline at end of file diff --git a/src/endstone_worldedit/commands/mask.py b/src/endstone_worldedit/commands/mask.py new file mode 100644 index 0000000..869a09e --- /dev/null +++ b/src/endstone_worldedit/commands/mask.py @@ -0,0 +1,71 @@ +from endstone.nbt import StringTag, CompoundTag +from endstone_worldedit.utils import command_executor + +command = { + "mask": { + "description": "Sets a mask for the current brush, replacing only the specified block.", + "usages": ["/mask ", "/mask none"], + "permissions": ["worldedit.command.mask"] + } +} + +@command_executor("mask") +def handler(plugin, sender, args): + item = sender.inventory.item_in_main_hand + if not item or not item.nbt or "worldedit" not in item.nbt or item.nbt["worldedit"].value != "brush": + sender.send_message("You must be holding a brush item.") + return False + + if len(args) < 1: + sender.send_message("Usage: /mask or /mask none") + return False + + mask_type = args[0].lower() + brush_data = item.nbt["brush"] + if mask_type == "none": + new_mask = StringTag("none") + lore_mask = "none" + msg = "Mask removed from current brush." + else: + new_mask = StringTag(f"minecraft:{mask_type}") + lore_mask = mask_type + msg = f"Brush mask set to {mask_type}." + + new_brush = CompoundTag({ + "type": brush_data["type"], + "radius": brush_data["radius"], + "block": brush_data["block"], + "hollow": brush_data["hollow"], + "mask": new_mask + }) + if "height" in brush_data: + new_brush["height"] = brush_data["height"] + + height_str = "" + if "height" in brush_data: + height_str = str(brush_data["height"].value) + + slot = sender.inventory.held_item_slot + sender.inventory.set_item(slot, None) + + item.nbt = CompoundTag({ + "worldedit": StringTag("brush"), + "brush": new_brush + }) + + meta = item.item_meta + meta.display_name = f"§bBrush ({brush_data['type'].value})" + lore = [ + f"§7Type: {brush_data['type'].value}", + f"§7Radius: {brush_data['radius'].value}", + f"§7Block: {brush_data['block'].value}", + f"§7Hollow: {brush_data['hollow'].value}", + f"§7Mask: {lore_mask}" + ] + if height_str: + lore.insert(2, f"§7Height: {height_str}") + meta.lore = lore + item.set_item_meta(meta) + sender.inventory.set_item(slot, item) + sender.send_message(msg) + return True \ No newline at end of file diff --git a/src/endstone_worldedit/commands/mcs.py b/src/endstone_worldedit/commands/mcs.py new file mode 100644 index 0000000..df274c9 --- /dev/null +++ b/src/endstone_worldedit/commands/mcs.py @@ -0,0 +1,177 @@ +import os +import nbtlib +from endstone_worldedit.utils import command_executor + +# Blocks that should not be placed due to safety or admin-only restrictions +BLOCKED_BLOCKS = { + "minecraft:command_block", + "minecraft:chain_command_block", + "minecraft:repeating_command_block", + "minecraft:structure_block", + "minecraft:structure_void", + "minecraft:allow", + "minecraft:deny", + "minecraft:border_block", + "minecraft:bedrock", + "minecraft:barrier", + "minecraft:end_portal_frame", + "minecraft:end_portal", + "minecraft:portal", + "minecraft:end_gateway", + "minecraft:reserved6", + "minecraft:jigsaw", + "minecraft:flowing_lava", +} + +command = { + "mcs": { + "description": "Loads a .mcstructure file.", + "usages": [ + "/mcs load ", + "/mcs list" + ], + "permissions": ["worldedit.command.mcstructure"] + } +} + +@command_executor("mcs") +def handler(plugin, sender, args): + if len(args) < 1: + sender.send_message("Usage: /mcs [name]") + return False + + sub_command = args[0].lower() + mcstructures_path = plugin.plugin_config.get("mcstructures-path", "plugins/WorldEdit/mcstructures") + + if sub_command == "list": + if not os.path.exists(mcstructures_path): + sender.send_message("MCStructures directory not found.") + return False + files = [f.replace('.mcstructure', '') for f in os.listdir(mcstructures_path) if f.endswith('.mcstructure')] + if not files: + sender.send_message("No structures found.") + else: + sender.send_message("Available structures: " + ", ".join(files)) + return True + + if sub_command == "load": + if len(args) < 2: + sender.send_message("Usage: /mcs load ") + return False + + name = args[1] + if ".." in name or "/" in name or "\\" in name: + sender.send_message("Invalid structure name.") + return False + file_path = os.path.join(mcstructures_path, f"{name}.mcstructure") + if not os.path.exists(file_path): + sender.send_message(f"Structure '{name}.mcstructure' not found.") + return False + + try: + nbt_file = nbtlib.load(file_path, byteorder='little') + if "" in nbt_file.keys(): + nbt_file = nbt_file[""] + blocks_raw = list(map(int, nbt_file["structure"]["block_indices"][0])) + size = list(map(int, nbt_file["size"])) + palette = nbt_file["structure"]["palette"]["default"]["block_palette"] + except Exception as e: + sender.send_message(f"Error reading structure file: {e}") + return False + + width, height, length = int(size[0]), int(size[1]), int(size[2]) + player_uuid = sender.unique_id + dimension = sender.dimension + player_location = sender.location + sender.send_message(f"Loading structure ({width}x{height}x{length})...") + blocks_to_change = [] + for y in range(height): + for z in range(length): + for x in range(width): + idx = (x * height + y) * length + z + if idx < 0 or idx >= len(blocks_raw): + continue + palette_index = blocks_raw[idx] + if palette_index < 0 or palette_index >= len(palette): + continue + block_info = palette[palette_index] + + block_name = str(block_info['name']) + if block_name in ("minecraft:air", "minecraft:flowing_water", "minecraft:flowing_lava"): + continue + if block_name in BLOCKED_BLOCKS: + continue + target_x = int(player_location.x) + x + target_y = int(player_location.y) + y + target_z = int(player_location.z) + z + states = {} + if 'states' in block_info: + for state_key, state_val in block_info['states'].items(): + key_str = str(state_key) + if "_bit" in key_str: + states[key_str] = bool(int(state_val)) + else: + try: + states[key_str] = int(state_val) + except (TypeError, ValueError): + states[key_str] = str(state_val) + blocks_to_change.append((target_x, target_y, target_z, block_name, states)) + + if not blocks_to_change: + sender.send_message("Structure is empty or only contains air.") + return True + + sender.send_message(f"Placing {len(blocks_to_change)} blocks...") + + dependent_blocks = [ + "flower", "sapling", "mushroom", "torch", "rail", "redstone_wire", "repeater", "comparator", + "sign", "door", "lever", "button", "pressure_plate", "tripwire_hook", "tripwire", "banner" + ] + solid_pass = [b for b in blocks_to_change if not any(d in b[3] for d in dependent_blocks)] + dependent_pass = [b for b in blocks_to_change if any(d in b[3] for d in dependent_blocks)] + + full_undo_entry = [] + for x, y, z, _, _ in blocks_to_change: + try: + old = dimension.get_block_at(x, y, z) + full_undo_entry.append((x, y, z, str(old.type), old.data)) + except RuntimeError: + continue + + if player_uuid not in plugin.undo_history: + plugin.undo_history[player_uuid] = [] + plugin.undo_history[player_uuid].append(full_undo_entry) + plugin.redo_history[player_uuid] = [] + + total_blocks = len(blocks_to_change) + if total_blocks > plugin.plugin_config.get("async-threshold", 5000): + ordered_blocks = solid_pass + dependent_pass + async_blocks = [] + for x, y, z, block_type, states in ordered_blocks: + try: + bd = plugin.server.create_block_data(block_type, block_states=states) + async_blocks.append((x, y, z, block_type, bd)) + except Exception as e: + plugin.logger.error(f"Failed to create block data for {block_type}: {e}") + + plugin.tasks[player_uuid] = {"dimension": dimension, "blocks": async_blocks} + sender.send_message(f"Starting async operation for {total_blocks} blocks...") + else: + def execute_pass(blocks_pass): + for x, y, z, block_type, states in blocks_pass: + try: + block = dimension.get_block_at(x, y, z) + bd = plugin.server.create_block_data(block_type, block_states=states) + block.set_type(block_type) + block.set_data(bd) + except Exception as e: + plugin.logger.error(f"Failed block {block_type}: {e}") + continue + execute_pass(solid_pass) + execute_pass(dependent_pass) + + sender.send_message(f"Operation complete ({len(blocks_to_change)} blocks affected).") + return True + + sender.send_message(f"Unknown sub-command '{sub_command}'. Use load or list.") + return False diff --git a/src/endstone_worldedit/commands/schem.py b/src/endstone_worldedit/commands/schem.py index c75d83a..824d623 100644 --- a/src/endstone_worldedit/commands/schem.py +++ b/src/endstone_worldedit/commands/schem.py @@ -185,7 +185,7 @@ def handler(plugin, sender, args): # Function to execute a pass def execute_pass(blocks_pass): - if len(blocks_pass) > plugin.plugin_config["async-threshold"]: + if len(blocks_pass) > plugin.plugin_config.get("async-threshold", 5000): plugin.tasks[player_uuid] = {"dimension": dimension, "blocks": blocks_pass} sender.send_message(f"Starting async operation for {len(blocks_pass)} blocks...") else: @@ -193,9 +193,8 @@ def execute_pass(blocks_pass): try: block = dimension.get_block_at(x, y, z) block.set_type(block_type) - # if data_value is not None: - # # block.data = data_value # Endstone API is read-only - # pass + if data_value is not None: + block.set_data(data_value) except RuntimeError as e: plugin.logger.error(f"Skipping block '{block_type}' for player {sender.name}: {e}") sender.send_message(f"§cSkipped block: {block_type} ({e})§r") diff --git a/src/endstone_worldedit/commands/stack.py b/src/endstone_worldedit/commands/stack.py new file mode 100644 index 0000000..26225e9 --- /dev/null +++ b/src/endstone_worldedit/commands/stack.py @@ -0,0 +1,96 @@ +from endstone_worldedit.utils import command_executor +from endstone.block import BlockFace + +command = { + "stack": { + "description": "Clone the selected area to the direction you are looking.", + "usages": ["/stack [count: int]"], + "permissions": ["worldedit.command.stack"] + } +} + +@command_executor("stack", selection_required=True) +def handler(plugin, sender, args): + player_uuid = sender.unique_id + pos1 = plugin.selections[player_uuid]['pos1'] + pos2 = plugin.selections[player_uuid]['pos2'] + dimension = sender.dimension + count = 1 + if args: + try: + count = int(args[0]) + if count < 1: + sender.send_message("Count must be a positive integer.") + return False + except ValueError: + sender.send_message("Invalid count. Usage: /stack [count]") + return False + yaw = sender.location.yaw % 360 + if 315 <= yaw or yaw < 45: + direction = BlockFace.SOUTH + elif 45 <= yaw < 135: + direction = BlockFace.WEST + elif 135 <= yaw < 225: + direction = BlockFace.NORTH + elif 225 <= yaw < 315: + direction = BlockFace.EAST + else: + direction = BlockFace.SOUTH + undo_entry = [] + plugin.redo_history[player_uuid] = [] + min_x, max_x = min(pos1[0], pos2[0]), max(pos1[0], pos2[0]) + min_y, max_y = min(pos1[1], pos2[1]), max(pos1[1], pos2[1]) + min_z, max_z = min(pos1[2], pos2[2]), max(pos1[2], pos2[2]) + width = max_x - min_x + 1 + length = max_z - min_z + 1 + height = max_y - min_y + 1 + original_blocks = [] + for x in range(int(min_x), int(max_x) + 1): + for y in range(int(min_y), int(max_y) + 1): + for z in range(int(min_z), int(max_z) + 1): + block = dimension.get_block_at(x, y, z) + original_blocks.append((x, y, z, block.type, block.data)) + + if direction == BlockFace.NORTH: + offset = (0, 0, -length) + elif direction == BlockFace.SOUTH: + offset = (0, 0, length) + elif direction == BlockFace.WEST: + offset = (-width, 0, 0) + elif direction == BlockFace.EAST: + offset = (width, 0, 0) + else: + sender.send_message("Unsupported direction.") + return False + blocks_to_change = [] + for i in range(1, count + 1): + for x, y, z, block_type, block_data in original_blocks: + new_x = x + offset[0] * i + new_y = y + offset[1] * i + new_z = z + offset[2] * i + blocks_to_change.append((new_x, new_y, new_z, block_type, block_data)) + for new_x, new_y, new_z, _, _ in blocks_to_change: + try: + block = dimension.get_block_at(new_x, new_y, new_z) + undo_entry.append((new_x, new_y, new_z, block.type, block.data)) + except RuntimeError: + sender.send_message(f"§cStack would place blocks out of bounds. Aborting.§r") + return False + affected_blocks = len(blocks_to_change) + if player_uuid not in plugin.undo_history: + plugin.undo_history[player_uuid] = [] + plugin.undo_history[player_uuid].append(undo_entry) + if affected_blocks > plugin.plugin_config["async-threshold"]: + plugin.tasks[player_uuid] = { + "dimension": dimension, + "blocks": blocks_to_change + } + sender.send_message(f"Starting async operation for {affected_blocks} blocks ({count} stacks)...") + else: + for x, y, z, block_type, block_data in blocks_to_change: + block = dimension.get_block_at(x, y, z) + block.set_type(block_type) + if block_data is not None: + block.set_data(block_data) + sender.send_message(f"Operation complete ({affected_blocks} blocks affected, {count} stacks).") + return True \ No newline at end of file diff --git a/src/endstone_worldedit/commands/wand.py b/src/endstone_worldedit/commands/wand.py index 44ffdbb..a5ba213 100644 --- a/src/endstone_worldedit/commands/wand.py +++ b/src/endstone_worldedit/commands/wand.py @@ -1,5 +1,6 @@ from endstone.inventory import ItemStack from endstone_worldedit.utils import command_executor +from endstone.nbt import StringTag, CompoundTag command = { "wand": { @@ -12,6 +13,14 @@ @command_executor("wand") def handler(plugin, sender, args): - sender.inventory.add_item(ItemStack("minecraft:wooden_axe")) - sender.send_message("You have been given the wand tool.") - return True + wand_item = ItemStack("minecraft:wooden_axe") + # Fixed wand item, using NBT to enforce the item to be a wand tool and prevent using normal wooden axe as a wand tool + nbt = CompoundTag({"worldedit": StringTag("wand")}) + wand_item.nbt = nbt + meta = wand_item.item_meta + meta.display_name = "§bWand Tool" + meta.lore = ["§7Left-click to set pos1", "§7Right-click to set pos2"] + wand_item.set_item_meta(meta) + sender.inventory.add_item(wand_item) + sender.send_message("You have been given a wand tool.") + return True \ No newline at end of file diff --git a/src/endstone_worldedit/config/configs.py b/src/endstone_worldedit/config/configs.py new file mode 100644 index 0000000..314ae67 --- /dev/null +++ b/src/endstone_worldedit/config/configs.py @@ -0,0 +1,91 @@ +import os +from ruamel.yaml import YAML + +DEFAULT_BLOCK_TRANSLATION_MAP = { + "cobblestone_stairs": "stone_stairs", + "rooted_dirt": "dirt", + "flowering_azalea_leaves": "azalea_leaves_flowered", + "slime_block": "slime", + "sugar_cane": "reeds", + "small_dripleaf": "small_dripleaf_block", + "magma_block": "magma", + "lily_pad": "waterlily", + "dead_bush": "deadbush", + "snow_block": "snow", + "dirt_path": "grass_path", + "jack_o_lantern": "lit_pumpkin", + "melon": "melon_block", + "end_stone_bricks": "end_bricks", + "end_stone_brick_stairs": "end_brick_stairs", + "prismarine_brick_stairs": "prismarine_stairs", + "nether_bricks": "nether_brick", + "bricks": "brick_block", + "red_nether_bricks": "red_nether_brick", + "note_block": "noteblock", + "cobweb": "web", + "nether_quartz_ore": "quartz_ore", + "waxed_copper_block": "copper_block", + "repeater": "unpowered_repeater", + "comparator": "unpowered_comparator", + "powered_rail": "golden_rail", + "beetroots": "beetroot", + "oak_door": "wooden_door", + "oak_trapdoor": "trapdoor", + "oak_fence": "fence", + "oak_fence_gate": "fence_gate", + "oak_button": "wooden_button", + "oak_pressure_plate": "wooden_pressure_plate", + "oak_sign": "standing_sign", + "oak_wall_sign": "wall_sign", + "warped_sign": "standing_sign", + "warped_wall_sign": "wall_sign", + "crimson_sign": "standing_sign", + "crimson_wall_sign": "wall_sign", + "bamboo_sign": "standing_sign", + "bamboo_wall_sign": "wall_sign", + "cherry_sign": "standing_sign", + "cherry_wall_sign": "wall_sign", + "mangrove_sign": "standing_sign", + "mangrove_wall_sign": "wall_sign", + "jungle_sign": "standing_sign", + "jungle_wall_sign": "wall_sign", + "acacia_sign": "standing_sign", + "acacia_wall_sign": "wall_sign", + "dark_oak_sign": "standing_sign", + "dark_oak_wall_sign": "wall_sign", + "birch_sign": "standing_sign", + "birch_wall_sign": "wall_sign", + "spruce_sign": "standing_sign", + "spruce_wall_sign": "wall_sign", + "oak_wall_hanging_sign": "wall_sign", + "terracotta": "hardened_clay", + "light_gray_glazed_terracotta": "light_gray_concrete", + "dark_oak_standing_sign": "standing_sign", + "sign": "standing_sign", +} + +DEFAULT_CONFIG = { + "async-threshold": 5000, + "particle-type": "minecraft:endrod", + "particle-density-step": 5, + "schematic-path": "plugins/WorldEdit/schematics", + "mcstructures-path": "plugins/WorldEdit/mcstructures", + "block_translation_map": DEFAULT_BLOCK_TRANSLATION_MAP +} + +def load_plugin_config(config_path: str = "plugins/WorldEdit/config.yml") -> dict: + yaml = YAML() + yaml.indent(mapping=4, sequence=4, offset=2) + + if not os.path.exists(config_path): + os.makedirs(os.path.dirname(config_path), exist_ok=True) + config = DEFAULT_CONFIG + with open(config_path, 'w', encoding='utf-8') as f: + yaml.dump(config, f) + else: + with open(config_path, 'r', encoding='utf-8') as f: + config = yaml.load(f) + if config is None: + config = DEFAULT_CONFIG + + return config diff --git a/src/endstone_worldedit/plugin.py b/src/endstone_worldedit/plugin.py index 7f56f1b..a3dfe10 100644 --- a/src/endstone_worldedit/plugin.py +++ b/src/endstone_worldedit/plugin.py @@ -7,13 +7,11 @@ ) import time import os -import json from endstone.command import Command, CommandSender, CommandSenderWrapper from .commands import preloaded_commands, preloaded_handlers - class WorldEditPlugin(Plugin): - api_version = "0.10" + api_version = "0.11" commands = preloaded_commands def __init__(self): @@ -25,98 +23,21 @@ def __init__(self): self.redo_history = {} self.clipboard = {} self.block_translation_map = {} - self.particle_toggle = {} # Stores player UUID -> bool + self.particle_toggle = {} def on_load(self): self.logger.info("WorldEditPlugin has been loaded!") self.load_config() - - # Create schematics directory if it doesn't exist schematic_path = self.plugin_config.get("schematic-path", "plugins/WorldEdit/schematics") if not os.path.exists(schematic_path): os.makedirs(schematic_path) + mcstructures_path = self.plugin_config.get("mcstructures-path", "plugins/WorldEdit/mcstructures") + if not os.path.exists(mcstructures_path): + os.makedirs(mcstructures_path) def load_config(self): - config_path = "plugins/WorldEdit/config.json" - default_block_translation_map = { - "cobblestone_stairs": "stone_stairs", - "rooted_dirt": "dirt", - "flowering_azalea_leaves": "azalea_leaves_flowered", - "slime_block": "slime", - "sugar_cane": "reeds", - "small_dripleaf": "small_dripleaf_block", - "magma_block": "magma", - "lily_pad": "waterlily", - "dead_bush": "deadbush", - "snow_block": "snow", - "dirt_path": "grass_path", - "jack_o_lantern": "lit_pumpkin", - "melon": "melon_block", - "end_stone_bricks": "end_bricks", - "end_stone_brick_stairs": "end_brick_stairs", - "prismarine_brick_stairs": "prismarine_stairs", - "nether_bricks": "nether_brick", - "bricks": "brick_block", - "red_nether_bricks": "red_nether_brick", - "note_block": "noteblock", - "cobweb": "web", - "nether_quartz_ore": "quartz_ore", - "waxed_copper_block": "copper_block", - "repeater": "unpowered_repeater", - "comparator": "unpowered_comparator", - "powered_rail": "golden_rail", - "beetroots": "beetroot", - "oak_door": "wooden_door", - "oak_trapdoor": "trapdoor", - "oak_fence": "fence", - "oak_fence_gate": "fence_gate", - "oak_button": "wooden_button", - "oak_pressure_plate": "wooden_pressure_plate", - "oak_sign": "standing_sign", - "oak_wall_sign": "wall_sign", - "warped_sign": "standing_sign", - "warped_wall_sign": "wall_sign", - "crimson_sign": "standing_sign", - "crimson_wall_sign": "wall_sign", - "bamboo_sign": "standing_sign", - "bamboo_wall_sign": "wall_sign", - "cherry_sign": "standing_sign", - "cherry_wall_sign": "wall_sign", - "mangrove_sign": "standing_sign", - "mangrove_wall_sign": "wall_sign", - "jungle_sign": "standing_sign", - "jungle_wall_sign": "wall_sign", - "acacia_sign": "standing_sign", - "acacia_wall_sign": "wall_sign", - "dark_oak_sign": "standing_sign", - "dark_oak_wall_sign": "wall_sign", - "birch_sign": "standing_sign", - "birch_wall_sign": "wall_sign", - "spruce_sign": "standing_sign", - "spruce_wall_sign": "wall_sign", - "oak_wall_hanging_sign": "wall_sign", - "terracotta": "hardened_clay", - "light_gray_glazed_terracotta": "light_gray_concrete", - "dark_oak_standing_sign": "standing_sign", - "sign": "standing_sign", - } - default_config = { - "async-threshold": 5000, - "particle-type": "minecraft:endrod", - "particle-density-step": 5, - "schematic-path": "plugins/WorldEdit/schematics", - "block_translation_map": default_block_translation_map - } - - if not os.path.exists(config_path): - os.makedirs(os.path.dirname(config_path), exist_ok=True) - self.plugin_config = default_config - with open(config_path, 'w') as f: - json.dump(self.plugin_config, f, indent=4) - else: - with open(config_path, 'r') as f: - self.plugin_config = json.load(f) - + from .config.configs import load_plugin_config + self.plugin_config = load_plugin_config() self.block_translation_map = self.plugin_config.get("block_translation_map", {}) def on_enable(self): @@ -129,7 +50,6 @@ def on_enable(self): def show_selection_particles(self): for player_uuid, selection in self.selections.items(): - # Check if particles are enabled for this player if not self.particle_toggle.get(player_uuid, True): continue @@ -141,11 +61,8 @@ def show_selection_particles(self): min_x, max_x = min(pos1[0], pos2[0]), max(pos1[0], pos2[0]) min_y, max_y = min(pos1[1], pos2[1]), max(pos1[1], pos2[1]) min_z, max_z = min(pos1[2], pos2[2]), max(pos1[2], pos2[2]) - - # Draw a grid of particles along the edges, executed by the player step = self.plugin_config["particle-density-step"] particle_type = self.plugin_config["particle-type"] - # Draw a grid of particles along the edges, executed by the player step = self.plugin_config["particle-density-step"] particle_type = self.plugin_config["particle-type"] player_name = player.name @@ -170,6 +87,7 @@ def run_particle_command(x, y, z): run_particle_command(min_x, max_y, z) run_particle_command(max_x, max_y, z) + def run_tasks(self): for player_uuid, task_info in list(self.tasks.items()): dimension = task_info["dimension"] @@ -208,18 +126,18 @@ def run_tasks(self): if player: player.send_message(f"§cSkipped block: {block_type} ({e})§r") continue # Skip to the next block - + def on_command(self, sender: CommandSender, command: Command, args: list[str]) -> bool: if command.name in self.handlers: handler = self.handlers[command.name] return handler(self, sender, args) return False - + @event_handler(priority=EventPriority.HIGH) def on_block_break(self, event: BlockBreakEvent): player = event.player item = player.inventory.item_in_main_hand - if item is not None and item.type == "minecraft:wooden_axe": + if item and item.nbt and "worldedit" in item.nbt and item.nbt["worldedit"].value == "wand": event.cancel() player_uuid = player.unique_id if player_uuid not in self.selections: @@ -235,15 +153,32 @@ def on_player_interact(self, event: PlayerInteractEvent): current_time = time.time() last_interact_time = self.interaction_cooldown.get(player_uuid, 0) - if current_time - last_interact_time < 0.1: # 100ms cooldown + if current_time - last_interact_time < 0.1: return - if event.action.name == "RIGHT_CLICK_BLOCK": - item = player.inventory.item_in_main_hand - if item is not None and item.type == "minecraft:wooden_axe": + item = event.item + if not item or not item.nbt or "worldedit" not in item.nbt: + return + + if item.nbt["worldedit"].value == "wand": + if event.action == PlayerInteractEvent.Action.RIGHT_CLICK_BLOCK: self.interaction_cooldown[player_uuid] = current_time if player_uuid not in self.selections: self.selections[player_uuid] = {} block = event.block self.selections[player_uuid]["pos2"] = (block.x, block.y, block.z) player.send_message(f"Position 2 set to ({block.x}, {block.y}, {block.z}).") + + elif item.nbt["worldedit"].value == "brush": + if event.action in (PlayerInteractEvent.Action.RIGHT_CLICK_BLOCK, PlayerInteractEvent.Action.RIGHT_CLICK_AIR): + self.interaction_cooldown[player_uuid] = current_time + if "brush" in item.nbt: + from .commands.brush import execute_brush, _get_target_block + if event.has_block: + target = event.block + else: + target = _get_target_block(player) + if target: + execute_brush(self, player, target, item.nbt["brush"]) + else: + player.send_message("§cNo target block found.§r")