feat: 最適化とアウトライン表示

This commit is contained in:
Keisuke Hirata 2025-12-09 09:40:15 +09:00
parent 0ed06e789f
commit 576005aada
6 changed files with 476 additions and 49 deletions

View File

@ -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? {

View File

@ -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<ChunkKey, MutableSet<Land>>()
// Land -> its bounding box (cached)
private val boundingBoxCache = ConcurrentHashMap<String, BoundingBox>()
/**
* 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<Land>) {
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<Land> {
val chunkKey = ChunkKey(world, chunkX, chunkZ)
return chunkIndex[chunkKey]?.toSet() ?: emptySet()
}
/**
* Gets all lands in the given world.
*/
fun getLandsInWorld(world: String): Set<Land> {
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
)

View File

@ -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<ChunkKey> {
val chunks = mutableSetOf<ChunkKey>()
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
}
}

View File

@ -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<Triple<Int, Int, Int>>()
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<Pair<Int, Int>> {
val outline = mutableSetOf<Pair<Int, Int>>()
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
}
}
}

View File

@ -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<String, Land>()
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<Land> = 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<Land> {
return spatialIndex.getLandsInChunk(world, chunkX, chunkZ)
}
/**
* Gets statistics about the spatial index.
*/
fun getIndexStats() = spatialIndex.getStats()
}

View File

@ -30,10 +30,22 @@ 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
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<net.hareworks.hcu.lands.model.Land>()
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)
}
@ -41,7 +53,6 @@ class VisualizerTask(private val landService: LandService) : Runnable {
}
}
}
}
private fun drawShape(player: Player, shape: Shape) {
when (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<Pair<Int, Int>>,
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)
}
}
}