diff --git a/src/main/kotlin/net/hareworks/hcu/lands/api/LandsAPIImpl.kt b/src/main/kotlin/net/hareworks/hcu/lands/api/LandsAPIImpl.kt index 5286919..3510e7c 100644 --- a/src/main/kotlin/net/hareworks/hcu/lands/api/LandsAPIImpl.kt +++ b/src/main/kotlin/net/hareworks/hcu/lands/api/LandsAPIImpl.kt @@ -21,16 +21,7 @@ class LandsAPIImpl(private val landService: LandService) : LandsAPI { } override fun getLandAt(world: String, x: Double, y: Double, z: Double): Land? { - return landService.getAllLands() - .filter { it.world == world } - .firstOrNull { land -> - land.data.parts.any { shape -> - when (shape) { - is net.hareworks.hcu.lands.model.Shape.Cuboid -> shape.contains(x, y, z) - is net.hareworks.hcu.lands.model.Shape.Cylinder -> shape.contains(x, y, z) - } - } - } + return landService.getLandAt(world, x, y, z) } override fun getLand(name: String): Land? { diff --git a/src/main/kotlin/net/hareworks/hcu/lands/index/LandIndex.kt b/src/main/kotlin/net/hareworks/hcu/lands/index/LandIndex.kt new file mode 100644 index 0000000..d1d6f95 --- /dev/null +++ b/src/main/kotlin/net/hareworks/hcu/lands/index/LandIndex.kt @@ -0,0 +1,198 @@ +package net.hareworks.hcu.lands.index + +import net.hareworks.hcu.lands.model.Land +import net.hareworks.hcu.lands.model.Shape +import java.util.concurrent.ConcurrentHashMap + +/** + * Chunk-based spatial index for fast land lookups. + * + * This index maintains a mapping from chunk coordinates to lands that overlap those chunks. + * This allows for O(1) chunk lookup followed by O(k) land checks where k is the number of + * lands in that chunk (typically 1-10). + */ +class LandIndex { + + // Chunk -> Set of lands that overlap this chunk + private val chunkIndex = ConcurrentHashMap>() + + // Land -> its bounding box (cached) + private val boundingBoxCache = ConcurrentHashMap() + + /** + * Rebuilds the entire index from a collection of lands. + * This should be called when the plugin initializes or when bulk changes occur. + */ + fun rebuild(lands: Collection) { + chunkIndex.clear() + boundingBoxCache.clear() + + for (land in lands) { + addLand(land) + } + } + + /** + * Adds a land to the index. + */ + fun addLand(land: Land) { + val bbox = calculateBoundingBox(land) + boundingBoxCache[land.name] = bbox + + val chunks = bbox.getAffectedChunks(land.world) + for (chunk in chunks) { + chunkIndex.getOrPut(chunk) { ConcurrentHashMap.newKeySet() }.add(land) + } + } + + /** + * Removes a land from the index. + */ + fun removeLand(land: Land) { + val bbox = boundingBoxCache.remove(land.name) ?: return + + val chunks = bbox.getAffectedChunks(land.world) + for (chunk in chunks) { + // Use removeIf to ensure we remove the land even if its data has changed + // (since equals/hashCode would be different for modified data classes) + chunkIndex[chunk]?.removeIf { it.name == land.name } + + if (chunkIndex[chunk]?.isEmpty() == true) { + chunkIndex.remove(chunk) + } + } + } + + /** + * Updates a land in the index (removes old, adds new). + */ + fun updateLand(land: Land) { + removeLand(land) + addLand(land) + } + + /** + * Finds the land at the given coordinates. + * + * @return The land containing the coordinates, or null if none found + */ + fun getLandAt(world: String, x: Double, y: Double, z: Double): Land? { + val chunkKey = ChunkKey.fromCoords(world, x, z) + val candidates = chunkIndex[chunkKey] ?: return null + + // Check each candidate land in this chunk + for (land in candidates) { + // Early exit: check bounding box first + val bbox = boundingBoxCache[land.name] + if (bbox != null && !bbox.contains(x, y, z)) { + continue + } + + // Detailed shape check + if (land.data.parts.any { shape -> checkShapeContains(shape, x, y, z) }) { + return land + } + } + + return null + } + + /** + * Gets all lands that overlap with the given chunk. + */ + fun getLandsInChunk(world: String, chunkX: Int, chunkZ: Int): Set { + val chunkKey = ChunkKey(world, chunkX, chunkZ) + return chunkIndex[chunkKey]?.toSet() ?: emptySet() + } + + /** + * Gets all lands in the given world. + */ + fun getLandsInWorld(world: String): Set { + return chunkIndex.entries + .filter { it.key.world == world } + .flatMap { it.value } + .toSet() + } + + /** + * Calculates the bounding box for a land. + */ + private fun calculateBoundingBox(land: Land): BoundingBox { + if (land.data.parts.isEmpty()) { + // Empty land, use center block as bounding box + return BoundingBox(0, 0, 0, 0, 0, 0) + } + + var minX = Int.MAX_VALUE + var minY = Int.MAX_VALUE + var minZ = Int.MAX_VALUE + var maxX = Int.MIN_VALUE + var maxY = Int.MIN_VALUE + var maxZ = Int.MIN_VALUE + + for (shape in land.data.parts) { + when (shape) { + is Shape.Cuboid -> { + minX = minOf(minX, shape.minX()) + maxX = maxOf(maxX, shape.maxX()) + minY = minOf(minY, shape.minY()) + maxY = maxOf(maxY, shape.maxY()) + minZ = minOf(minZ, shape.minZ()) + maxZ = maxOf(maxZ, shape.maxZ()) + } + is Shape.Cylinder -> { + // Use actual radius (radius + 0.5) for bounding box + val actualRadius = shape.radius + 0.5 + val r = actualRadius.toInt() + 1 + minX = minOf(minX, shape.x - r) + maxX = maxOf(maxX, shape.x + r) + minY = minOf(minY, shape.y - shape.bottomHeight) + maxY = maxOf(maxY, shape.y + shape.topHeight) + minZ = minOf(minZ, shape.z - r) + maxZ = maxOf(maxZ, shape.z + r) + } + } + } + + return BoundingBox(minX, minY, minZ, maxX, maxY, maxZ) + } + + /** + * Checks if a shape contains the given coordinates. + */ + private fun checkShapeContains(shape: Shape, x: Double, y: Double, z: Double): Boolean { + return when (shape) { + is Shape.Cuboid -> shape.contains(x, y, z) + is Shape.Cylinder -> shape.contains(x, y, z) + } + } + + /** + * Gets statistics about the index. + */ + fun getStats(): IndexStats { + val totalChunks = chunkIndex.size + val totalLands = boundingBoxCache.size + val avgLandsPerChunk = if (totalChunks > 0) { + chunkIndex.values.sumOf { it.size }.toDouble() / totalChunks + } else { + 0.0 + } + + return IndexStats( + totalChunks = totalChunks, + totalLands = totalLands, + avgLandsPerChunk = avgLandsPerChunk + ) + } +} + +/** + * Statistics about the land index. + */ +data class IndexStats( + val totalChunks: Int, + val totalLands: Int, + val avgLandsPerChunk: Double +) diff --git a/src/main/kotlin/net/hareworks/hcu/lands/index/SpatialTypes.kt b/src/main/kotlin/net/hareworks/hcu/lands/index/SpatialTypes.kt new file mode 100644 index 0000000..6c9493e --- /dev/null +++ b/src/main/kotlin/net/hareworks/hcu/lands/index/SpatialTypes.kt @@ -0,0 +1,68 @@ +package net.hareworks.hcu.lands.index + +import net.hareworks.hcu.lands.model.Land + +/** + * Represents a chunk coordinate key for indexing. + */ +data class ChunkKey( + val world: String, + val chunkX: Int, + val chunkZ: Int +) { + companion object { + /** + * Creates a ChunkKey from block coordinates. + */ + fun fromBlockCoords(world: String, blockX: Int, blockZ: Int): ChunkKey { + return ChunkKey(world, blockX shr 4, blockZ shr 4) + } + + /** + * Creates a ChunkKey from double coordinates. + */ + fun fromCoords(world: String, x: Double, z: Double): ChunkKey { + return ChunkKey(world, x.toInt() shr 4, z.toInt() shr 4) + } + } +} + +/** + * Represents a 3D bounding box for spatial queries. + */ +data class BoundingBox( + val minX: Int, + val minY: Int, + val minZ: Int, + val maxX: Int, + val maxY: Int, + val maxZ: Int +) { + /** + * Checks if this bounding box contains the given coordinates. + */ + fun contains(x: Double, y: Double, z: Double): Boolean { + return x >= minX && x <= maxX + 1.0 && + y >= minY && y <= maxY + 1.0 && + z >= minZ && z <= maxZ + 1.0 + } + + /** + * Gets all chunk keys that this bounding box overlaps. + */ + fun getAffectedChunks(world: String): Set { + val chunks = mutableSetOf() + val minChunkX = minX shr 4 + val maxChunkX = maxX shr 4 + val minChunkZ = minZ shr 4 + val maxChunkZ = maxZ shr 4 + + for (chunkX in minChunkX..maxChunkX) { + for (chunkZ in minChunkZ..maxChunkZ) { + chunks.add(ChunkKey(world, chunkX, chunkZ)) + } + } + + return chunks + } +} diff --git a/src/main/kotlin/net/hareworks/hcu/lands/model/Land.kt b/src/main/kotlin/net/hareworks/hcu/lands/model/Land.kt index 155890a..3ee8d14 100644 --- a/src/main/kotlin/net/hareworks/hcu/lands/model/Land.kt +++ b/src/main/kotlin/net/hareworks/hcu/lands/model/Land.kt @@ -67,9 +67,13 @@ sealed class Shape { val maxY = centerY + 1.0 + topHeight if (ty < minY || ty > maxY) return false + + // Add 0.5 to radius for block-aligned judgment + // This ensures that a radius of 4 includes all blocks within 4 blocks from center + val actualRadius = radius + 0.5 val dx = tx - centerX val dz = tz - centerZ - return (dx * dx + dz * dz) <= (radius * radius) + return (dx * dx + dz * dz) <= (actualRadius * actualRadius) } /** @@ -92,13 +96,16 @@ sealed class Shape { val outline = mutableSetOf>() val centerX = x + 0.5 val centerZ = z + 0.5 - val radiusSquared = radius * radius + + // Use actual radius (radius + 0.5) for block-aligned judgment + val actualRadius = radius + 0.5 + val radiusSquared = actualRadius * actualRadius // Calculate the bounding box for the cylinder - val minBlockX = (x - radius - 1).toInt() - val maxBlockX = (x + radius + 1).toInt() - val minBlockZ = (z - radius - 1).toInt() - val maxBlockZ = (z + radius + 1).toInt() + val minBlockX = (x - actualRadius - 1).toInt() + val maxBlockX = (x + actualRadius + 1).toInt() + val minBlockZ = (z - actualRadius - 1).toInt() + val maxBlockZ = (z + actualRadius + 1).toInt() // For each Y level in the cylinder val minY = y - bottomHeight @@ -108,10 +115,8 @@ sealed class Shape { // Check each block in the XZ plane for (blockX in minBlockX..maxBlockX) { for (blockZ in minBlockZ..maxBlockZ) { - // Check if this block is inside the cylinder - val dx = (blockX + 0.5) - centerX - val dz = (blockZ + 0.5) - centerZ - val isInside = (dx * dx + dz * dz) <= radiusSquared + // Check if this block center is inside the cylinder + val isInside = contains(blockX + 0.5, blockY + 0.5, blockZ + 0.5) if (isInside) { // Check if any adjacent block is outside (making this an edge block) @@ -121,9 +126,7 @@ sealed class Shape { Pair(blockX, blockZ - 1), Pair(blockX, blockZ + 1) ).any { (nx, nz) -> - val ndx = (nx + 0.5) - centerX - val ndz = (nz + 0.5) - centerZ - (ndx * ndx + ndz * ndz) > radiusSquared + !contains(nx + 0.5, blockY + 0.5, nz + 0.5) } if (hasOutsideNeighbor) { @@ -136,6 +139,70 @@ sealed class Shape { return outline } + + /** + * Returns a set of blocks that form the outline of the blockified cylinder. + * Optimized algorithm: O(R) instead of O(R^2). + * Calculates boundary blocks by scanning X/Z axes instead of checking all blocks area. + */ + fun getOptimizedFaceOutline(): Set> { + val outline = mutableSetOf>() + val actualRadius = radius + 0.5 + val radiusSq = actualRadius * actualRadius + val rInt = actualRadius.toInt() + 1 + + // 1. Scan X axis to find Z boundaries (Top/Bottom edges) + for (dx in -rInt..rInt) { + val bx = x + dx + // Calculate distance from center X + // Block center is at bx + 0.5. Relative to cylinder center (x + 0.5) + // dist = (bx + 0.5) - (x + 0.5) = bx - x = dx + // Wait, logic check: + // Cylinder center: cx = x + 0.5 + // Block center: bcx = bx + 0.5 + // diff = bcx - cx = bx - x = dx + // So (dx)^2 + (dz)^2 <= R^2 + // We need to verify if this dx is valid geometrically with +0.5 offset considerations handled by contains() + // Let's rely on calculation consistent with contains(): + // (bx + 0.5 - (x + 0.5))^2 + ... = dx^2 + dz^2 + + // Correction: The contains() logic uses: + // val dx = tx - centerX + // tx is block coord + 0.5, centerX is x + 0.5. + // So dx = (bx + 0.5) - (x + 0.5) = bx - x. + // It works simply with integers. + + val term = radiusSq - dx * dx + if (term < 0) continue + + val maxDistZ = kotlin.math.sqrt(term) + // We want largest integer dz such that dz^2 <= term + // Since dz is integer difference (bz - z), dz can be simply floor(maxDistZ) and ceil(-maxDistZ) + // Let's verify: if dz = 4.5, integer dz can be 4. + + val maxDz = kotlin.math.floor(maxDistZ).toInt() + val minDz = -maxDz // Symmetry + + outline.add(Pair(bx, z + maxDz)) + outline.add(Pair(bx, z + minDz)) + } + + // 2. Scan Z axis to find X boundaries (Left/Right edges) + for (dz in -rInt..rInt) { + val term = radiusSq - dz * dz + if (term < 0) continue + + val maxDistX = kotlin.math.sqrt(term) + val maxDx = kotlin.math.floor(maxDistX).toInt() + val minDx = -maxDx + + val bz = z + dz + outline.add(Pair(x + maxDx, bz)) + outline.add(Pair(x + minDx, bz)) + } + + return outline + } } } diff --git a/src/main/kotlin/net/hareworks/hcu/lands/service/LandService.kt b/src/main/kotlin/net/hareworks/hcu/lands/service/LandService.kt index 4449420..52deef4 100644 --- a/src/main/kotlin/net/hareworks/hcu/lands/service/LandService.kt +++ b/src/main/kotlin/net/hareworks/hcu/lands/service/LandService.kt @@ -1,6 +1,7 @@ package net.hareworks.hcu.lands.service import net.hareworks.hcu.lands.database.LandsTable +import net.hareworks.hcu.lands.index.LandIndex import net.hareworks.hcu.lands.model.Land import net.hareworks.hcu.lands.model.LandData import org.jetbrains.exposed.v1.core.eq @@ -16,6 +17,7 @@ import org.bukkit.Bukkit class LandService(private val database: Database) { private val cache = ConcurrentHashMap() + private val spatialIndex = LandIndex() fun init() { transaction(database) { @@ -35,6 +37,9 @@ class LandService(private val database: Database) { ) cache[land.name] = land } + + // Rebuild spatial index + spatialIndex.rebuild(cache.values) } fun createLand(name: String, actorId: Int, world: String): Boolean { @@ -48,8 +53,10 @@ class LandService(private val database: Database) { it[LandsTable.data] = LandData() } } - // Update cache efficiently - cache[name] = Land(name, actorId, world, LandData()) + // Update cache and index + val land = Land(name, actorId, world, LandData()) + cache[name] = land + spatialIndex.addLand(land) return true } @@ -65,20 +72,52 @@ class LandService(private val database: Database) { it[LandsTable.data] = newData } } - cache[name] = land.copy(data = newData) + + val updatedLand = land.copy(data = newData) + cache[name] = updatedLand + + // Update spatial index + spatialIndex.updateLand(updatedLand) return true } fun deleteLand(name: String): Boolean { if (!cache.containsKey(name)) return false + val land = cache[name]!! + transaction(database) { LandsTable.deleteWhere { LandsTable.name eq name } } + cache.remove(name) + + // Remove from spatial index + spatialIndex.removeLand(land) return true } fun getLand(name: String): Land? = cache[name] fun getAllLands(): Collection = cache.values + + /** + * Gets the land at the specified coordinates using the spatial index. + * This is much faster than iterating through all lands. + */ + fun getLandAt(world: String, x: Double, y: Double, z: Double): Land? { + return spatialIndex.getLandAt(world, x, y, z) + } + + /** + * Gets all lands in the specified chunk. + */ + fun getLandsInChunk(world: String, chunkX: Int, chunkZ: Int): Set { + return spatialIndex.getLandsInChunk(world, chunkX, chunkZ) + } + + /** + * Gets statistics about the spatial index. + */ + fun getIndexStats() = spatialIndex.getStats() } + diff --git a/src/main/kotlin/net/hareworks/hcu/lands/task/VisualizerTask.kt b/src/main/kotlin/net/hareworks/hcu/lands/task/VisualizerTask.kt index 2efb547..66e7a0f 100644 --- a/src/main/kotlin/net/hareworks/hcu/lands/task/VisualizerTask.kt +++ b/src/main/kotlin/net/hareworks/hcu/lands/task/VisualizerTask.kt @@ -30,13 +30,24 @@ class VisualizerTask(private val landService: LandService) : Runnable { override fun run() { Bukkit.getOnlinePlayers().forEach { p -> if (LandsVisualizer.isEnabled(p)) { - landService.getAllLands().forEach { land -> - if (land.world == p.world.name) { - // Optimization: Check distance to Land center/bounds? - // For now basic iteration - land.data.parts.forEach { shape -> - drawShape(p, shape) - } + val pChunkX = p.location.blockX shr 4 + val pChunkZ = p.location.blockZ shr 4 + val radiusInChunks = 2 // 32 blocks radius approx + + // Collect lands from nearby chunks using spatial index + val nearbyLands = mutableSetOf() + + for (cx in (pChunkX - radiusInChunks)..(pChunkX + radiusInChunks)) { + for (cz in (pChunkZ - radiusInChunks)..(pChunkZ + radiusInChunks)) { + nearbyLands.addAll(landService.getLandsInChunk(p.world.name, cx, cz)) + } + } + + nearbyLands.forEach { land -> + // Further distance check could be added here if needed, + // but chunk-based filtering is already efficient enough for visualization logic + land.data.parts.forEach { shape -> + drawShape(p, shape) } } } @@ -101,6 +112,9 @@ class VisualizerTask(private val landService: LandService) : Runnable { val centerX = cylinder.x + 0.5 val centerZ = cylinder.z + 0.5 + // Use actual radius (radius + 0.5) for visualization + val actualRadius = cylinder.radius + 0.5 + // Y coordinates use block boundaries (no +0.5 offset) val bottomY = cylinder.y.toDouble() - cylinder.bottomHeight val topY = cylinder.y.toDouble() + 1.0 + cylinder.topHeight @@ -113,10 +127,10 @@ class VisualizerTask(private val landService: LandService) : Runnable { for (i in 0 until segments) { val angle1 = i * step val angle2 = (i + 1) * step - val x1 = centerX + cos(angle1) * cylinder.radius - val z1 = centerZ + sin(angle1) * cylinder.radius - val x2 = centerX + cos(angle2) * cylinder.radius - val z2 = centerZ + sin(angle2) * cylinder.radius + val x1 = centerX + cos(angle1) * actualRadius + val z1 = centerZ + sin(angle1) * actualRadius + val x2 = centerX + cos(angle2) * actualRadius + val z2 = centerZ + sin(angle2) * actualRadius drawLine(player, x1, bottomY, z1, x2, bottomY, z2, edgeColor, 0.25) } @@ -125,10 +139,10 @@ class VisualizerTask(private val landService: LandService) : Runnable { for (i in 0 until segments) { val angle1 = i * step val angle2 = (i + 1) * step - val x1 = centerX + cos(angle1) * cylinder.radius - val z1 = centerZ + sin(angle1) * cylinder.radius - val x2 = centerX + cos(angle2) * cylinder.radius - val z2 = centerZ + sin(angle2) * cylinder.radius + val x1 = centerX + cos(angle1) * actualRadius + val z1 = centerZ + sin(angle1) * actualRadius + val x2 = centerX + cos(angle2) * actualRadius + val z2 = centerZ + sin(angle2) * actualRadius drawLine(player, x1, topY, z1, x2, topY, z2, edgeColor, 0.25) } @@ -137,19 +151,69 @@ class VisualizerTask(private val landService: LandService) : Runnable { val verticalSegments = 8 for (i in 0 until verticalSegments) { val angle = i * (Math.PI * 2) / verticalSegments - val x = centerX + cos(angle) * cylinder.radius - val z = centerZ + sin(angle) * cylinder.radius + val x = centerX + cos(angle) * actualRadius + val z = centerZ + sin(angle) * actualRadius drawLine(player, x, bottomY, z, x, topY, z, edgeColor, 0.5) } - // Draw blockified outline - val blockifiedOutline = cylinder.getBlockifiedOutline() - val blockColor = Particle.DustOptions(Color.fromRGB(150, 220, 255), 0.5f) + // Draw top face outline in red (outer edges only) + // Optimized: Calculate outline once for both faces as they share the same XZ shape + val faceOutline = cylinder.getOptimizedFaceOutline() + val redColor = Particle.DustOptions(Color.RED, 1.0f) + val topFaceY = cylinder.y + cylinder.topHeight - for ((bx, by, bz) in blockifiedOutline) { - // Draw a subtle outline for each block in the blockified cylinder - drawBlockOutline(player, bx, by, bz, Color.fromRGB(150, 220, 255)) + drawFaceOutline(player, faceOutline, topFaceY + 1.0, redColor, cylinder) + + // Draw bottom face outline in red (outer edges only) + val bottomFaceY = cylinder.y - cylinder.bottomHeight + + drawFaceOutline(player, faceOutline, bottomFaceY.toDouble(), redColor, cylinder) + } + + /** + * Draws only the outer edges of a face outline (excluding inner holes). + */ + private fun drawFaceOutline( + player: Player, + faceBlocks: Set>, + y: Double, + dustOptions: Particle.DustOptions, + cylinder: Shape.Cylinder + ) { + // Use the cylinder's vertical center for the 'contains' check to ensure we are within Y bounds + // This effectively checks if the neighbor is outside the radius laterally + val checkY = cylinder.y + 0.5 + + // For each block in the face, check which edges face outward (not toward inner holes) + for ((bx, bz) in faceBlocks) { + // Check each of the 4 edges + // An edge should be drawn if the adjacent block is NOT in the face + // AND is outside the cylinder (not an inner hole) + + // Top edge (z- direction) + val topNeighbor = Pair(bx, bz - 1) + if (!faceBlocks.contains(topNeighbor) && !cylinder.contains(topNeighbor.first + 0.5, checkY, topNeighbor.second + 0.5)) { + drawLine(player, bx.toDouble(), y, bz.toDouble(), bx + 1.0, y, bz.toDouble(), dustOptions, 0.25) + } + + // Right edge (x+ direction) + val rightNeighbor = Pair(bx + 1, bz) + if (!faceBlocks.contains(rightNeighbor) && !cylinder.contains(rightNeighbor.first + 0.5, checkY, rightNeighbor.second + 0.5)) { + drawLine(player, bx + 1.0, y, bz.toDouble(), bx + 1.0, y, bz + 1.0, dustOptions, 0.25) + } + + // Bottom edge (z+ direction) + val bottomNeighbor = Pair(bx, bz + 1) + if (!faceBlocks.contains(bottomNeighbor) && !cylinder.contains(bottomNeighbor.first + 0.5, checkY, bottomNeighbor.second + 0.5)) { + drawLine(player, bx + 1.0, y, bz + 1.0, bx.toDouble(), y, bz + 1.0, dustOptions, 0.25) + } + + // Left edge (x- direction) + val leftNeighbor = Pair(bx - 1, bz) + if (!faceBlocks.contains(leftNeighbor) && !cylinder.contains(leftNeighbor.first + 0.5, checkY, leftNeighbor.second + 0.5)) { + drawLine(player, bx.toDouble(), y, bz + 1.0, bx.toDouble(), y, bz.toDouble(), dustOptions, 0.25) + } } }