From 8f6f505cb4cae208d72afd7609fb530e28af09f4 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 20 Dec 2025 00:52:35 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E3=83=93=E3=82=B8=E3=83=A5=E3=82=A2?= =?UTF-8?q?=E3=83=A9=E3=82=A4=E3=82=BA=E3=81=A8=E8=A8=AD=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Lands | 2 +- .../hcu/landsector/LandSectorPlugin.kt | 24 +- .../landsector/command/LandSectorCommand.kt | 166 ++++++- .../hcu/landsector/database/SectorsTable.kt | 16 + .../hcu/landsector/listener/SectorListener.kt | 432 +++++++++++++++++- .../landsector/listener/SelectionListener.kt | 188 ++++++++ .../hareworks/hcu/landsector/model/Sector.kt | 3 +- .../hcu/landsector/model/SectorRange.kt | 14 + .../hcu/landsector/model/SelectionData.kt | 15 + .../hcu/landsector/service/SectorService.kt | 240 +++++++++- .../landsector/service/SelectionService.kt | 103 +++++ .../hcu/landsector/task/SectorRotationTask.kt | 88 ++++ .../task/SelectionVisualizerTask.kt | 195 ++++++++ build.gradle.kts | 5 + .../hcu/landsector/LandSectorPlugin.kt | 15 +- .../landsector/command/LandSectorCommand.kt | 66 +++ .../hcu/landsector/database/SectorsTable.kt | 15 + .../hcu/landsector/listener/SectorListener.kt | 122 ++++- .../landsector/listener/SelectionListener.kt | 188 ++++++++ .../hcu/landsector/model/SectorRange.kt | 14 + .../hcu/landsector/model/SelectionData.kt | 15 + .../hcu/landsector/service/SectorService.kt | 60 ++- .../landsector/service/SelectionService.kt | 103 +++++ .../task/SelectionVisualizerTask.kt | 195 ++++++++ 24 files changed, 2241 insertions(+), 43 deletions(-) create mode 100644 bin/main/net/hareworks/hcu/landsector/listener/SelectionListener.kt create mode 100644 bin/main/net/hareworks/hcu/landsector/model/SectorRange.kt create mode 100644 bin/main/net/hareworks/hcu/landsector/model/SelectionData.kt create mode 100644 bin/main/net/hareworks/hcu/landsector/service/SelectionService.kt create mode 100644 bin/main/net/hareworks/hcu/landsector/task/SectorRotationTask.kt create mode 100644 bin/main/net/hareworks/hcu/landsector/task/SelectionVisualizerTask.kt create mode 100644 src/main/kotlin/net/hareworks/hcu/landsector/listener/SelectionListener.kt create mode 100644 src/main/kotlin/net/hareworks/hcu/landsector/model/SectorRange.kt create mode 100644 src/main/kotlin/net/hareworks/hcu/landsector/model/SelectionData.kt create mode 100644 src/main/kotlin/net/hareworks/hcu/landsector/service/SelectionService.kt create mode 100644 src/main/kotlin/net/hareworks/hcu/landsector/task/SelectionVisualizerTask.kt diff --git a/Lands b/Lands index e24484b..d27057d 160000 --- a/Lands +++ b/Lands @@ -1 +1 @@ -Subproject commit e24484bdea95f6dd1d9aeef538c01c0c7b9db1f1 +Subproject commit d27057dc89e228ce120c5b5b4534b3c130e8480a diff --git a/bin/main/net/hareworks/hcu/landsector/LandSectorPlugin.kt b/bin/main/net/hareworks/hcu/landsector/LandSectorPlugin.kt index f954370..acc9d30 100644 --- a/bin/main/net/hareworks/hcu/landsector/LandSectorPlugin.kt +++ b/bin/main/net/hareworks/hcu/landsector/LandSectorPlugin.kt @@ -4,6 +4,7 @@ import net.hareworks.hcu.core.Main import net.hareworks.hcu.core.player.PlayerIdService import net.hareworks.hcu.landsector.command.LandSectorCommand import net.hareworks.hcu.landsector.listener.SectorListener +import net.hareworks.hcu.landsector.service.SelectionService import net.hareworks.hcu.landsector.service.SectorService import org.bukkit.plugin.java.JavaPlugin @@ -15,10 +16,13 @@ class LandSectorPlugin : JavaPlugin() { } var sectorService: SectorService? = null + var selectionService: SelectionService? = null override fun onEnable() { instance = this + selectionService = SelectionService() + // Register commands LandSectorCommand(this).register() @@ -57,12 +61,30 @@ class LandSectorPlugin : JavaPlugin() { return } - server.pluginManager.registerEvents(SectorListener(this, service, pIdService), this) + val selService = selectionService + if (selService != null) { + server.pluginManager.registerEvents(SectorListener(this, service, pIdService, selService), this) + server.pluginManager.registerEvents(net.hareworks.hcu.landsector.listener.SelectionListener(this, selService, service), this) + + // Schedule visualization task + net.hareworks.hcu.landsector.task.SelectionVisualizerTask(this, selService).runTaskTimer(this, 0L, 4L) + } else { + logger.severe("SelectionService not initialized!") + } + + // Schedule auto-save every 5 minutes + server.scheduler.runTaskTimerAsynchronously(this, Runnable { + service.flushChanges() + }, 6000L, 6000L) + + // Schedule rotation task + net.hareworks.hcu.landsector.task.SectorRotationTask(this).runTaskTimer(this, 1L, 1L) logger.info("LandSector initialized with services.") } override fun onDisable() { + sectorService?.flushChanges() logger.info("LandSector plugin has been disabled!") } } diff --git a/bin/main/net/hareworks/hcu/landsector/command/LandSectorCommand.kt b/bin/main/net/hareworks/hcu/landsector/command/LandSectorCommand.kt index 53898b3..aba7ee9 100644 --- a/bin/main/net/hareworks/hcu/landsector/command/LandSectorCommand.kt +++ b/bin/main/net/hareworks/hcu/landsector/command/LandSectorCommand.kt @@ -1,19 +1,62 @@ package net.hareworks.hcu.landsector.command import net.hareworks.hcu.landsector.LandSectorPlugin +import net.hareworks.hcu.landsector.model.Sector import net.hareworks.kommand_lib.kommand import net.kyori.adventure.text.Component import net.kyori.adventure.text.format.NamedTextColor +import org.bukkit.Bukkit +import org.bukkit.Location import org.bukkit.Material import org.bukkit.NamespacedKey import org.bukkit.entity.Player +import org.bukkit.entity.Shulker import org.bukkit.inventory.ItemStack import org.bukkit.persistence.PersistentDataType -class LandSectorCommand(private val plugin: LandSectorPlugin) { +class LandSectorCommand(private val landSectorPlugin: LandSectorPlugin) { fun register() { - kommand(plugin) { + kommand(landSectorPlugin) { command("landsector") { + literal("givetool") { + executes { + val player = sender as? Player ?: return@executes + giveTool(player, null) + } + integer("sectorId") { + executes { + val player = sender as? Player ?: return@executes + val id = argument("sectorId") + giveTool(player, id) + } + } + } + + literal("activate") { + integer("sectorId") { + executes { + val id = argument("sectorId") + sender.sendMessage(Component.text("Activation for Sector #$id pending implementation.", NamedTextColor.YELLOW)) + // TODO: Validate selection and lock in + } + } + } + + literal("cancel") { + integer("sectorId") { + executes { + val id = argument("sectorId") + sender.sendMessage(Component.text("Cancellation for Sector #$id pending implementation.", NamedTextColor.YELLOW)) + // TODO: Cancel logic + val player = sender as? Player + if (player != null) { + landSectorPlugin.selectionService?.clearSelection(player.uniqueId) + sender.sendMessage(Component.text("Selection cleared.", NamedTextColor.RED)) + } + } + } + } + literal("give") { executes { val player = sender as? Player ?: return@executes @@ -22,7 +65,7 @@ class LandSectorCommand(private val plugin: LandSectorPlugin) { val meta = item.itemMeta meta.displayName(Component.text("Sector Core", NamedTextColor.LIGHT_PURPLE)) meta.persistentDataContainer.set( - NamespacedKey(plugin, "component"), + NamespacedKey(landSectorPlugin, "component"), PersistentDataType.STRING, "sector_core" ) @@ -32,7 +75,124 @@ class LandSectorCommand(private val plugin: LandSectorPlugin) { sender.sendMessage(Component.text("Gave 1 Sector Core.", NamedTextColor.GREEN)) } } + + literal("list") { + executes { + val player = sender as? Player ?: return@executes + val service = landSectorPlugin.sectorService + if (service == null) { + sender.sendMessage(Component.text("SectorService not ready.", NamedTextColor.RED)) + return@executes + } + + val worldName = player.world.name + val loc = player.location + + val sectors = service.getAllSectors(worldName) + .sortedBy { sector -> + val sx = sector.x + 0.5 + val sy = sector.y + 1.0 + val sz = sector.z + 0.5 + + (loc.x - sx) * (loc.x - sx) + + (loc.y - sy) * (loc.y - sy) + + (loc.z - sz) * (loc.z - sz) + } + + sender.sendMessage(Component.text("=== Sector List (${sectors.size}) ===", NamedTextColor.GOLD)) + sectors.forEach { sector -> + val distSq = (loc.x - (sector.x + 0.5)) * (loc.x - (sector.x + 0.5)) + + (loc.y - (sector.y + 1.0)) * (loc.y - (sector.y + 1.0)) + + (loc.z - (sector.z + 0.5)) * (loc.z - (sector.z + 0.5)) + val dist = Math.sqrt(distSq).toInt() + + sender.sendMessage( + Component.text() + .append(Component.text("#${sector.id} ", NamedTextColor.YELLOW)) + .append(Component.text("(${sector.x}, ${sector.y}, ${sector.z}) ", NamedTextColor.GRAY)) + .append(Component.text("HP: ${sector.hp} ", NamedTextColor.RED)) + .append(Component.text("- ${dist}m", NamedTextColor.AQUA)) + .build() + ) + } + } + } + + literal("delete") { + integer("id") { + executes { + val id = argument("id") + val service = landSectorPlugin.sectorService + if (service == null) { + sender.sendMessage(Component.text("SectorService not ready.", NamedTextColor.RED)) + return@executes + } + + val sector = service.deleteSector(id) + if (sector == null) { + sender.sendMessage(Component.text("Sector #$id not found.", NamedTextColor.RED)) + } else { + // Physical removal + val world = Bukkit.getWorld(sector.world) + if (world != null) { + val x = sector.x + val y = sector.y + val z = sector.z + + val blockBase = world.getBlockAt(x, y - 1, z) + val blockTop = world.getBlockAt(x, y + 1, z) + + if (blockBase.type == Material.DEEPSLATE_TILE_SLAB) blockBase.type = Material.AIR + if (blockTop.type == Material.DEEPSLATE_TILE_SLAB) blockTop.type = Material.AIR + + // Remove All Entities with this sector ID + val center = Location(world, x + 0.5, y.toDouble(), z + 0.5) + if (center.chunk.isLoaded) { + val sectorKey = NamespacedKey(landSectorPlugin, "sector_id") + center.chunk.entities.forEach { entity -> + if (entity.persistentDataContainer.has(sectorKey, PersistentDataType.INTEGER)) { + val sId = entity.persistentDataContainer.get(sectorKey, PersistentDataType.INTEGER) + if (sId == id) { + entity.remove() + } + } + } + } + } + + sender.sendMessage(Component.text("Sector #$id deleted.", NamedTextColor.GREEN)) + } + } + } + } } } } + + private fun giveTool(player: Player, sectorId: Int?) { + val item = ItemStack(Material.FLINT) + val meta = item.itemMeta + meta.displayName(Component.text("Range Selection Tool" + (if (sectorId != null) " (#$sectorId)" else ""), NamedTextColor.AQUA)) + meta.persistentDataContainer.set( + NamespacedKey(landSectorPlugin, "component"), + PersistentDataType.STRING, + "land_sector_tool" + ) + if (sectorId != null) { + meta.persistentDataContainer.set( + NamespacedKey(landSectorPlugin, "sector_id"), + PersistentDataType.INTEGER, + sectorId + ) + } + meta.lore(listOf( + Component.text("Left Click: Switch Mode", NamedTextColor.GRAY), + Component.text("Right Click: Select Position", NamedTextColor.GRAY), + Component.text("Sneaking acts as modifier", NamedTextColor.DARK_GRAY) + ) + if (sectorId != null) listOf(Component.text("Linked to Sector #$sectorId", NamedTextColor.GOLD)) else emptyList()) + item.itemMeta = meta + + player.inventory.addItem(item) + player.sendMessage(Component.text("Gave Range Selection Tool${if (sectorId != null) " for Sector #$sectorId" else ""}.", NamedTextColor.GREEN)) + } } diff --git a/bin/main/net/hareworks/hcu/landsector/database/SectorsTable.kt b/bin/main/net/hareworks/hcu/landsector/database/SectorsTable.kt index c83179f..fc94862 100644 --- a/bin/main/net/hareworks/hcu/landsector/database/SectorsTable.kt +++ b/bin/main/net/hareworks/hcu/landsector/database/SectorsTable.kt @@ -9,6 +9,22 @@ object SectorsTable : Table("land_sectors") { val x = integer("x") val y = integer("y") val z = integer("z") + val hp = integer("hp").default(1000) + + override val primaryKey = PrimaryKey(id) +} + +object SectorRangesTable : Table("land_sector_ranges") { + val id = integer("id").autoIncrement() + val sectorId = integer("sector_id").references(SectorsTable.id) + val type = varchar("type", 16) + val x1 = integer("x1") + val y1 = integer("y1") + val z1 = integer("z1") + val x2 = integer("x2") + val y2 = integer("y2") + val z2 = integer("z2") + val isSneaking = bool("is_sneaking") override val primaryKey = PrimaryKey(id) } diff --git a/bin/main/net/hareworks/hcu/landsector/listener/SectorListener.kt b/bin/main/net/hareworks/hcu/landsector/listener/SectorListener.kt index 9da20a2..dbd4977 100644 --- a/bin/main/net/hareworks/hcu/landsector/listener/SectorListener.kt +++ b/bin/main/net/hareworks/hcu/landsector/listener/SectorListener.kt @@ -7,22 +7,62 @@ import net.kyori.adventure.text.Component import net.kyori.adventure.text.format.NamedTextColor import org.bukkit.Material import org.bukkit.NamespacedKey +import org.bukkit.Particle +import org.bukkit.Sound import org.bukkit.entity.EntityType import org.bukkit.entity.Shulker import org.bukkit.attribute.Attribute import org.bukkit.event.EventHandler import org.bukkit.event.Listener +import org.bukkit.event.block.BlockBreakEvent import org.bukkit.event.block.BlockPlaceEvent +import org.bukkit.event.entity.EntityDamageEvent +import org.bukkit.event.entity.EntityDamageByEntityEvent +import org.bukkit.event.world.ChunkLoadEvent +import org.bukkit.entity.Player +import org.bukkit.entity.BlockDisplay import org.bukkit.persistence.PersistentDataType +import org.bukkit.util.Transformation +import org.joml.Vector3f +import org.joml.Quaternionf +import org.bukkit.block.data.type.Slab + +import net.hareworks.hcu.landsector.service.SelectionService +import net.kyori.adventure.text.event.ClickEvent +import org.bukkit.event.player.PlayerInteractEntityEvent class SectorListener( private val plugin: LandSectorPlugin, private val sectorService: SectorService, - private val playerIdService: PlayerIdService + private val playerIdService: PlayerIdService, + private val selectionService: SelectionService ) : Listener { + @EventHandler + fun onBreak(event: BlockBreakEvent) { + val block = event.block + val loc = block.location + val player = event.player + + if (sectorService.isSectorBlock(loc.world.name, loc.blockX, loc.blockY, loc.blockZ)) { + player.sendMessage(Component.text("You cannot break Sector Core blocks!", NamedTextColor.RED)) + event.isCancelled = true + } + } + @EventHandler fun onPlace(event: BlockPlaceEvent) { + val block = event.block + val loc = block.location + val player = event.player + + // Global protection check + if (sectorService.isSectorArea(loc.world.name, loc.blockX, loc.blockY, loc.blockZ)) { + player.sendMessage(Component.text("This area is protected by a Sector Core.", NamedTextColor.RED)) + event.isCancelled = true + return + } + val item = event.itemInHand val meta = item.itemMeta ?: return val key = NamespacedKey(plugin, "component") @@ -31,50 +71,396 @@ class SectorListener( return } - val player = event.player val playerEntry = playerIdService.find(player.uniqueId) if (playerEntry == null) { player.sendMessage(Component.text("Identity not found.", NamedTextColor.RED)) event.isCancelled = true return } - - val block = event.blockPlaced - val loc = block.location - // Space check - val above1 = block.getRelative(0, 1, 0) - val above2 = block.getRelative(0, 2, 0) + // ... (rest of the code) + + // Check 3x3x3 space availability (relative to placed block base) + val baseLoc = block.location + for (dx in -1..1) { + for (dy in 0..2) { + for (dz in -1..1) { + if (dx == 0 && dy == 0 && dz == 0) continue // Skip the placed block itself + val checkLoc = baseLoc.clone().add(dx.toDouble(), dy.toDouble(), dz.toDouble()) + if (!checkLoc.block.type.isAir) { + player.sendMessage(Component.text("Not enough space! Need 3x3x3 free area.", NamedTextColor.RED)) + event.isCancelled = true + return + } + } + } + } - if (!above1.type.isAir || !above2.type.isAir) { - player.sendMessage(Component.text("Not enough space for Sector Core.", NamedTextColor.RED)) + // Define Center Location (This will be the DB coordinates) + val centerLoc = baseLoc.clone().add(0.0, 1.0, 0.0) // y+1 from base + + val sector = sectorService.createSector( + playerEntry.actorId, + player.world.name, + centerLoc.blockX, + centerLoc.blockY, + centerLoc.blockZ + ) + + if (sector == null) { + player.sendMessage(Component.text("Failed to create sector.", NamedTextColor.RED)) event.isCancelled = true return } - // Spawn Shulker - val shulkerLoc = loc.clone().add(0.5, 1.0, 0.5) + // Create Visuals + val sectorKey = NamespacedKey(plugin, "sector_id") + val visualCenter = centerLoc.clone().add(0.5, 0.0, 0.5) // Center of the block space + + // 1. Command Block (Center + 0.5y) -> Matches old base+1.5 + val cbLoc = visualCenter.clone().add(0.0, 0.5, 0.0) + val cb = player.world.spawnEntity(cbLoc, EntityType.BLOCK_DISPLAY) as BlockDisplay + cb.block = Material.COMMAND_BLOCK.createBlockData { + (it as org.bukkit.block.data.Directional).facing = org.bukkit.block.BlockFace.UP + } + cb.transformation = Transformation( + Vector3f(-0.25f, -0.25f, -0.25f), + Quaternionf(0f, 0f, 0f, 1f), + Vector3f(0.5f, 0.5f, 0.5f), + Quaternionf(0f, 0f, 0f, 1f) + ) + cb.persistentDataContainer.set(sectorKey, PersistentDataType.INTEGER, sector.id) + + // 2. Tinted Glass (Center + 0.5y) -> Matches old base+1.5 + val glass = player.world.spawnEntity(cbLoc, EntityType.BLOCK_DISPLAY) as BlockDisplay + glass.block = Material.TINTED_GLASS.createBlockData() + glass.transformation = Transformation( + Vector3f(-0.4375f, -0.4375f, -0.4375f), + Quaternionf(0f, 0f, 0f, 1f), + Vector3f(0.875f, 0.875f, 0.875f), + Quaternionf(0f, 0f, 0f, 1f) + ) + glass.persistentDataContainer.set(sectorKey, PersistentDataType.INTEGER, sector.id) + + // 3. Top Cauldron (Center + 1.3125y) -> Matches old base+2.3125 + val topCauldronLoc = visualCenter.clone().add(0.0, 1.3125, 0.0) + val topCauldron = player.world.spawnEntity(topCauldronLoc, EntityType.BLOCK_DISPLAY) as BlockDisplay + topCauldron.block = Material.CAULDRON.createBlockData() + topCauldron.transformation = Transformation( + Vector3f(0.4990f, -0.4990f, 0.4990f), + Quaternionf(0f, 1f, 0f, 0f), + Vector3f(0.9980f, 0.9980f, 0.9980f), + Quaternionf(0f, 0f, 0f, 1f) + ) + topCauldron.persistentDataContainer.set(sectorKey, PersistentDataType.INTEGER, sector.id) + + // 4. Bottom Cauldron (Center - 0.3125y) -> Matches old base+0.6875 + val botCauldronLoc = visualCenter.clone().add(0.0, -0.3125, 0.0) + val botCauldron = player.world.spawnEntity(botCauldronLoc, EntityType.BLOCK_DISPLAY) as BlockDisplay + botCauldron.block = Material.CAULDRON.createBlockData() + botCauldron.transformation = Transformation( + Vector3f(-0.4990f, 0.4990f, 0.4990f), + Quaternionf(1f, 0f, 0f, 0f), + Vector3f(0.9980f, 0.9980f, 0.9980f), + Quaternionf(0f, 0f, 0f, 1f) + ) + botCauldron.persistentDataContainer.set(sectorKey, PersistentDataType.INTEGER, sector.id) + + // Spawn Shulker (Hitbox) at Center + val shulkerLoc = visualCenter.clone() // Center is exactly y+1 from base val shulker = player.world.spawnEntity(shulkerLoc, EntityType.SHULKER) as Shulker shulker.setAI(false) - shulker.isInvulnerable = true shulker.isInvisible = true + // shulker.isInvulnerable = true // REMOVED to allow damage val param = shulker.getAttribute(Attribute.MAX_HEALTH) param?.baseValue = 1000.0 shulker.health = 1000.0 + shulker.maximumNoDamageTicks = 0 + + // Tag Shulker with Sector ID + shulker.persistentDataContainer.set(sectorKey, PersistentDataType.INTEGER, sector.id) - // Place top bedrock - above2.type = Material.BEDROCK + // Place Blocks (Base and Top) + // Base is at center.y - 1 (The placed block) + // Top is at center.y + 1 - // Record to DB (using Shulker pos as center) - sectorService.createSector( - playerEntry.actorId, - player.world.name, - above1.x, - above1.y, - above1.z - ) + block.type = Material.DEEPSLATE_TILE_SLAB + val bottomSlab = block.blockData as Slab + bottomSlab.type = Slab.Type.BOTTOM + block.blockData = bottomSlab + + val topBlock = centerLoc.clone().add(0.0, 1.0, 0.0).block + topBlock.type = Material.DEEPSLATE_TILE_SLAB + val topSlab = topBlock.blockData as Slab + topSlab.type = Slab.Type.TOP + topBlock.blockData = topSlab player.sendMessage(Component.text("Sector Core placed!", NamedTextColor.GREEN)) } + + @EventHandler + fun onInteractEntity(event: PlayerInteractEntityEvent) { + if (event.hand != org.bukkit.inventory.EquipmentSlot.HAND) return + val entity = event.rightClicked + if (entity !is Shulker) return + + val sectorKey = NamespacedKey(plugin, "sector_id") + if (!entity.persistentDataContainer.has(sectorKey, PersistentDataType.INTEGER)) return + + val sectorId = entity.persistentDataContainer.get(sectorKey, PersistentDataType.INTEGER) ?: return + val player = event.player + + // Ownership Check + val pEntry = playerIdService.find(player.uniqueId) + if (pEntry == null) return + val sector = sectorService.getSector(sectorId) ?: return + + if (sector.ownerActorId != pEntry.actorId) { + player.sendMessage(Component.text("You are not the owner of this sector.", NamedTextColor.RED)) + return + } + + // Open GUI + val gui = org.bukkit.Bukkit.createInventory(null, 27, Component.text("Sector Manager (#${sectorId})", NamedTextColor.BLACK)) + + // Info Item with Ranges + val ranges = sectorService.getRanges(sectorId) + val rangeLore = ranges.map { Component.text(" - [${it.id}] ${it.type}", NamedTextColor.GRAY) } + val infoLore = mutableListOf() + infoLore.add(Component.text("Sector ID: $sectorId", NamedTextColor.WHITE)) + infoLore.addAll(rangeLore) + + gui.setItem(4, createGuiItem( + Material.PAPER, + Component.text("Sector Info", NamedTextColor.GOLD), + infoLore, + sectorId, + "info" + )) + + // Get Selection Tool + gui.setItem(11, createGuiItem( + Material.FLINT, + Component.text("Get Selection Tool", NamedTextColor.AQUA), + listOf(Component.text("Click to receive the selection tool.", NamedTextColor.GRAY)), + sectorId, + "give_tool" + )) + + // Activate + gui.setItem(13, createGuiItem( + Material.LIME_CONCRETE, + Component.text("Activate Sector", NamedTextColor.GREEN), + listOf(Component.text("Click to activate this sector.", NamedTextColor.GRAY)), + sectorId, + "activate" + )) + + // Cancel / Destroy + gui.setItem(15, createGuiItem( + Material.RED_CONCRETE, + Component.text("Cancel / Destroy", NamedTextColor.RED), + listOf(Component.text("Click to destroy this sector.", NamedTextColor.GRAY)), + sectorId, + "cancel" + )) + + player.openInventory(gui) + + event.isCancelled = true + } + + @EventHandler + fun onDamage(event: EntityDamageEvent) { + val entity = event.entity + if (entity !is Shulker) return + + val sectorKey = NamespacedKey(plugin, "sector_id") + if (!entity.persistentDataContainer.has(sectorKey, PersistentDataType.INTEGER)) return + + val sectorId = entity.persistentDataContainer.get(sectorKey, PersistentDataType.INTEGER) ?: return + + // Prevent vanilla death logic but show damage effect + // We restore health to max after processing + val damage = event.finalDamage + val maxHealth = entity.getAttribute(Attribute.MAX_HEALTH)?.value ?: 1000.0 + + // Cancel the event so it doesn't actually die in vanilla terms, + // OR let it happen but force health reset. + // Cancelling prevents the red flash in some versions/cases, so setting damage to 0 is often better + // if we want the effect. But resetting health is robust. + val oldHp = sectorService.getHealth(sectorId) ?: return // Get current health from service + val newHp = sectorService.reduceHealth(sectorId, damage.toInt()) + + entity.health = maxHealth + entity.noDamageTicks = 0 // Disable invulnerability + + if (newHp != null) { + val loc = entity.location + val world = entity.world + + // Sound Logic + val threshold = 100 // 10% of 1000 + val crossedThreshold = (oldHp / threshold) > (newHp / threshold) + + if (crossedThreshold) { + world.playSound(loc, Sound.BLOCK_ANVIL_PLACE, 1.0f, 0.5f) + } else { + world.playSound(loc, Sound.BLOCK_STONE_BREAK, 1.0f, 1.0f) + } + + world.spawnParticle(Particle.BLOCK, loc.add(0.0, 0.5, 0.0), 20, 0.3, 0.3, 0.3, Material.BEDROCK.createBlockData()) + + if (event is EntityDamageByEntityEvent && event.damager is Player) { + val player = event.damager as Player + + val percent = newHp.toDouble() / maxHealth + val color = when { + percent > 0.5 -> NamedTextColor.GREEN + percent > 0.2 -> NamedTextColor.YELLOW + else -> NamedTextColor.RED + } + + val progressBar = createProgressBar(newHp, maxHealth.toInt(), color) + + val message = Component.text() + .append(Component.text("Sector Core ", NamedTextColor.GOLD)) + .append(progressBar) + .append(Component.text(" ")) + .append(Component.text(newHp, color)) + .append(Component.text("/", NamedTextColor.GRAY)) + .append(Component.text(maxHealth.toInt(), NamedTextColor.GRAY)) + .build() + + player.sendActionBar(message) + } + + if (newHp <= 0) { + // Destruction + entity.remove() + + // Remove other visuals in chunk + val chunk = entity.chunk + chunk.entities.forEach { ent -> + val sId = ent.persistentDataContainer.get(sectorKey, PersistentDataType.INTEGER) + if (sId == sectorId) ent.remove() + } + + // Remove blocks. + val x = loc.blockX + val y = loc.blockY + val z = loc.blockZ + + val base = world.getBlockAt(x, y - 1, z) + val top = world.getBlockAt(x, y + 1, z) + + if (base.type == Material.DEEPSLATE_TILE_SLAB) base.type = Material.AIR + if (top.type == Material.DEEPSLATE_TILE_SLAB) top.type = Material.AIR + + world.dropItemNaturally(loc, org.bukkit.inventory.ItemStack(Material.DEEPSLATE_TILE_SLAB, 2)) + } + } + } + + private fun createProgressBar(current: Int, max: Int, color: NamedTextColor): Component { + val totalBars = 20 + val percent = current.toDouble() / max.toDouble() + val filledBars = (totalBars * percent).toInt().coerceIn(0, totalBars) + val emptyBars = totalBars - filledBars + + return Component.text() + .append(Component.text("[", NamedTextColor.DARK_GRAY)) + .append(Component.text("|".repeat(filledBars), color)) + .append(Component.text("|".repeat(emptyBars), NamedTextColor.GRAY)) + .append(Component.text("]", NamedTextColor.DARK_GRAY)) + .build() + } + + @EventHandler + fun onChunkLoad(event: ChunkLoadEvent) { + if (event.isNewChunk) return // Optimization: New chunks won't have old sectors to cleanup + + val chunk = event.chunk + val entities = chunk.entities + val sectorKey = NamespacedKey(plugin, "sector_id") + + entities.forEach { entity -> + if (entity.persistentDataContainer.has(sectorKey, PersistentDataType.INTEGER)) { + val sectorId = entity.persistentDataContainer.get(sectorKey, PersistentDataType.INTEGER) ?: return@forEach + + // Check existance async + plugin.server.scheduler.runTaskAsynchronously(plugin, Runnable { + val exists = sectorService.exists(sectorId) + if (!exists) { + plugin.server.scheduler.runTask(plugin, Runnable { + // Re-verify ent validity + if (entity.isValid) { + entity.remove() + // We should also clean up blocks, but that requires knowing where they are relative to entity + val loc = entity.location + val world = entity.world + val x = loc.blockX + val y = loc.blockY + val z = loc.blockZ + + // Entity is at x, y, z + // Base is y-1, Top is y+1 + val base = world.getBlockAt(x, y - 1, z) + val top = world.getBlockAt(x, y + 1, z) + + if (base.type == Material.DEEPSLATE_TILE_SLAB) base.type = Material.AIR + if (top.type == Material.DEEPSLATE_TILE_SLAB) top.type = Material.AIR + + plugin.logger.info("Removed orphaned sector artifacts for ID $sectorId at $x,$y,$z") + } + }) + } + }) + } + } + } + + @EventHandler + fun onInventoryClick(event: org.bukkit.event.inventory.InventoryClickEvent) { + val item = event.currentItem ?: return + val meta = item.itemMeta ?: return + val pluginKey = NamespacedKey(plugin, "gui_action") + + if (!meta.persistentDataContainer.has(pluginKey, PersistentDataType.STRING)) return + + event.isCancelled = true // Prevent taking items + + val player = event.whoClicked as? Player ?: return + val action = meta.persistentDataContainer.get(pluginKey, PersistentDataType.STRING) + val sectorKey = NamespacedKey(plugin, "sector_id") + val sectorId = meta.persistentDataContainer.get(sectorKey, PersistentDataType.INTEGER) ?: return + + player.closeInventory() + + when (action) { + "give_tool" -> player.performCommand("landsector givetool $sectorId") + "activate" -> player.performCommand("landsector activate $sectorId") + "cancel" -> player.performCommand("landsector cancel $sectorId") + "info" -> { + player.playSound(player.location, Sound.UI_BUTTON_CLICK, 1f, 1f) + } + } + } + + private fun createGuiItem(material: Material, name: Component, lore: List, sectorId: Int, action: String): org.bukkit.inventory.ItemStack { + val item = org.bukkit.inventory.ItemStack(material) + val meta = item.itemMeta + meta.displayName(name) + meta.lore(lore) + + val actionKey = NamespacedKey(plugin, "gui_action") + val sectorKey = NamespacedKey(plugin, "sector_id") + + meta.persistentDataContainer.set(actionKey, PersistentDataType.STRING, action) + meta.persistentDataContainer.set(sectorKey, PersistentDataType.INTEGER, sectorId) + + item.itemMeta = meta + return item + } } diff --git a/bin/main/net/hareworks/hcu/landsector/listener/SelectionListener.kt b/bin/main/net/hareworks/hcu/landsector/listener/SelectionListener.kt new file mode 100644 index 0000000..a2f3546 --- /dev/null +++ b/bin/main/net/hareworks/hcu/landsector/listener/SelectionListener.kt @@ -0,0 +1,188 @@ +package net.hareworks.hcu.landsector.listener + +import net.hareworks.hcu.landsector.LandSectorPlugin +import net.hareworks.hcu.landsector.service.SelectionService +import net.hareworks.hcu.landsector.service.SectorService +import net.hareworks.hcu.landsector.model.SelectionMode +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.format.NamedTextColor +import org.bukkit.Material +import org.bukkit.NamespacedKey +import org.bukkit.event.EventHandler +import org.bukkit.event.Listener +import org.bukkit.event.block.Action +import org.bukkit.event.player.PlayerInteractEvent +import org.bukkit.persistence.PersistentDataType +import org.bukkit.inventory.EquipmentSlot + +class SelectionListener( + private val plugin: LandSectorPlugin, + private val selectionService: SelectionService, + private val sectorService: SectorService +) : Listener { + + @EventHandler + fun onInteract(event: PlayerInteractEvent) { + val item = event.item ?: return + if (item.type != Material.FLINT) return + + val key = NamespacedKey(plugin, "component") + if (item.itemMeta?.persistentDataContainer?.get(key, PersistentDataType.STRING) != "land_sector_tool") { + return + } + + // Only Main Hand to avoid double fire + if (event.hand != EquipmentSlot.HAND) return + + val player = event.player + val selection = selectionService.getSelection(player.uniqueId) + + // Left Click: Switch Mode + if (event.action == Action.LEFT_CLICK_AIR || event.action == Action.LEFT_CLICK_BLOCK) { + event.isCancelled = true // Prevent breaking block + + selection.mode = if (selection.mode == SelectionMode.CUBOID) SelectionMode.CYLINDER else SelectionMode.CUBOID + player.sendMessage(Component.text("Mode switched to: ${selection.mode}", NamedTextColor.YELLOW)) + + // Reset selection on mode switch + selection.point1 = null + selection.point2 = null + selection.p1Sneaking = false + return + } + + // Right Click: Set Points + if (event.action == Action.RIGHT_CLICK_BLOCK) { + event.isCancelled = true // Prevent placing + + val clickedBlock = event.clickedBlock ?: return + val loc = clickedBlock.location + + if (selection.point1 == null) { + // Set Point 1 + selection.point1 = loc + selection.p1Sneaking = player.isSneaking + selection.point2 = null // Clear p2 just in case + + player.sendMessage(Component.text("Position 1 set at ${loc.blockX}, ${loc.blockY}, ${loc.blockZ} (Mode: ${selection.mode}, Sneaking: ${selection.p1Sneaking})", NamedTextColor.GREEN)) + } else { + // Set Point 2 + // If P2 is already set, reset to P1 new? standard standard is cyclic. + if (selection.point2 != null) { + // Resetting, treat as P1 + selection.point1 = loc + selection.p1Sneaking = player.isSneaking + selection.point2 = null + player.sendMessage(Component.text("Selection reset. Position 1 set at ${loc.blockX}, ${loc.blockY}, ${loc.blockZ} (Mode: ${selection.mode}, Sneaking: ${selection.p1Sneaking})", NamedTextColor.GREEN)) + } else { + selection.point2 = loc + // Check for sector ID + val itemStack = event.item + val sectorKey = NamespacedKey(plugin, "sector_id") + if (itemStack != null && itemStack.itemMeta.persistentDataContainer.has(sectorKey, PersistentDataType.INTEGER)) { + val sId = itemStack.itemMeta.persistentDataContainer.get(sectorKey, PersistentDataType.INTEGER)!! + val p1 = selection.point1!! + val p2 = selection.point2!! + + // Add Range + val range = sectorService.addRange( + sId, + selection.mode, + p1.blockX, p1.blockY, p1.blockZ, + p2.blockX, p2.blockY, p2.blockZ, + selection.p1Sneaking + ) + + player.sendMessage(Component.text("Range added to Sector #$sId.", NamedTextColor.GREEN)) + + // Clear selection + selection.point1 = null + selection.point2 = null + } else { + player.sendMessage(Component.text("Position 2 set at ${loc.blockX}, ${loc.blockY}, ${loc.blockZ}", NamedTextColor.GREEN)) + + // Show Cost / Area + player.sendMessage(Component.text(selectionService.getAreaDetails(player.uniqueId), NamedTextColor.AQUA)) + player.sendMessage(Component.text("Cost: ${selectionService.getCost(player.uniqueId)}", NamedTextColor.GOLD)) + } + } + } + } + } + + @EventHandler + fun onDrop(event: org.bukkit.event.player.PlayerDropItemEvent) { + val item = event.itemDrop.itemStack + val key = NamespacedKey(plugin, "component") + if (item.itemMeta?.persistentDataContainer?.get(key, PersistentDataType.STRING) == "land_sector_tool") { + event.isCancelled = true + event.player.sendMessage(Component.text("You cannot drop this tool.", NamedTextColor.RED)) + } + } + + @EventHandler + fun onInventoryClick(event: org.bukkit.event.inventory.InventoryClickEvent) { + val player = event.whoClicked as? org.bukkit.entity.Player ?: return + val clickedInv = event.clickedInventory + val action = event.action + + fun isTool(stack: org.bukkit.inventory.ItemStack?): Boolean { + if (stack == null || stack.type != Material.FLINT) return false + val key = NamespacedKey(plugin, "component") + return stack.itemMeta?.persistentDataContainer?.get(key, PersistentDataType.STRING) == "land_sector_tool" + } + + // 1. Prevent dropping via inventory + if (action.name.startsWith("DROP")) { + if (isTool(event.currentItem) || isTool(event.cursor)) { + event.isCancelled = true + player.sendMessage(Component.text("You cannot drop this tool.", NamedTextColor.RED)) + return + } + } + + // 2. Prevent interaction with tool in external inventories (Top Inventory) + // If clicking inside an inventory that is NOT the player's inventory + if (clickedInv != null && clickedInv != player.inventory) { + // If trying to place tool (Cursor has tool) + if (isTool(event.cursor)) { + event.isCancelled = true + player.sendMessage(Component.text("You cannot store this tool.", NamedTextColor.RED)) + return + } + + // If trying to swap tool from hotbar (Number key) + if (event.click == org.bukkit.event.inventory.ClickType.NUMBER_KEY) { + val swappedItem = player.inventory.getItem(event.hotbarButton) + if (isTool(swappedItem)) { + event.isCancelled = true + player.sendMessage(Component.text("You cannot store this tool.", NamedTextColor.RED)) + return + } + } + + // If trying to take tool IS in external inventory? (Shouldn't happen, but prevent taking just in case) + // This safeguards if somehow it got there. + if (isTool(event.currentItem)) { + event.isCancelled = true + // Allow them to break the item? No, just block interaction. + return + } + } + + // 3. Prevent Shift-Clicking tool FROM player inventory TO external inventory + if (clickedInv == player.inventory && event.isShiftClick) { + if (isTool(event.currentItem)) { + // If top inventory is NOT Crafting/Creative (Personal), block transfer + val topType = event.view.topInventory.type + if (topType != org.bukkit.event.inventory.InventoryType.CRAFTING && + topType != org.bukkit.event.inventory.InventoryType.CREATIVE) { + + event.isCancelled = true + player.sendMessage(Component.text("You cannot store this tool.", NamedTextColor.RED)) + return + } + } + } + } +} diff --git a/bin/main/net/hareworks/hcu/landsector/model/Sector.kt b/bin/main/net/hareworks/hcu/landsector/model/Sector.kt index 9c6fb83..053efad 100644 --- a/bin/main/net/hareworks/hcu/landsector/model/Sector.kt +++ b/bin/main/net/hareworks/hcu/landsector/model/Sector.kt @@ -6,5 +6,6 @@ data class Sector( val world: String, val x: Int, val y: Int, - val z: Int + val z: Int, + val hp: Int ) diff --git a/bin/main/net/hareworks/hcu/landsector/model/SectorRange.kt b/bin/main/net/hareworks/hcu/landsector/model/SectorRange.kt new file mode 100644 index 0000000..292bb29 --- /dev/null +++ b/bin/main/net/hareworks/hcu/landsector/model/SectorRange.kt @@ -0,0 +1,14 @@ +package net.hareworks.hcu.landsector.model + +data class SectorRange( + val id: Int, + val sectorId: Int, + val type: SelectionMode, + val x1: Int, + val y1: Int, + val z1: Int, + val x2: Int, + val y2: Int, + val z2: Int, + val isSneaking: Boolean +) diff --git a/bin/main/net/hareworks/hcu/landsector/model/SelectionData.kt b/bin/main/net/hareworks/hcu/landsector/model/SelectionData.kt new file mode 100644 index 0000000..9ca2428 --- /dev/null +++ b/bin/main/net/hareworks/hcu/landsector/model/SelectionData.kt @@ -0,0 +1,15 @@ +package net.hareworks.hcu.landsector.model + +import org.bukkit.Location + +enum class SelectionMode { + CUBOID, + CYLINDER +} + +data class SelectionData( + var mode: SelectionMode = SelectionMode.CUBOID, + var point1: Location? = null, + var point2: Location? = null, + var p1Sneaking: Boolean = false +) diff --git a/bin/main/net/hareworks/hcu/landsector/service/SectorService.kt b/bin/main/net/hareworks/hcu/landsector/service/SectorService.kt index 3cdd888..88f49e0 100644 --- a/bin/main/net/hareworks/hcu/landsector/service/SectorService.kt +++ b/bin/main/net/hareworks/hcu/landsector/service/SectorService.kt @@ -1,20 +1,44 @@ package net.hareworks.hcu.landsector.service import net.hareworks.hcu.landsector.database.SectorsTable +import net.hareworks.hcu.landsector.database.SectorRangesTable import net.hareworks.hcu.landsector.model.Sector +import net.hareworks.hcu.landsector.model.SectorRange +import net.hareworks.hcu.landsector.model.SelectionMode import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.jdbc.Database import org.jetbrains.exposed.v1.jdbc.SchemaUtils import org.jetbrains.exposed.v1.jdbc.insert import org.jetbrains.exposed.v1.jdbc.selectAll +import org.jetbrains.exposed.v1.jdbc.select +import org.jetbrains.exposed.v1.jdbc.update import org.jetbrains.exposed.v1.jdbc.andWhere +import org.jetbrains.exposed.v1.jdbc.deleteWhere import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import java.util.concurrent.ConcurrentHashMap class SectorService(private val database: Database) { + private val hpCache = ConcurrentHashMap() + private val dirtySet = ConcurrentHashMap.newKeySet() + private val sectorsCache = ConcurrentHashMap() + fun init() { transaction(database) { - SchemaUtils.createMissingTablesAndColumns(SectorsTable) + SchemaUtils.createMissingTablesAndColumns(SectorsTable, SectorRangesTable) + SectorsTable.selectAll().forEach { + val id = it[SectorsTable.id] + val sector = Sector( + id, + it[SectorsTable.ownerActorId], + it[SectorsTable.world], + it[SectorsTable.x], + it[SectorsTable.y], + it[SectorsTable.z], + it[SectorsTable.hp] + ) + sectorsCache[id] = sector + } } } @@ -26,29 +50,223 @@ class SectorService(private val database: Database) { it[SectorsTable.x] = x it[SectorsTable.y] = y it[SectorsTable.z] = z + it[SectorsTable.hp] = 1000 }[SectorsTable.id] - Sector(id, ownerActorId, world, x, y, z) + hpCache[id] = 1000 + val sector = Sector(id, ownerActorId, world, x, y, z, 1000) + sectorsCache[id] = sector + sector } } fun getSectorAt(world: String, x: Int, y: Int, z: Int): Sector? { - return transaction(database) { - SectorsTable.selectAll() - .andWhere { SectorsTable.world eq world } - .andWhere { SectorsTable.x eq x } - .andWhere { SectorsTable.y eq y } - .andWhere { SectorsTable.z eq z } - .map { + // Optimized to use cache + return sectorsCache.values.firstOrNull { + it.world == world && it.x == x && it.y == y && it.z == z + } + } + + fun getSector(id: Int): Sector? { + return sectorsCache[id] ?: transaction(database) { + SectorsTable.selectAll().andWhere { SectorsTable.id eq id }.singleOrNull()?.let { Sector( it[SectorsTable.id], it[SectorsTable.ownerActorId], it[SectorsTable.world], it[SectorsTable.x], it[SectorsTable.y], - it[SectorsTable.z] + it[SectorsTable.z], + it[SectorsTable.hp] ) - }.singleOrNull() + } + } + } + + fun getHealth(id: Int): Int? { + // Try get from cache + var currentHp = hpCache[id] + + // If not in cache, load from DB + if (currentHp == null) { + val sector = sectorsCache[id] ?: return null + + currentHp = sector.hp + hpCache[id] = currentHp + } + return currentHp + } + + fun reduceHealth(id: Int, amount: Int): Int? { + // Try get from cache + var currentHp = hpCache[id] + + // If not in cache, try to init from sectorsCache + if (currentHp == null) { + val sector = sectorsCache[id] ?: return null + currentHp = sector.hp + hpCache[id] = currentHp + } + + val newHp = (currentHp!! - amount).coerceAtLeast(0) + hpCache[id] = newHp + dirtySet.add(id) + + return newHp + } + + fun flushChanges() { + if (dirtySet.isEmpty()) return + + // Take a snapshot of keys to update + val toUpdate = dirtySet.toMutableSet() + dirtySet.removeAll(toUpdate) // Clear them from dirty set so we don't re-save unless modified again + + if (toUpdate.isEmpty()) return + + transaction(database) { + toUpdate.forEach { id -> + val hp = hpCache[id] ?: return@forEach + SectorsTable.update({ SectorsTable.id eq id }) { + it[SectorsTable.hp] = hp + } + } + } + } + + fun deleteSector(id: Int): Sector? { + val sector = transaction(database) { + val record = SectorsTable.selectAll().andWhere { SectorsTable.id eq id }.singleOrNull() ?: return@transaction null + + SectorRangesTable.deleteWhere { SectorRangesTable.sectorId eq id } + SectorsTable.deleteWhere { SectorsTable.id eq id } + + Sector( + record[SectorsTable.id], + record[SectorsTable.ownerActorId], + record[SectorsTable.world], + record[SectorsTable.x], + record[SectorsTable.y], + record[SectorsTable.z], + record[SectorsTable.hp] + ) + } + + if (sector != null) { + hpCache.remove(id) + dirtySet.remove(id) + sectorsCache.remove(id) + } + + return sector + } + + fun isSectorBlock(world: String, x: Int, y: Int, z: Int): Boolean { + // Sector blocks are at center.y - 1 (Base) and center.y + 1 (Top) + // Center is at sector.y + return sectorsCache.values.any { + it.world == world && + it.x == x && + it.z == z && + (it.y - 1 == y || it.y + 1 == y) + } + } + + fun isSectorArea(world: String, x: Int, y: Int, z: Int): Boolean { + // Check 3x3x3 around center (sector.x, sector.y, sector.z) + return sectorsCache.values.any { + it.world == world && + x >= it.x - 1 && x <= it.x + 1 && + y >= it.y - 1 && y <= it.y + 1 && + z >= it.z - 1 && z <= it.z + 1 + } + } + + fun exists(id: Int): Boolean { + if (hpCache.containsKey(id)) return true + + return transaction(database) { + SectorsTable.selectAll().andWhere { SectorsTable.id eq id }.count() > 0 + } + } + + fun getAllSectorsList(): List { + return transaction(database) { + SectorsTable.selectAll().map { + val id = it[SectorsTable.id] + val cachedHp = hpCache[id] + val hp = cachedHp ?: it[SectorsTable.hp] + Sector( + id, + it[SectorsTable.ownerActorId], + it[SectorsTable.world], + it[SectorsTable.x], + it[SectorsTable.y], + it[SectorsTable.z], + hp + ) + } + } + } + + fun getAllSectors(world: String): List { + return transaction(database) { + SectorsTable.selectAll() + .andWhere { SectorsTable.world eq world } + .map { + // Update HP from cache if exists + val id = it[SectorsTable.id] + val cachedHp = hpCache[id] + val hp = cachedHp ?: it[SectorsTable.hp] + + Sector( + id, + it[SectorsTable.ownerActorId], + it[SectorsTable.world], + it[SectorsTable.x], + it[SectorsTable.y], + it[SectorsTable.z], + hp + ) + } + } + } + + + fun addRange(sectorId: Int, mode: SelectionMode, x1: Int, y1: Int, z1: Int, x2: Int, y2: Int, z2: Int, isSneaking: Boolean): SectorRange { + return transaction(database) { + val id = SectorRangesTable.insert { + it[SectorRangesTable.sectorId] = sectorId + it[SectorRangesTable.type] = mode.name + it[SectorRangesTable.x1] = x1 + it[SectorRangesTable.y1] = y1 + it[SectorRangesTable.z1] = z1 + it[SectorRangesTable.x2] = x2 + it[SectorRangesTable.y2] = y2 + it[SectorRangesTable.z2] = z2 + it[SectorRangesTable.isSneaking] = isSneaking + }[SectorRangesTable.id] + + SectorRange(id, sectorId, mode, x1, y1, z1, x2, y2, z2, isSneaking) + } + } + + fun getRanges(sectorId: Int): List { + return transaction(database) { + SectorRangesTable.selectAll().andWhere { SectorRangesTable.sectorId eq sectorId }.map { + SectorRange( + it[SectorRangesTable.id], + it[SectorRangesTable.sectorId], + SelectionMode.valueOf(it[SectorRangesTable.type]), + it[SectorRangesTable.x1], + it[SectorRangesTable.y1], + it[SectorRangesTable.z1], + it[SectorRangesTable.x2], + it[SectorRangesTable.y2], + it[SectorRangesTable.z2], + it[SectorRangesTable.isSneaking] + ) + } } } } diff --git a/bin/main/net/hareworks/hcu/landsector/service/SelectionService.kt b/bin/main/net/hareworks/hcu/landsector/service/SelectionService.kt new file mode 100644 index 0000000..ad95dc2 --- /dev/null +++ b/bin/main/net/hareworks/hcu/landsector/service/SelectionService.kt @@ -0,0 +1,103 @@ +package net.hareworks.hcu.landsector.service + +import net.hareworks.hcu.landsector.model.SelectionData +import org.bukkit.Location +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import kotlin.math.abs +import kotlin.math.PI +import kotlin.math.ceil + +class SelectionService { + private val selections = ConcurrentHashMap() + + fun getSelection(uuid: UUID): SelectionData { + return selections.computeIfAbsent(uuid) { SelectionData() } + } + + fun clearSelection(uuid: UUID) { + selections.remove(uuid) + } + + fun getAreaDetails(uuid: UUID): String { + val data = getSelection(uuid) + if (data.point1 == null || data.point2 == null) { + return "No complete selection." + } + + val p1 = data.point1!! + val p2 = data.point2!! + + return if (data.mode == net.hareworks.hcu.landsector.model.SelectionMode.CUBOID) { + val width: Int + val height: Int + val length: Int + + if (!data.p1Sneaking) { + // Normal: P1 to P2 + width = abs(p1.blockX - p2.blockX) + 1 + height = abs(p1.blockY - p2.blockY) + 1 + length = abs(p1.blockZ - p2.blockZ) + 1 + } else { + // Sneak: P1 is center + val dx = abs(p1.blockX - p2.blockX) + val dy = abs(p1.blockY - p2.blockY) + val dz = abs(p1.blockZ - p2.blockZ) + width = dx * 2 + 1 + height = dy * 2 + 1 + length = dz * 2 + 1 + } + "Cuboid: ${width}x${height}x${length} (${width * height * length} blocks)" + } else { + // Cylinder + val dx = p1.blockX - p2.blockX + val dz = p1.blockZ - p2.blockZ + // Use ceil(dist - 0.5) to find the minimum integer radius R such that (dist <= R + 0.5) + // This ensures the point is included in the R+0.5 boundary without overshooting to the next integer. + val dist = Math.sqrt((dx * dx + dz * dz).toDouble()) + val baseRadius = ceil(dist - 0.5) + val radius = baseRadius + 0.5 + + val totalHeight = if (!data.p1Sneaking) { + // Normal: P1 center, symmetric height + val h = abs(p1.blockY - p2.blockY) + h * 2 + 1 + } else { + // Sneak: P1 base, P2 top + abs(p1.blockY - p2.blockY) + 1 + } + + val volume = PI * radius * radius * totalHeight + "Cylinder: r=${"%.0f".format(baseRadius)}, h=$totalHeight (~${ceil(volume).toInt()} blocks)" + } + } + + fun getCost(uuid: UUID): Double { + val data = getSelection(uuid) + if (data.point1 == null || data.point2 == null) { + return 0.0 + } + + // Placeholder cost logic: 1 block = 1 unit? + // Need to refine based on actual volume + val p1 = data.point1!! + val p2 = data.point2!! + + val volume = if (data.mode == net.hareworks.hcu.landsector.model.SelectionMode.CUBOID) { + val width = abs(p1.blockX - p2.blockX) + 1 + val height = abs(p1.blockY - p2.blockY) + 1 + val length = abs(p1.blockZ - p2.blockZ) + 1 + (width * height * length).toDouble() + } else { + val dx = p1.blockX - p2.blockX + val dz = p1.blockZ - p2.blockZ + val dist = Math.sqrt((dx * dx + dz * dz).toDouble()) + val baseRadius = ceil(dist - 0.5) + val radius = baseRadius + 0.5 + val height = abs(p1.blockY - p2.blockY) + 1 + PI * radius * radius * height + } + + return volume * 0.5 // Example rate + } +} diff --git a/bin/main/net/hareworks/hcu/landsector/task/SectorRotationTask.kt b/bin/main/net/hareworks/hcu/landsector/task/SectorRotationTask.kt new file mode 100644 index 0000000..6d87210 --- /dev/null +++ b/bin/main/net/hareworks/hcu/landsector/task/SectorRotationTask.kt @@ -0,0 +1,88 @@ +package net.hareworks.hcu.landsector.task + +import net.hareworks.hcu.landsector.LandSectorPlugin +import org.bukkit.Bukkit +import org.bukkit.Material +import org.bukkit.NamespacedKey +import org.bukkit.entity.BlockDisplay +import org.bukkit.entity.EntityType +import org.bukkit.persistence.PersistentDataType +import org.bukkit.scheduler.BukkitRunnable +import org.bukkit.util.Transformation +import org.joml.Quaternionf +import org.joml.Vector3f +import kotlin.math.PI + +class SectorRotationTask(private val plugin: LandSectorPlugin) : BukkitRunnable() { + + private val sectorKey = NamespacedKey(plugin, "sector_id") + private val scale = Vector3f(0.5f, 0.5f, 0.5f) + private val centerOffset = Vector3f(0.25f, 0.25f, 0.25f) // Scale 0.5 * Model Center (0.5, 0.5, 0.5) + + override fun run() { + val sectors = plugin.sectorService?.getAllSectorsList() ?: return + // Bukkit.getLogger().info("Task running. Sectors count: ${sectors.size}") + + sectors.forEach { sector -> + val world = Bukkit.getWorld(sector.world) ?: return@forEach + // Simple check if chunk is loaded to avoid loading chunks + val chunkX = sector.x shr 4 + val chunkZ = sector.z shr 4 + + if (!world.isChunkLoaded(chunkX, chunkZ)) { + // Bukkit.getLogger().info("Chunk not loaded for sector ${sector.id}") + return@forEach + } + + // Check entities at the core location + val centerLoc = org.bukkit.Location(world, sector.x + 0.5, sector.y + 0.5, sector.z + 0.5) + + // Search radius expanded to 2.0 to ensure hit + val entities = world.getNearbyEntities(centerLoc, 2.0, 2.0, 2.0) + + // Bukkit.getLogger().info("Checking sector ${sector.id} at $centerLoc. Found entities: ${entities.size}") + + entities.forEach { entity -> + if (entity.type == EntityType.BLOCK_DISPLAY && entity.persistentDataContainer.has(sectorKey, PersistentDataType.INTEGER)) { + val bd = entity as BlockDisplay + // Check if it's the command block + if (bd.block.material == Material.COMMAND_BLOCK) { + // Bukkit.getLogger().info("Rotater found target: ${bd.uniqueId}") + updateRotation(bd) + } + } + } + } + } + + private fun updateRotation(bd: BlockDisplay) { + // Continuous rotation based on time: 1 full rotation every 4 seconds + val periodMs = 4000L + val time = System.currentTimeMillis() + val phase = (time % periodMs).toDouble() / periodMs.toDouble() + val angle = (phase * 2 * Math.PI).toFloat() // 0 to 2PI + + // Log for debug (once per 100 ticks approx to avoid spam? No, just once to verify) + // Bukkit.getLogger().info("Rotating BD: angle=$angle") + + // Rotation around Y axis + val rot = Quaternionf().rotateY(angle) + + val centerOffset = Vector3f(0.25f, 0.25f, 0.25f) // The center in Scaled space + // T = - (R * C) + val rotatedOffset = Vector3f(centerOffset).rotate(rot) + val newTranslation = Vector3f(rotatedOffset).negate() + + val newTrans = Transformation( + newTranslation, + rot, + scale, + Quaternionf(0f, 0f, 0f, 1f) + ) + + // Interpolation settings + bd.interpolationDuration = 3 // Short duration to match high frequency update + bd.interpolationDelay = -1 // Start immediately + bd.transformation = newTrans + } +} diff --git a/bin/main/net/hareworks/hcu/landsector/task/SelectionVisualizerTask.kt b/bin/main/net/hareworks/hcu/landsector/task/SelectionVisualizerTask.kt new file mode 100644 index 0000000..e8b57e8 --- /dev/null +++ b/bin/main/net/hareworks/hcu/landsector/task/SelectionVisualizerTask.kt @@ -0,0 +1,195 @@ +package net.hareworks.hcu.landsector.task + +import net.hareworks.hcu.landsector.model.SelectionMode +import net.hareworks.hcu.landsector.service.SelectionService +import net.hareworks.hcu.visualizer.GeometryVisualizer +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.format.NamedTextColor +import org.bukkit.Bukkit +import org.bukkit.Color +import org.bukkit.Material +import org.bukkit.NamespacedKey +import org.bukkit.entity.Player +import org.bukkit.inventory.EquipmentSlot +import org.bukkit.persistence.PersistentDataType +import org.bukkit.plugin.java.JavaPlugin +import org.bukkit.scheduler.BukkitRunnable +import kotlin.math.abs + +class SelectionVisualizerTask( + private val plugin: JavaPlugin, + private val selectionService: SelectionService +) : BukkitRunnable() { + + override fun run() { + for (player in Bukkit.getOnlinePlayers()) { + visualize(player) + } + } + + private fun visualize(player: Player) { + // Check if holding tool + // Check if tool is in inventory + val key = NamespacedKey(plugin, "component") + val hasTool = player.inventory.contents.any { item -> + item != null && item.type == Material.FLINT && + item.itemMeta?.persistentDataContainer?.get(key, PersistentDataType.STRING) == "land_sector_tool" + } + + if (!hasTool) return + + val selection = selectionService.getSelection(player.uniqueId) + val p1 = selection.point1 ?: return + + // Determine P2: Either set in selection, or dynamic based on cursor if not set (or we show review for both?) + // If p2 is set, visualize that. + // If p2 is NOT set, visualize dynamic preview using target block. + + var p2 = selection.point2 + var isDynamic = false + + if (p2 == null) { + val target = player.getTargetBlockExact(30) + if (target != null) { + p2 = target.location + isDynamic = true + } + } + + if (p2 == null) return // No p2 and no target + + // If different world, ignore + if (p1.world != p2.world) return + + if (selection.mode == SelectionMode.CUBOID) { + // Cuboid visualization + var minX: Double + var minY: Double + var minZ: Double + var maxX: Double + var maxY: Double + var maxZ: Double + + if (!selection.p1Sneaking) { + // Normal: corner to corner + minX = minOf(p1.blockX, p2.blockX).toDouble() + minY = minOf(p1.blockY, p2.blockY).toDouble() + minZ = minOf(p1.blockZ, p2.blockZ).toDouble() + maxX = maxOf(p1.blockX, p2.blockX).toDouble() + 1.0 + maxY = maxOf(p1.blockY, p2.blockY).toDouble() + 1.0 + maxZ = maxOf(p1.blockZ, p2.blockZ).toDouble() + 1.0 + } else { + // Sneak: P1 center, P2 defines radius + val dx = abs(p1.blockX - p2.blockX) + val dy = abs(p1.blockY - p2.blockY) + val dz = abs(p1.blockZ - p2.blockZ) + + minX = (p1.blockX - dx).toDouble() + maxX = (p1.blockX + dx).toDouble() + 1.0 + minY = (p1.blockY - dy).toDouble() + maxY = (p1.blockY + dy).toDouble() + 1.0 + minZ = (p1.blockZ - dz).toDouble() + maxZ = (p1.blockZ + dz).toDouble() + 1.0 + } + + GeometryVisualizer.drawCuboid(player, minX, minY, minZ, maxX, maxY, maxZ) + + } else { + // Cylinder visualization + val centerX: Double + val centerY: Double + val centerZ: Double + val radius: Double + val minY: Double + val maxY: Double + + val dx = p1.blockX - p2.blockX + val dz = p1.blockZ - p2.blockZ + val dist = Math.sqrt((dx * dx + dz * dz).toDouble()) + radius = kotlin.math.ceil(dist - 0.5) + + if (!selection.p1Sneaking) { + // Normal: P1 center, symmetric height based on P2.y diff + centerX = p1.blockX + 0.5 + centerY = p1.blockY + 0.5 // Logic center + centerZ = p1.blockZ + 0.5 + + val hDiff = abs(p1.blockY - p2.blockY) + // Bottom and Top + // From center block center, go down hDiff blocks (and include center block?) + // If P1.y = 10, P2.y = 12. Diff 2. + // Blocks: 8, 9, 10, 11, 12. Range [8, 12]. Height 5. (2*2 + 1) + + // Block coords: + val baseBlockY = p1.blockY - hDiff + val topBlockY = p1.blockY + hDiff + + minY = baseBlockY.toDouble() + maxY = topBlockY.toDouble() + 1.0 + } else { + // Sneak: P1 base center. P2 top center. + centerX = p1.blockX + 0.5 + centerZ = p1.blockZ + 0.5 + // Only height from p2 + val y1 = p1.blockY + val y2 = p2.blockY + + val baseBlockY = minOf(y1, y2) + val topBlockY = maxOf(y1, y2) + + minY = baseBlockY.toDouble() + maxY = topBlockY.toDouble() + 1.0 + } + + // Calculate blocks for cylinder outline + val cX = kotlin.math.floor(centerX).toInt() + val cZ = kotlin.math.floor(centerZ).toInt() + val blocks = mutableSetOf>() + // Add 0.5 to radius to encompass the full block width of the boundary blocks + val actualRadius = radius + 0.5 + val radiusSq = actualRadius * actualRadius + val rInt = actualRadius.toInt() + 1 + + for (dx in -rInt..rInt) { + for (dz in -rInt..rInt) { + // Check if block center is within the radius + if (dx * dx + dz * dz <= radiusSq) { + blocks.add(Pair(cX + dx, cZ + dz)) + } + } + } + + + // Draw wireframe circle (smooth) layered with block outline + // Wireframe uses the expanded radius to match the block outline visuals + GeometryVisualizer.drawCylinder(player, centerX, p1.blockY.toDouble(), centerZ, radius.toInt(), minY, maxY) + + val color = Color.fromRGB(100, 200, 255) + // Draw bottom surface outline + GeometryVisualizer.drawBlockSurfaceOutline( + player, + minY, + blocks, + { _, _, _ -> false }, + color, + minY + ) + + // Draw top surface outline + GeometryVisualizer.drawBlockSurfaceOutline( + player, + maxY, + blocks, + { _, _, _ -> false }, + color, + maxY + ) + + // Vertical edges for the outline? + // The current generic visualizer doesn't have a specific "drawVerticalConnectors" for this map-based approach easily accessible + // or we'd have to iterate edges. + // For now, surface outlines should be sufficient as requested. + } + + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 43c068a..adfcc59 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -32,6 +32,9 @@ dependencies { compileOnly("net.hareworks:permits-lib") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0") + // Visualizer Lib (Bundled & Relocated) + implementation("net.hareworks.hcu:visualizer-lib:1.0") + // Database (Exposed v1) compileOnly("org.jetbrains.exposed:exposed-core:$exposedVersion") compileOnly("org.jetbrains.exposed:exposed-dao:$exposedVersion") @@ -55,6 +58,8 @@ tasks { exclude(dependency("org.jetbrains.kotlin:kotlin-stdlib-jdk8")) exclude(dependency("org.jetbrains.kotlin:kotlin-stdlib-jdk7")) } + + relocate("net.hareworks.hcu.visualizer", "net.hareworks.hcu.landsector.libs.visualizer") } } diff --git a/src/main/kotlin/net/hareworks/hcu/landsector/LandSectorPlugin.kt b/src/main/kotlin/net/hareworks/hcu/landsector/LandSectorPlugin.kt index 03166e9..acc9d30 100644 --- a/src/main/kotlin/net/hareworks/hcu/landsector/LandSectorPlugin.kt +++ b/src/main/kotlin/net/hareworks/hcu/landsector/LandSectorPlugin.kt @@ -4,6 +4,7 @@ import net.hareworks.hcu.core.Main import net.hareworks.hcu.core.player.PlayerIdService import net.hareworks.hcu.landsector.command.LandSectorCommand import net.hareworks.hcu.landsector.listener.SectorListener +import net.hareworks.hcu.landsector.service.SelectionService import net.hareworks.hcu.landsector.service.SectorService import org.bukkit.plugin.java.JavaPlugin @@ -15,10 +16,13 @@ class LandSectorPlugin : JavaPlugin() { } var sectorService: SectorService? = null + var selectionService: SelectionService? = null override fun onEnable() { instance = this + selectionService = SelectionService() + // Register commands LandSectorCommand(this).register() @@ -57,7 +61,16 @@ class LandSectorPlugin : JavaPlugin() { return } - server.pluginManager.registerEvents(SectorListener(this, service, pIdService), this) + val selService = selectionService + if (selService != null) { + server.pluginManager.registerEvents(SectorListener(this, service, pIdService, selService), this) + server.pluginManager.registerEvents(net.hareworks.hcu.landsector.listener.SelectionListener(this, selService, service), this) + + // Schedule visualization task + net.hareworks.hcu.landsector.task.SelectionVisualizerTask(this, selService).runTaskTimer(this, 0L, 4L) + } else { + logger.severe("SelectionService not initialized!") + } // Schedule auto-save every 5 minutes server.scheduler.runTaskTimerAsynchronously(this, Runnable { diff --git a/src/main/kotlin/net/hareworks/hcu/landsector/command/LandSectorCommand.kt b/src/main/kotlin/net/hareworks/hcu/landsector/command/LandSectorCommand.kt index d3a24da..aba7ee9 100644 --- a/src/main/kotlin/net/hareworks/hcu/landsector/command/LandSectorCommand.kt +++ b/src/main/kotlin/net/hareworks/hcu/landsector/command/LandSectorCommand.kt @@ -18,6 +18,45 @@ class LandSectorCommand(private val landSectorPlugin: LandSectorPlugin) { fun register() { kommand(landSectorPlugin) { command("landsector") { + literal("givetool") { + executes { + val player = sender as? Player ?: return@executes + giveTool(player, null) + } + integer("sectorId") { + executes { + val player = sender as? Player ?: return@executes + val id = argument("sectorId") + giveTool(player, id) + } + } + } + + literal("activate") { + integer("sectorId") { + executes { + val id = argument("sectorId") + sender.sendMessage(Component.text("Activation for Sector #$id pending implementation.", NamedTextColor.YELLOW)) + // TODO: Validate selection and lock in + } + } + } + + literal("cancel") { + integer("sectorId") { + executes { + val id = argument("sectorId") + sender.sendMessage(Component.text("Cancellation for Sector #$id pending implementation.", NamedTextColor.YELLOW)) + // TODO: Cancel logic + val player = sender as? Player + if (player != null) { + landSectorPlugin.selectionService?.clearSelection(player.uniqueId) + sender.sendMessage(Component.text("Selection cleared.", NamedTextColor.RED)) + } + } + } + } + literal("give") { executes { val player = sender as? Player ?: return@executes @@ -129,4 +168,31 @@ class LandSectorCommand(private val landSectorPlugin: LandSectorPlugin) { } } } + + private fun giveTool(player: Player, sectorId: Int?) { + val item = ItemStack(Material.FLINT) + val meta = item.itemMeta + meta.displayName(Component.text("Range Selection Tool" + (if (sectorId != null) " (#$sectorId)" else ""), NamedTextColor.AQUA)) + meta.persistentDataContainer.set( + NamespacedKey(landSectorPlugin, "component"), + PersistentDataType.STRING, + "land_sector_tool" + ) + if (sectorId != null) { + meta.persistentDataContainer.set( + NamespacedKey(landSectorPlugin, "sector_id"), + PersistentDataType.INTEGER, + sectorId + ) + } + meta.lore(listOf( + Component.text("Left Click: Switch Mode", NamedTextColor.GRAY), + Component.text("Right Click: Select Position", NamedTextColor.GRAY), + Component.text("Sneaking acts as modifier", NamedTextColor.DARK_GRAY) + ) + if (sectorId != null) listOf(Component.text("Linked to Sector #$sectorId", NamedTextColor.GOLD)) else emptyList()) + item.itemMeta = meta + + player.inventory.addItem(item) + player.sendMessage(Component.text("Gave Range Selection Tool${if (sectorId != null) " for Sector #$sectorId" else ""}.", NamedTextColor.GREEN)) + } } diff --git a/src/main/kotlin/net/hareworks/hcu/landsector/database/SectorsTable.kt b/src/main/kotlin/net/hareworks/hcu/landsector/database/SectorsTable.kt index 8e6f1a3..fc94862 100644 --- a/src/main/kotlin/net/hareworks/hcu/landsector/database/SectorsTable.kt +++ b/src/main/kotlin/net/hareworks/hcu/landsector/database/SectorsTable.kt @@ -13,3 +13,18 @@ object SectorsTable : Table("land_sectors") { override val primaryKey = PrimaryKey(id) } + +object SectorRangesTable : Table("land_sector_ranges") { + val id = integer("id").autoIncrement() + val sectorId = integer("sector_id").references(SectorsTable.id) + val type = varchar("type", 16) + val x1 = integer("x1") + val y1 = integer("y1") + val z1 = integer("z1") + val x2 = integer("x2") + val y2 = integer("y2") + val z2 = integer("z2") + val isSneaking = bool("is_sneaking") + + override val primaryKey = PrimaryKey(id) +} diff --git a/src/main/kotlin/net/hareworks/hcu/landsector/listener/SectorListener.kt b/src/main/kotlin/net/hareworks/hcu/landsector/listener/SectorListener.kt index 2e99bdd..dbd4977 100644 --- a/src/main/kotlin/net/hareworks/hcu/landsector/listener/SectorListener.kt +++ b/src/main/kotlin/net/hareworks/hcu/landsector/listener/SectorListener.kt @@ -27,10 +27,15 @@ import org.joml.Vector3f import org.joml.Quaternionf import org.bukkit.block.data.type.Slab +import net.hareworks.hcu.landsector.service.SelectionService +import net.kyori.adventure.text.event.ClickEvent +import org.bukkit.event.player.PlayerInteractEntityEvent + class SectorListener( private val plugin: LandSectorPlugin, private val sectorService: SectorService, - private val playerIdService: PlayerIdService + private val playerIdService: PlayerIdService, + private val selectionService: SelectionService ) : Listener { @EventHandler @@ -194,6 +199,78 @@ class SectorListener( player.sendMessage(Component.text("Sector Core placed!", NamedTextColor.GREEN)) } + @EventHandler + fun onInteractEntity(event: PlayerInteractEntityEvent) { + if (event.hand != org.bukkit.inventory.EquipmentSlot.HAND) return + val entity = event.rightClicked + if (entity !is Shulker) return + + val sectorKey = NamespacedKey(plugin, "sector_id") + if (!entity.persistentDataContainer.has(sectorKey, PersistentDataType.INTEGER)) return + + val sectorId = entity.persistentDataContainer.get(sectorKey, PersistentDataType.INTEGER) ?: return + val player = event.player + + // Ownership Check + val pEntry = playerIdService.find(player.uniqueId) + if (pEntry == null) return + val sector = sectorService.getSector(sectorId) ?: return + + if (sector.ownerActorId != pEntry.actorId) { + player.sendMessage(Component.text("You are not the owner of this sector.", NamedTextColor.RED)) + return + } + + // Open GUI + val gui = org.bukkit.Bukkit.createInventory(null, 27, Component.text("Sector Manager (#${sectorId})", NamedTextColor.BLACK)) + + // Info Item with Ranges + val ranges = sectorService.getRanges(sectorId) + val rangeLore = ranges.map { Component.text(" - [${it.id}] ${it.type}", NamedTextColor.GRAY) } + val infoLore = mutableListOf() + infoLore.add(Component.text("Sector ID: $sectorId", NamedTextColor.WHITE)) + infoLore.addAll(rangeLore) + + gui.setItem(4, createGuiItem( + Material.PAPER, + Component.text("Sector Info", NamedTextColor.GOLD), + infoLore, + sectorId, + "info" + )) + + // Get Selection Tool + gui.setItem(11, createGuiItem( + Material.FLINT, + Component.text("Get Selection Tool", NamedTextColor.AQUA), + listOf(Component.text("Click to receive the selection tool.", NamedTextColor.GRAY)), + sectorId, + "give_tool" + )) + + // Activate + gui.setItem(13, createGuiItem( + Material.LIME_CONCRETE, + Component.text("Activate Sector", NamedTextColor.GREEN), + listOf(Component.text("Click to activate this sector.", NamedTextColor.GRAY)), + sectorId, + "activate" + )) + + // Cancel / Destroy + gui.setItem(15, createGuiItem( + Material.RED_CONCRETE, + Component.text("Cancel / Destroy", NamedTextColor.RED), + listOf(Component.text("Click to destroy this sector.", NamedTextColor.GRAY)), + sectorId, + "cancel" + )) + + player.openInventory(gui) + + event.isCancelled = true + } + @EventHandler fun onDamage(event: EntityDamageEvent) { val entity = event.entity @@ -343,4 +420,47 @@ class SectorListener( } } } + + @EventHandler + fun onInventoryClick(event: org.bukkit.event.inventory.InventoryClickEvent) { + val item = event.currentItem ?: return + val meta = item.itemMeta ?: return + val pluginKey = NamespacedKey(plugin, "gui_action") + + if (!meta.persistentDataContainer.has(pluginKey, PersistentDataType.STRING)) return + + event.isCancelled = true // Prevent taking items + + val player = event.whoClicked as? Player ?: return + val action = meta.persistentDataContainer.get(pluginKey, PersistentDataType.STRING) + val sectorKey = NamespacedKey(plugin, "sector_id") + val sectorId = meta.persistentDataContainer.get(sectorKey, PersistentDataType.INTEGER) ?: return + + player.closeInventory() + + when (action) { + "give_tool" -> player.performCommand("landsector givetool $sectorId") + "activate" -> player.performCommand("landsector activate $sectorId") + "cancel" -> player.performCommand("landsector cancel $sectorId") + "info" -> { + player.playSound(player.location, Sound.UI_BUTTON_CLICK, 1f, 1f) + } + } + } + + private fun createGuiItem(material: Material, name: Component, lore: List, sectorId: Int, action: String): org.bukkit.inventory.ItemStack { + val item = org.bukkit.inventory.ItemStack(material) + val meta = item.itemMeta + meta.displayName(name) + meta.lore(lore) + + val actionKey = NamespacedKey(plugin, "gui_action") + val sectorKey = NamespacedKey(plugin, "sector_id") + + meta.persistentDataContainer.set(actionKey, PersistentDataType.STRING, action) + meta.persistentDataContainer.set(sectorKey, PersistentDataType.INTEGER, sectorId) + + item.itemMeta = meta + return item + } } diff --git a/src/main/kotlin/net/hareworks/hcu/landsector/listener/SelectionListener.kt b/src/main/kotlin/net/hareworks/hcu/landsector/listener/SelectionListener.kt new file mode 100644 index 0000000..a2f3546 --- /dev/null +++ b/src/main/kotlin/net/hareworks/hcu/landsector/listener/SelectionListener.kt @@ -0,0 +1,188 @@ +package net.hareworks.hcu.landsector.listener + +import net.hareworks.hcu.landsector.LandSectorPlugin +import net.hareworks.hcu.landsector.service.SelectionService +import net.hareworks.hcu.landsector.service.SectorService +import net.hareworks.hcu.landsector.model.SelectionMode +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.format.NamedTextColor +import org.bukkit.Material +import org.bukkit.NamespacedKey +import org.bukkit.event.EventHandler +import org.bukkit.event.Listener +import org.bukkit.event.block.Action +import org.bukkit.event.player.PlayerInteractEvent +import org.bukkit.persistence.PersistentDataType +import org.bukkit.inventory.EquipmentSlot + +class SelectionListener( + private val plugin: LandSectorPlugin, + private val selectionService: SelectionService, + private val sectorService: SectorService +) : Listener { + + @EventHandler + fun onInteract(event: PlayerInteractEvent) { + val item = event.item ?: return + if (item.type != Material.FLINT) return + + val key = NamespacedKey(plugin, "component") + if (item.itemMeta?.persistentDataContainer?.get(key, PersistentDataType.STRING) != "land_sector_tool") { + return + } + + // Only Main Hand to avoid double fire + if (event.hand != EquipmentSlot.HAND) return + + val player = event.player + val selection = selectionService.getSelection(player.uniqueId) + + // Left Click: Switch Mode + if (event.action == Action.LEFT_CLICK_AIR || event.action == Action.LEFT_CLICK_BLOCK) { + event.isCancelled = true // Prevent breaking block + + selection.mode = if (selection.mode == SelectionMode.CUBOID) SelectionMode.CYLINDER else SelectionMode.CUBOID + player.sendMessage(Component.text("Mode switched to: ${selection.mode}", NamedTextColor.YELLOW)) + + // Reset selection on mode switch + selection.point1 = null + selection.point2 = null + selection.p1Sneaking = false + return + } + + // Right Click: Set Points + if (event.action == Action.RIGHT_CLICK_BLOCK) { + event.isCancelled = true // Prevent placing + + val clickedBlock = event.clickedBlock ?: return + val loc = clickedBlock.location + + if (selection.point1 == null) { + // Set Point 1 + selection.point1 = loc + selection.p1Sneaking = player.isSneaking + selection.point2 = null // Clear p2 just in case + + player.sendMessage(Component.text("Position 1 set at ${loc.blockX}, ${loc.blockY}, ${loc.blockZ} (Mode: ${selection.mode}, Sneaking: ${selection.p1Sneaking})", NamedTextColor.GREEN)) + } else { + // Set Point 2 + // If P2 is already set, reset to P1 new? standard standard is cyclic. + if (selection.point2 != null) { + // Resetting, treat as P1 + selection.point1 = loc + selection.p1Sneaking = player.isSneaking + selection.point2 = null + player.sendMessage(Component.text("Selection reset. Position 1 set at ${loc.blockX}, ${loc.blockY}, ${loc.blockZ} (Mode: ${selection.mode}, Sneaking: ${selection.p1Sneaking})", NamedTextColor.GREEN)) + } else { + selection.point2 = loc + // Check for sector ID + val itemStack = event.item + val sectorKey = NamespacedKey(plugin, "sector_id") + if (itemStack != null && itemStack.itemMeta.persistentDataContainer.has(sectorKey, PersistentDataType.INTEGER)) { + val sId = itemStack.itemMeta.persistentDataContainer.get(sectorKey, PersistentDataType.INTEGER)!! + val p1 = selection.point1!! + val p2 = selection.point2!! + + // Add Range + val range = sectorService.addRange( + sId, + selection.mode, + p1.blockX, p1.blockY, p1.blockZ, + p2.blockX, p2.blockY, p2.blockZ, + selection.p1Sneaking + ) + + player.sendMessage(Component.text("Range added to Sector #$sId.", NamedTextColor.GREEN)) + + // Clear selection + selection.point1 = null + selection.point2 = null + } else { + player.sendMessage(Component.text("Position 2 set at ${loc.blockX}, ${loc.blockY}, ${loc.blockZ}", NamedTextColor.GREEN)) + + // Show Cost / Area + player.sendMessage(Component.text(selectionService.getAreaDetails(player.uniqueId), NamedTextColor.AQUA)) + player.sendMessage(Component.text("Cost: ${selectionService.getCost(player.uniqueId)}", NamedTextColor.GOLD)) + } + } + } + } + } + + @EventHandler + fun onDrop(event: org.bukkit.event.player.PlayerDropItemEvent) { + val item = event.itemDrop.itemStack + val key = NamespacedKey(plugin, "component") + if (item.itemMeta?.persistentDataContainer?.get(key, PersistentDataType.STRING) == "land_sector_tool") { + event.isCancelled = true + event.player.sendMessage(Component.text("You cannot drop this tool.", NamedTextColor.RED)) + } + } + + @EventHandler + fun onInventoryClick(event: org.bukkit.event.inventory.InventoryClickEvent) { + val player = event.whoClicked as? org.bukkit.entity.Player ?: return + val clickedInv = event.clickedInventory + val action = event.action + + fun isTool(stack: org.bukkit.inventory.ItemStack?): Boolean { + if (stack == null || stack.type != Material.FLINT) return false + val key = NamespacedKey(plugin, "component") + return stack.itemMeta?.persistentDataContainer?.get(key, PersistentDataType.STRING) == "land_sector_tool" + } + + // 1. Prevent dropping via inventory + if (action.name.startsWith("DROP")) { + if (isTool(event.currentItem) || isTool(event.cursor)) { + event.isCancelled = true + player.sendMessage(Component.text("You cannot drop this tool.", NamedTextColor.RED)) + return + } + } + + // 2. Prevent interaction with tool in external inventories (Top Inventory) + // If clicking inside an inventory that is NOT the player's inventory + if (clickedInv != null && clickedInv != player.inventory) { + // If trying to place tool (Cursor has tool) + if (isTool(event.cursor)) { + event.isCancelled = true + player.sendMessage(Component.text("You cannot store this tool.", NamedTextColor.RED)) + return + } + + // If trying to swap tool from hotbar (Number key) + if (event.click == org.bukkit.event.inventory.ClickType.NUMBER_KEY) { + val swappedItem = player.inventory.getItem(event.hotbarButton) + if (isTool(swappedItem)) { + event.isCancelled = true + player.sendMessage(Component.text("You cannot store this tool.", NamedTextColor.RED)) + return + } + } + + // If trying to take tool IS in external inventory? (Shouldn't happen, but prevent taking just in case) + // This safeguards if somehow it got there. + if (isTool(event.currentItem)) { + event.isCancelled = true + // Allow them to break the item? No, just block interaction. + return + } + } + + // 3. Prevent Shift-Clicking tool FROM player inventory TO external inventory + if (clickedInv == player.inventory && event.isShiftClick) { + if (isTool(event.currentItem)) { + // If top inventory is NOT Crafting/Creative (Personal), block transfer + val topType = event.view.topInventory.type + if (topType != org.bukkit.event.inventory.InventoryType.CRAFTING && + topType != org.bukkit.event.inventory.InventoryType.CREATIVE) { + + event.isCancelled = true + player.sendMessage(Component.text("You cannot store this tool.", NamedTextColor.RED)) + return + } + } + } + } +} diff --git a/src/main/kotlin/net/hareworks/hcu/landsector/model/SectorRange.kt b/src/main/kotlin/net/hareworks/hcu/landsector/model/SectorRange.kt new file mode 100644 index 0000000..292bb29 --- /dev/null +++ b/src/main/kotlin/net/hareworks/hcu/landsector/model/SectorRange.kt @@ -0,0 +1,14 @@ +package net.hareworks.hcu.landsector.model + +data class SectorRange( + val id: Int, + val sectorId: Int, + val type: SelectionMode, + val x1: Int, + val y1: Int, + val z1: Int, + val x2: Int, + val y2: Int, + val z2: Int, + val isSneaking: Boolean +) diff --git a/src/main/kotlin/net/hareworks/hcu/landsector/model/SelectionData.kt b/src/main/kotlin/net/hareworks/hcu/landsector/model/SelectionData.kt new file mode 100644 index 0000000..9ca2428 --- /dev/null +++ b/src/main/kotlin/net/hareworks/hcu/landsector/model/SelectionData.kt @@ -0,0 +1,15 @@ +package net.hareworks.hcu.landsector.model + +import org.bukkit.Location + +enum class SelectionMode { + CUBOID, + CYLINDER +} + +data class SelectionData( + var mode: SelectionMode = SelectionMode.CUBOID, + var point1: Location? = null, + var point2: Location? = null, + var p1Sneaking: Boolean = false +) diff --git a/src/main/kotlin/net/hareworks/hcu/landsector/service/SectorService.kt b/src/main/kotlin/net/hareworks/hcu/landsector/service/SectorService.kt index 2ec7dfc..88f49e0 100644 --- a/src/main/kotlin/net/hareworks/hcu/landsector/service/SectorService.kt +++ b/src/main/kotlin/net/hareworks/hcu/landsector/service/SectorService.kt @@ -1,7 +1,10 @@ package net.hareworks.hcu.landsector.service import net.hareworks.hcu.landsector.database.SectorsTable +import net.hareworks.hcu.landsector.database.SectorRangesTable import net.hareworks.hcu.landsector.model.Sector +import net.hareworks.hcu.landsector.model.SectorRange +import net.hareworks.hcu.landsector.model.SelectionMode import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.jdbc.Database import org.jetbrains.exposed.v1.jdbc.SchemaUtils @@ -22,7 +25,7 @@ class SectorService(private val database: Database) { fun init() { transaction(database) { - SchemaUtils.createMissingTablesAndColumns(SectorsTable) + SchemaUtils.createMissingTablesAndColumns(SectorsTable, SectorRangesTable) SectorsTable.selectAll().forEach { val id = it[SectorsTable.id] val sector = Sector( @@ -64,6 +67,22 @@ class SectorService(private val database: Database) { } } + fun getSector(id: Int): Sector? { + return sectorsCache[id] ?: transaction(database) { + SectorsTable.selectAll().andWhere { SectorsTable.id eq id }.singleOrNull()?.let { + Sector( + it[SectorsTable.id], + it[SectorsTable.ownerActorId], + it[SectorsTable.world], + it[SectorsTable.x], + it[SectorsTable.y], + it[SectorsTable.z], + it[SectorsTable.hp] + ) + } + } + } + fun getHealth(id: Int): Int? { // Try get from cache var currentHp = hpCache[id] @@ -119,6 +138,7 @@ class SectorService(private val database: Database) { val sector = transaction(database) { val record = SectorsTable.selectAll().andWhere { SectorsTable.id eq id }.singleOrNull() ?: return@transaction null + SectorRangesTable.deleteWhere { SectorRangesTable.sectorId eq id } SectorsTable.deleteWhere { SectorsTable.id eq id } Sector( @@ -211,4 +231,42 @@ class SectorService(private val database: Database) { } } } + + + fun addRange(sectorId: Int, mode: SelectionMode, x1: Int, y1: Int, z1: Int, x2: Int, y2: Int, z2: Int, isSneaking: Boolean): SectorRange { + return transaction(database) { + val id = SectorRangesTable.insert { + it[SectorRangesTable.sectorId] = sectorId + it[SectorRangesTable.type] = mode.name + it[SectorRangesTable.x1] = x1 + it[SectorRangesTable.y1] = y1 + it[SectorRangesTable.z1] = z1 + it[SectorRangesTable.x2] = x2 + it[SectorRangesTable.y2] = y2 + it[SectorRangesTable.z2] = z2 + it[SectorRangesTable.isSneaking] = isSneaking + }[SectorRangesTable.id] + + SectorRange(id, sectorId, mode, x1, y1, z1, x2, y2, z2, isSneaking) + } + } + + fun getRanges(sectorId: Int): List { + return transaction(database) { + SectorRangesTable.selectAll().andWhere { SectorRangesTable.sectorId eq sectorId }.map { + SectorRange( + it[SectorRangesTable.id], + it[SectorRangesTable.sectorId], + SelectionMode.valueOf(it[SectorRangesTable.type]), + it[SectorRangesTable.x1], + it[SectorRangesTable.y1], + it[SectorRangesTable.z1], + it[SectorRangesTable.x2], + it[SectorRangesTable.y2], + it[SectorRangesTable.z2], + it[SectorRangesTable.isSneaking] + ) + } + } + } } diff --git a/src/main/kotlin/net/hareworks/hcu/landsector/service/SelectionService.kt b/src/main/kotlin/net/hareworks/hcu/landsector/service/SelectionService.kt new file mode 100644 index 0000000..ad95dc2 --- /dev/null +++ b/src/main/kotlin/net/hareworks/hcu/landsector/service/SelectionService.kt @@ -0,0 +1,103 @@ +package net.hareworks.hcu.landsector.service + +import net.hareworks.hcu.landsector.model.SelectionData +import org.bukkit.Location +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import kotlin.math.abs +import kotlin.math.PI +import kotlin.math.ceil + +class SelectionService { + private val selections = ConcurrentHashMap() + + fun getSelection(uuid: UUID): SelectionData { + return selections.computeIfAbsent(uuid) { SelectionData() } + } + + fun clearSelection(uuid: UUID) { + selections.remove(uuid) + } + + fun getAreaDetails(uuid: UUID): String { + val data = getSelection(uuid) + if (data.point1 == null || data.point2 == null) { + return "No complete selection." + } + + val p1 = data.point1!! + val p2 = data.point2!! + + return if (data.mode == net.hareworks.hcu.landsector.model.SelectionMode.CUBOID) { + val width: Int + val height: Int + val length: Int + + if (!data.p1Sneaking) { + // Normal: P1 to P2 + width = abs(p1.blockX - p2.blockX) + 1 + height = abs(p1.blockY - p2.blockY) + 1 + length = abs(p1.blockZ - p2.blockZ) + 1 + } else { + // Sneak: P1 is center + val dx = abs(p1.blockX - p2.blockX) + val dy = abs(p1.blockY - p2.blockY) + val dz = abs(p1.blockZ - p2.blockZ) + width = dx * 2 + 1 + height = dy * 2 + 1 + length = dz * 2 + 1 + } + "Cuboid: ${width}x${height}x${length} (${width * height * length} blocks)" + } else { + // Cylinder + val dx = p1.blockX - p2.blockX + val dz = p1.blockZ - p2.blockZ + // Use ceil(dist - 0.5) to find the minimum integer radius R such that (dist <= R + 0.5) + // This ensures the point is included in the R+0.5 boundary without overshooting to the next integer. + val dist = Math.sqrt((dx * dx + dz * dz).toDouble()) + val baseRadius = ceil(dist - 0.5) + val radius = baseRadius + 0.5 + + val totalHeight = if (!data.p1Sneaking) { + // Normal: P1 center, symmetric height + val h = abs(p1.blockY - p2.blockY) + h * 2 + 1 + } else { + // Sneak: P1 base, P2 top + abs(p1.blockY - p2.blockY) + 1 + } + + val volume = PI * radius * radius * totalHeight + "Cylinder: r=${"%.0f".format(baseRadius)}, h=$totalHeight (~${ceil(volume).toInt()} blocks)" + } + } + + fun getCost(uuid: UUID): Double { + val data = getSelection(uuid) + if (data.point1 == null || data.point2 == null) { + return 0.0 + } + + // Placeholder cost logic: 1 block = 1 unit? + // Need to refine based on actual volume + val p1 = data.point1!! + val p2 = data.point2!! + + val volume = if (data.mode == net.hareworks.hcu.landsector.model.SelectionMode.CUBOID) { + val width = abs(p1.blockX - p2.blockX) + 1 + val height = abs(p1.blockY - p2.blockY) + 1 + val length = abs(p1.blockZ - p2.blockZ) + 1 + (width * height * length).toDouble() + } else { + val dx = p1.blockX - p2.blockX + val dz = p1.blockZ - p2.blockZ + val dist = Math.sqrt((dx * dx + dz * dz).toDouble()) + val baseRadius = ceil(dist - 0.5) + val radius = baseRadius + 0.5 + val height = abs(p1.blockY - p2.blockY) + 1 + PI * radius * radius * height + } + + return volume * 0.5 // Example rate + } +} diff --git a/src/main/kotlin/net/hareworks/hcu/landsector/task/SelectionVisualizerTask.kt b/src/main/kotlin/net/hareworks/hcu/landsector/task/SelectionVisualizerTask.kt new file mode 100644 index 0000000..e8b57e8 --- /dev/null +++ b/src/main/kotlin/net/hareworks/hcu/landsector/task/SelectionVisualizerTask.kt @@ -0,0 +1,195 @@ +package net.hareworks.hcu.landsector.task + +import net.hareworks.hcu.landsector.model.SelectionMode +import net.hareworks.hcu.landsector.service.SelectionService +import net.hareworks.hcu.visualizer.GeometryVisualizer +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.format.NamedTextColor +import org.bukkit.Bukkit +import org.bukkit.Color +import org.bukkit.Material +import org.bukkit.NamespacedKey +import org.bukkit.entity.Player +import org.bukkit.inventory.EquipmentSlot +import org.bukkit.persistence.PersistentDataType +import org.bukkit.plugin.java.JavaPlugin +import org.bukkit.scheduler.BukkitRunnable +import kotlin.math.abs + +class SelectionVisualizerTask( + private val plugin: JavaPlugin, + private val selectionService: SelectionService +) : BukkitRunnable() { + + override fun run() { + for (player in Bukkit.getOnlinePlayers()) { + visualize(player) + } + } + + private fun visualize(player: Player) { + // Check if holding tool + // Check if tool is in inventory + val key = NamespacedKey(plugin, "component") + val hasTool = player.inventory.contents.any { item -> + item != null && item.type == Material.FLINT && + item.itemMeta?.persistentDataContainer?.get(key, PersistentDataType.STRING) == "land_sector_tool" + } + + if (!hasTool) return + + val selection = selectionService.getSelection(player.uniqueId) + val p1 = selection.point1 ?: return + + // Determine P2: Either set in selection, or dynamic based on cursor if not set (or we show review for both?) + // If p2 is set, visualize that. + // If p2 is NOT set, visualize dynamic preview using target block. + + var p2 = selection.point2 + var isDynamic = false + + if (p2 == null) { + val target = player.getTargetBlockExact(30) + if (target != null) { + p2 = target.location + isDynamic = true + } + } + + if (p2 == null) return // No p2 and no target + + // If different world, ignore + if (p1.world != p2.world) return + + if (selection.mode == SelectionMode.CUBOID) { + // Cuboid visualization + var minX: Double + var minY: Double + var minZ: Double + var maxX: Double + var maxY: Double + var maxZ: Double + + if (!selection.p1Sneaking) { + // Normal: corner to corner + minX = minOf(p1.blockX, p2.blockX).toDouble() + minY = minOf(p1.blockY, p2.blockY).toDouble() + minZ = minOf(p1.blockZ, p2.blockZ).toDouble() + maxX = maxOf(p1.blockX, p2.blockX).toDouble() + 1.0 + maxY = maxOf(p1.blockY, p2.blockY).toDouble() + 1.0 + maxZ = maxOf(p1.blockZ, p2.blockZ).toDouble() + 1.0 + } else { + // Sneak: P1 center, P2 defines radius + val dx = abs(p1.blockX - p2.blockX) + val dy = abs(p1.blockY - p2.blockY) + val dz = abs(p1.blockZ - p2.blockZ) + + minX = (p1.blockX - dx).toDouble() + maxX = (p1.blockX + dx).toDouble() + 1.0 + minY = (p1.blockY - dy).toDouble() + maxY = (p1.blockY + dy).toDouble() + 1.0 + minZ = (p1.blockZ - dz).toDouble() + maxZ = (p1.blockZ + dz).toDouble() + 1.0 + } + + GeometryVisualizer.drawCuboid(player, minX, minY, minZ, maxX, maxY, maxZ) + + } else { + // Cylinder visualization + val centerX: Double + val centerY: Double + val centerZ: Double + val radius: Double + val minY: Double + val maxY: Double + + val dx = p1.blockX - p2.blockX + val dz = p1.blockZ - p2.blockZ + val dist = Math.sqrt((dx * dx + dz * dz).toDouble()) + radius = kotlin.math.ceil(dist - 0.5) + + if (!selection.p1Sneaking) { + // Normal: P1 center, symmetric height based on P2.y diff + centerX = p1.blockX + 0.5 + centerY = p1.blockY + 0.5 // Logic center + centerZ = p1.blockZ + 0.5 + + val hDiff = abs(p1.blockY - p2.blockY) + // Bottom and Top + // From center block center, go down hDiff blocks (and include center block?) + // If P1.y = 10, P2.y = 12. Diff 2. + // Blocks: 8, 9, 10, 11, 12. Range [8, 12]. Height 5. (2*2 + 1) + + // Block coords: + val baseBlockY = p1.blockY - hDiff + val topBlockY = p1.blockY + hDiff + + minY = baseBlockY.toDouble() + maxY = topBlockY.toDouble() + 1.0 + } else { + // Sneak: P1 base center. P2 top center. + centerX = p1.blockX + 0.5 + centerZ = p1.blockZ + 0.5 + // Only height from p2 + val y1 = p1.blockY + val y2 = p2.blockY + + val baseBlockY = minOf(y1, y2) + val topBlockY = maxOf(y1, y2) + + minY = baseBlockY.toDouble() + maxY = topBlockY.toDouble() + 1.0 + } + + // Calculate blocks for cylinder outline + val cX = kotlin.math.floor(centerX).toInt() + val cZ = kotlin.math.floor(centerZ).toInt() + val blocks = mutableSetOf>() + // Add 0.5 to radius to encompass the full block width of the boundary blocks + val actualRadius = radius + 0.5 + val radiusSq = actualRadius * actualRadius + val rInt = actualRadius.toInt() + 1 + + for (dx in -rInt..rInt) { + for (dz in -rInt..rInt) { + // Check if block center is within the radius + if (dx * dx + dz * dz <= radiusSq) { + blocks.add(Pair(cX + dx, cZ + dz)) + } + } + } + + + // Draw wireframe circle (smooth) layered with block outline + // Wireframe uses the expanded radius to match the block outline visuals + GeometryVisualizer.drawCylinder(player, centerX, p1.blockY.toDouble(), centerZ, radius.toInt(), minY, maxY) + + val color = Color.fromRGB(100, 200, 255) + // Draw bottom surface outline + GeometryVisualizer.drawBlockSurfaceOutline( + player, + minY, + blocks, + { _, _, _ -> false }, + color, + minY + ) + + // Draw top surface outline + GeometryVisualizer.drawBlockSurfaceOutline( + player, + maxY, + blocks, + { _, _, _ -> false }, + color, + maxY + ) + + // Vertical edges for the outline? + // The current generic visualizer doesn't have a specific "drawVerticalConnectors" for this map-based approach easily accessible + // or we'd have to iterate edges. + // For now, surface outlines should be sufficient as requested. + } + + } +}