diff --git a/gradle.properties b/gradle.properties index c520c243..2739ff64 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,7 +3,7 @@ org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m kotlinVersion=1.9.10 kotlinCoroutinesVersion=1.8.0 -kommonsVersion=2.11.0 +kommonsVersion=2.11.1 ffiVersion=2.2.13 lwjglVersion=3.3.0 diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/client/world/ClientWorld.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/client/world/ClientWorld.kt index 6c5a4422..e003a432 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/client/world/ClientWorld.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/client/world/ClientWorld.kt @@ -308,7 +308,7 @@ class ClientWorld( } } - override fun tickInner() { + override fun tick0() { } diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/math/Utils.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/math/Utils.kt index 3d4f5a04..4a522e25 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/math/Utils.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/math/Utils.kt @@ -2,10 +2,6 @@ package ru.dbotthepony.kstarbound.math import kotlin.math.absoluteValue -fun lerp(t: Double, a: Double, b: Double): Double { - return a * (1.0 - t) + b * t -} - /** * Выполняет преобразование [value] типа [Double] в [Int] так, * что выходной [Int] всегда будет больше или равен по модулю [value] @@ -59,11 +55,7 @@ fun roundTowardsPositiveInfinity(value: Double): Int { } fun divideUp(a: Int, b: Int): Int { - return if (a % b == 0) { - a / b - } else { - a / b + 1 - } + return (a + b - 1) / b } private const val EPSILON = 0.00000001 diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/network/Connection.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/network/Connection.kt index 00286c7e..56c701a9 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/network/Connection.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/network/Connection.kt @@ -190,7 +190,7 @@ abstract class Connection(val side: ConnectionSide, val type: ConnectionType) : val result = ArrayList() var mins = Vector2i(windowXMin - GlobalDefaults.client.windowMonitoringBorder, windowYMin - GlobalDefaults.client.windowMonitoringBorder) - var maxs = Vector2i(windowWidth + GlobalDefaults.client.windowMonitoringBorder, windowHeight + GlobalDefaults.client.windowMonitoringBorder) + var maxs = Vector2i(windowWidth + GlobalDefaults.client.windowMonitoringBorder * 2, windowHeight + GlobalDefaults.client.windowMonitoringBorder * 2) if (maxs.x - mins.x > 1000) { // holy shit diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/network/packets/EntityUpdateSetPacket.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/network/packets/EntityUpdateSetPacket.kt index 645ebe8d..b042013b 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/network/packets/EntityUpdateSetPacket.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/network/packets/EntityUpdateSetPacket.kt @@ -37,7 +37,9 @@ class EntityUpdateSetPacket(val forConnection: Int, val deltas: Int2ObjectMap() private val ticketLists = ArrayList() + private val ticketListLock = ReentrantLock() private fun getTicketList(pos: ChunkPos): TicketList { return ticketMap.computeIfAbsent(geometry.wrapToLong(pos), Long2ObjectFunction { TicketList(it) }) } fun permanentChunkTicket(pos: ChunkPos): ITicket { - lock.withLock { + ticketListLock.withLock { return getTicketList(pos).Ticket() } } @@ -251,7 +256,7 @@ class ServerWorld private constructor( fun temporaryChunkTicket(pos: ChunkPos, time: Int): ITicket { require(time > 0) { "Invalid ticket time: $time" } - lock.withLock { + ticketListLock.withLock { return getTicketList(pos).TimedTicket(time) } } @@ -308,16 +313,6 @@ class ServerWorld private constructor( return temporary.isNotEmpty() || permanent.isNotEmpty() } - override fun onEntityAdded(entity: AbstractEntity) { - permanent.forEach { it.listener?.onEntityAdded(entity) } - temporary.forEach { it.listener?.onEntityAdded(entity) } - } - - override fun onEntityRemoved(entity: AbstractEntity) { - permanent.forEach { it.listener?.onEntityRemoved(entity) } - temporary.forEach { it.listener?.onEntityRemoved(entity) } - } - override fun onCellChanges(x: Int, y: Int, cell: ImmutableCell) { permanent.forEach { it.listener?.onCellChanges(x, y, cell) } temporary.forEach { it.listener?.onCellChanges(x, y, cell) } @@ -374,10 +369,9 @@ class ServerWorld private constructor( final override fun cancel() { if (isCanceled) return - lock.withLock { + ticketListLock.withLock { if (isCanceled) return isCanceled = true - chunk?.entities?.forEach { e -> listener?.onEntityRemoved(e) } loadFuture?.cancel(false) listener = null cancel0() @@ -389,14 +383,6 @@ class ServerWorld private constructor( get() = chunkMap[pos] final override var listener: IChunkListener? = null - set(value) { - if (field != value) { - val chunk = chunk - chunk?.entities?.forEach { e -> field?.onEntityRemoved(e) } - chunk?.entities?.forEach { e -> value?.onEntityAdded(e) } - field = value - } - } } inner class Ticket : AbstractTicket() { @@ -428,7 +414,7 @@ class ServerWorld private constructor( override fun prolong(ticks: Int) { if (ticks == 0 || isCanceled) return - lock.withLock { + ticketListLock.withLock { if (isCanceled) return temporary.remove(this) diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/ServerWorldTracker.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/ServerWorldTracker.kt index c52c7759..f7b245f9 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/ServerWorldTracker.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/ServerWorldTracker.kt @@ -3,8 +3,9 @@ package ru.dbotthepony.kstarbound.server.world import it.unimi.dsi.fastutil.bytes.ByteArrayList import it.unimi.dsi.fastutil.ints.Int2LongOpenHashMap import it.unimi.dsi.fastutil.ints.Int2ObjectMaps +import it.unimi.dsi.fastutil.ints.IntArrayList import it.unimi.dsi.fastutil.io.FastByteArrayOutputStream -import it.unimi.dsi.fastutil.objects.ObjectArraySet +import it.unimi.dsi.fastutil.objects.ObjectAVLTreeSet import it.unimi.dsi.fastutil.objects.ObjectLinkedOpenHashSet import org.apache.logging.log4j.LogManager import ru.dbotthepony.kommons.vector.Vector2d @@ -16,6 +17,7 @@ import ru.dbotthepony.kstarbound.defs.WarpAction import ru.dbotthepony.kstarbound.defs.WorldID import ru.dbotthepony.kstarbound.network.IPacket import ru.dbotthepony.kstarbound.network.packets.EntityCreatePacket +import ru.dbotthepony.kstarbound.network.packets.EntityDestroyPacket import ru.dbotthepony.kstarbound.network.packets.EntityUpdateSetPacket import ru.dbotthepony.kstarbound.network.packets.clientbound.CentralStructureUpdatePacket import ru.dbotthepony.kstarbound.network.packets.clientbound.LegacyTileArrayUpdatePacket @@ -69,9 +71,6 @@ class ServerWorldTracker(val world: ServerWorld, val client: ServerConnection, p } private inner class Ticket(val ticket: ServerWorld.ITicket, val pos: ChunkPos) : IChunkListener { - override fun onEntityAdded(entity: AbstractEntity) {} - override fun onEntityRemoved(entity: AbstractEntity) {} - override fun onCellChanges(x: Int, y: Int, cell: ImmutableCell) { if (pos !in pendingSend) { send(LegacyTileUpdatePacket(pos.tile + Vector2i(x, y), cell.toLegacyNet())) @@ -141,11 +140,13 @@ class ServerWorldTracker(val world: ServerWorld, val client: ServerConnection, p client.playerEntity = world.entities[client.playerID] as? PlayerEntity - run { - val newTrackedChunks = ObjectArraySet() + val trackingRegions = client.trackingTileRegions() - for (region in client.trackingTileRegions()) { - newTrackedChunks.addAll(world.geometry.tileRegion2Chunks(region)) + run { + val newTrackedChunks = ArrayList() + + for (region in trackingRegions) { + newTrackedChunks.addAll(world.geometry.region2Chunks(region)) } val itr = tickets.entries.iterator() @@ -186,26 +187,55 @@ class ServerWorldTracker(val world: ServerWorld, val client: ServerConnection, p } } - for ((id, entity) in world.entities) { - if (entity.connectionID != client.connectionID && entity is PlayerEntity) { - if (entityVersions.get(id) == -1L) { - // never networked - val initial = FastByteArrayOutputStream() - entity.writeNetwork(DataOutputStream(initial), client.isLegacy) - val (data, version) = entity.networkGroup.write(isLegacy = client.isLegacy) + run { + val trackingEntities = ObjectAVLTreeSet() - entityVersions.put(id, version) + for (region in trackingRegions) { + // we don't care about distinct values here, since + // we handle this by ourselves + trackingEntities.addAll(world.entityIndex.query(region, filter = { it.connectionID != client.connectionID }, distinct = false)) + } - send(EntityCreatePacket( - entity.type, - ByteArrayList.wrap(initial.array, initial.length), - data, - entity.entityID - )) + val unseen = IntArrayList(entityVersions.keys) + + for (entity in trackingEntities) { + val id = entity.entityID + unseen.rem(id) + + if (entity is PlayerEntity) { + if (entityVersions.get(id) == -1L) { + // never networked + val initial = FastByteArrayOutputStream() + entity.writeNetwork(DataOutputStream(initial), client.isLegacy) + val (data, version) = entity.networkGroup.write(isLegacy = client.isLegacy) + + entityVersions.put(id, version) + + send(EntityCreatePacket( + entity.type, + ByteArrayList.wrap(initial.array, initial.length), + data, + entity.entityID + )) + } else { + val (data, version) = entity.networkGroup.write(remoteVersion = entityVersions.get(id), isLegacy = client.isLegacy) + entityVersions.put(id, version) + send(EntityUpdateSetPacket(entity.connectionID, Int2ObjectMaps.singleton(entity.entityID, data))) + } + } + } + + val itr = unseen.iterator() + + while (itr.hasNext()) { + val id = itr.nextInt() + val entity = world.entities[id] + val version = entityVersions.remove(id) + + if (entity == null) { + send(EntityDestroyPacket(id, ByteArrayList(), false)) } else { - val (data, version) = entity.networkGroup.write(remoteVersion = entityVersions.get(id), isLegacy = client.isLegacy) - entityVersions.put(id, version) - send(EntityUpdateSetPacket(entity.connectionID, Int2ObjectMaps.singleton(entity.entityID, data))) + send(EntityDestroyPacket(id, entity.networkGroup.write(version, isLegacy = client.isLegacy).first, false)) } } } diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/Chunk.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/Chunk.kt index 1df97fd0..d496abdf 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/Chunk.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/Chunk.kt @@ -20,6 +20,7 @@ import ru.dbotthepony.kstarbound.world.api.TileView import ru.dbotthepony.kstarbound.world.entities.AbstractEntity import ru.dbotthepony.kstarbound.world.entities.DynamicEntity import ru.dbotthepony.kstarbound.world.entities.TileEntity +import java.util.concurrent.CopyOnWriteArraySet import kotlin.concurrent.withLock /** @@ -51,10 +52,7 @@ abstract class Chunk, This : Chunk() - val dynamicEntities = ReferenceOpenHashSet() - val tileEntities = ReferenceOpenHashSet() - protected val subscribers = ObjectArraySet() + protected val subscribers = CopyOnWriteArraySet() // local cells' tile access val localBackgroundView = TileView.Background(this) @@ -245,94 +243,18 @@ abstract class Chunk, This : Chunk) { - world.lock.withLock { - if (otherChunk == this) - throw IllegalArgumentException("what?") - - if (world != otherChunk.world) - throw IllegalArgumentException("Chunks belong to different worlds: this: $this / other: $otherChunk") - - changeset++ - otherChunk.changeset++ - - entities.add(entity) - otherChunk.entities.remove(entity) - - if (entity is TileEntity) { - tileEntities.add(entity) - otherChunk.tileEntities.remove(entity) - } - - if (entity is DynamicEntity) { - dynamicEntities.add(entity) - otherChunk.dynamicEntities.remove(entity) - } - - otherChunk.subscribers.forEach { it.onEntityRemoved(entity) } - subscribers.forEach { it.onEntityAdded(entity) } - } - } - - fun removeEntity(entity: AbstractEntity) { - world.lock.withLock { - if (!entities.remove(entity)) - throw IllegalArgumentException("Already not having entity $entity") - - if (entity is TileEntity) - tileEntities.remove(entity) - - if (entity is DynamicEntity) - dynamicEntities.remove(entity) - - changeset++ - subscribers.forEach { it.onEntityRemoved(entity) } - } + return subscribers.remove(subscriber) } override fun toString(): String { - return "${this::class.simpleName}(pos=$pos, entityCount=${entities.size}, world=$world)" + return "${this::class.simpleName}(pos=$pos, world=$world)" } open fun remove() { - world.lock.withLock { - for (ent in ObjectArrayList(entities)) { - ent.chunk = null - } - } } open fun tick() { diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/CoordinateMapper.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/CoordinateMapper.kt index 914718d3..9b331332 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/CoordinateMapper.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/CoordinateMapper.kt @@ -1,7 +1,9 @@ package ru.dbotthepony.kstarbound.world +import ru.dbotthepony.kommons.vector.Vector2d import ru.dbotthepony.kommons.vector.Vector2i import ru.dbotthepony.kstarbound.math.divideUp +import kotlin.math.pow fun positiveModulo(a: Int, b: Int): Int { val result = a % b @@ -36,6 +38,7 @@ abstract class CoordinateMapper { fun chunkFromCell(value: Double): Int = chunkFromCell(value.toInt()) data class SplitResult(val rangeA: Vector2i, val rangeB: Vector2i?, val offset: Int) + data class SplitResultDouble(val rangeA: Vector2d, val rangeB: Vector2d?, val offset: Double) /** * One of: @@ -49,6 +52,11 @@ abstract class CoordinateMapper { abstract fun split(first: Int, last: Int): SplitResult + // splitting doubles is tricky, because we have to decide what is world's edge + // In case of ints, world edge is determined to be width - 1, but what about doubles? + // width - 0.1 is clearly not out of bounds, but how can we represent this? + abstract fun split(first: Double, last: Double): SplitResultDouble + // inside world bounds abstract fun inBoundsCell(value: Int): Boolean abstract fun inBoundsChunk(value: Int): Boolean @@ -59,6 +67,20 @@ abstract class CoordinateMapper { class Wrapper(private val cells: Int) : CoordinateMapper() { override val chunks = divideUp(cells, CHUNK_SIZE) + private val cellsD = cells.toDouble() + + // try to approach reasonable "edge" value, which is just little less than `cells` + private var cellsEdge = cellsD + + init { + var power = -64.0 + + do { + cellsEdge -= 2.0.pow(++power) + } while (cellsEdge >= cellsD) + } + + private val edgeDelta = cellsD - cellsEdge override fun inBoundsCell(value: Int) = value in 0 until cells override fun inBoundsChunk(value: Int) = value in 0 until chunks @@ -109,10 +131,68 @@ abstract class CoordinateMapper { } } } + + override fun split(first: Double, last: Double): SplitResultDouble { + if (first >= last) { + // point or empty range + val wrap = cell(first) + return SplitResultDouble(Vector2d(wrap, wrap), null, 0.0) + } else if (first <= 0.0 && last >= cellsEdge) { + // covers entire world along this axis + return SplitResultDouble(Vector2d(0.0, cellsEdge), null, 0.0) + } else if (first >= 0.0 && last < cellsEdge) { + // within range along this axis + return SplitResultDouble(Vector2d(first, last), null, 0.0) + } else { + val newFirst = cell(first) + val newLast = cell(last) + + if (first < 0.0) { + // wrapped around left edge + + return SplitResultDouble( + Vector2d(0.0, newLast.coerceAtLeast(edgeDelta)), // handles case where splitting happens *exactly* at world's border + Vector2d(newFirst, cellsEdge), + newFirst - cellsD + ) + } else { + // wrapped around right edge + + return SplitResultDouble( + Vector2d(newFirst, cellsEdge), + Vector2d(0.0, newLast.coerceAtLeast(edgeDelta)), // handles case where splitting happens *exactly* at world's border + -newLast - 1 + ) + } + } + } } class Clamper(private val cells: Int) : CoordinateMapper() { override val chunks = divideUp(cells, CHUNK_SIZE) + private val cellsD = cells.toDouble() + private val cellsF = cells.toFloat() + + // try to approach reasonable "edge" value, which is just little less than `cells` + private var cellsEdge = cellsD + + init { + var power = -64.0 + + do { + cellsEdge -= 2.0.pow(++power) + } while (cellsEdge >= cellsD) + } + + private var cellsEdgeFloat = cellsF + + init { + var power = -64f + + do { + cellsEdgeFloat -= 2f.pow(++power) + } while (cellsEdgeFloat >= cellsF) + } override fun inBoundsCell(value: Int): Boolean { return value in 0 until cells @@ -131,16 +211,21 @@ abstract class CoordinateMapper { return SplitResult(Vector2i(cell(first), cell(last)), null, 0) } + override fun split(first: Double, last: Double): SplitResultDouble { + // just clamp + return SplitResultDouble(Vector2d(cell(first), cell(last)), null, 0.0) + } + override fun cell(value: Int): Int { return value.coerceIn(0, cells - 1) } override fun cell(value: Double): Double { - return value.coerceIn(0.0, cells - 1.0) + return value.coerceIn(0.0, cellsEdge) } override fun cell(value: Float): Float { - return value.coerceIn(0f, cells - 1f) + return value.coerceIn(0f, cellsEdgeFloat) } override fun chunk(value: Int): Int { diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/IChunkListener.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/IChunkListener.kt index 66dac075..64f60603 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/IChunkListener.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/IChunkListener.kt @@ -4,8 +4,6 @@ import ru.dbotthepony.kstarbound.world.api.ImmutableCell import ru.dbotthepony.kstarbound.world.entities.AbstractEntity interface IChunkListener { - fun onEntityAdded(entity: AbstractEntity) {} - fun onEntityRemoved(entity: AbstractEntity) {} fun onCellChanges(x: Int, y: Int, cell: ImmutableCell) {} fun onTileHealthUpdate(x: Int, y: Int, isBackground: Boolean, health: TileHealth) {} } diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/SpatialIndex.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/SpatialIndex.kt new file mode 100644 index 00000000..e994522a --- /dev/null +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/SpatialIndex.kt @@ -0,0 +1,301 @@ +package ru.dbotthepony.kstarbound.world + +import it.unimi.dsi.fastutil.longs.Long2ObjectFunction +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap +import it.unimi.dsi.fastutil.longs.LongArrayList +import it.unimi.dsi.fastutil.objects.Object2IntAVLTreeMap +import it.unimi.dsi.fastutil.objects.ObjectAVLTreeSet +import ru.dbotthepony.kommons.util.AABB +import ru.dbotthepony.kommons.util.AABBi +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.locks.ReentrantLock +import java.util.function.Predicate +import kotlin.concurrent.withLock + +// After some thinking, I decided to go with separate spatial index over +// using chunk/chunkmap as spatial indexing of entities (just like original engine does). + +// Advantages of this system include allowing to put entities outside any chunks +// into index and getting rid of "orphaned entities" collection, +// while also setting up ground for better spatial index strategies, if they +// have to be done in the future. +class SpatialIndex(val geometry: WorldGeometry) { + private val lock = ReentrantLock() + private val map = Long2ObjectOpenHashMap() + private val factory = Long2ObjectFunction { Sector(it) } + private val counter = AtomicInteger() + + private fun index(x: Int, y: Int): Long { + require(x in 0 .. Int.MAX_VALUE) { "x out of range: $x" } + require(y in 0 .. Int.MAX_VALUE) { "y out of range: $y" } + return (x.toLong() shl 32) or y.toLong() + } + + private fun values(index: Long): Pair { + return index.ushr(32).toInt() to index.toInt() + } + + private inner class Sector(val index: Long) : Comparable { + val entries = ObjectAVLTreeSet() + + override fun compareTo(other: Sector): Int { + return index.compareTo(other.index) + } + + fun add(entry: Entry) { + entries.add(entry) + } + + fun remove(entry: Entry) { + check(entries.remove(entry)) { "Expected to remove spatial entry from spatial sector" } + + if (entries.isEmpty()) { + check(map.remove(index) == this) { "Tried to remove self from spatial index, but removed something else!" } + } + } + } + + inner class Entry(val value: T) : Comparable { + private val sectors = Object2IntAVLTreeMap() + private val id = counter.getAndIncrement() + private val fixtures = ArrayList(1) + + // default fixture since in most cases it should be enough + val fixture = Fixture() + + fun intersects(rect: AABB, withEdges: Boolean): Boolean { + return fixtures.any { it.intersects(rect, withEdges) } + } + + override fun compareTo(other: Entry): Int { + return id.compareTo(other.id) + } + + private fun ref(sector: Sector) { + val new = sectors.getInt(sector) + 1 + + if (new == 1) { + sector.add(this) + } + + check(new >= 1) + sectors.put(sector, new) + } + + private fun deref(sector: Sector) { + val result = sectors.getInt(sector) - 1 + + if (result <= 0) { + check(result == 0) + sectors.removeInt(sector) + sector.remove(this) + } else { + sectors.put(sector, result) + } + } + + inner class Fixture { + init { + lock.withLock { + fixtures.add(this) + } + } + + private var isRemoved = false + private val sectors = ObjectAVLTreeSet() + private var boxes: List = listOf() + + fun intersects(aabb: AABB, withEdges: Boolean): Boolean { + return boxes.any { if (withEdges) aabb.intersect(it) else aabb.intersectWeak(it) } + } + + fun move(aabb: AABB) { + if (isRemoved) return + val newSectors = LongArrayList() + val split = geometry.split(aabb).first + boxes = split + + for (actualRegion in split) { + val xMin = geometry.x.chunkFromCell(actualRegion.mins.x) + val xMax = geometry.x.chunkFromCell(actualRegion.maxs.x) + + val yMin = geometry.y.chunkFromCell(actualRegion.mins.y) + val yMax = geometry.y.chunkFromCell(actualRegion.maxs.y) + + for (x in xMin .. xMax) { + for (y in yMin .. yMax) { + newSectors.add(index(x, y)) + } + } + } + + if (newSectors.isEmpty) { + // how? + clear() + } else { + val newSectors0 = newSectors.toLongArray() + newSectors0.sort() + + lock.withLock { + if (isRemoved) return + + val addSectors = ArrayList(0) + + val existingItr = sectors.iterator() + val newItr = newSectors0.iterator() + var existing = if (existingItr.hasNext()) existingItr.next() else null + var sector = newItr.nextLong() + + while (true) { + if (existing != null && existing.index == sector) { + // everything is correct + existing = if (existingItr.hasNext()) existingItr.next() else null + + if (!newItr.hasNext()) { + break + } else { + var nextSector = newItr.nextLong() + + while (nextSector == sector && newItr.hasNext()) + nextSector = newItr.nextLong() + + if (nextSector != sector) + sector = nextSector + else + break + } + + continue + } + + if (existing == null || existing.index > sector) { + // we started to reference new sector + val get = map.computeIfAbsent(sector, factory) + addSectors.add(get) + ref(get) + // println("Entered sector ${values(sector)}") + + if (!newItr.hasNext()) { + break + } else { + var nextSector = newItr.nextLong() + + while (nextSector == sector && newItr.hasNext()) + nextSector = newItr.nextLong() + + if (nextSector != sector) + sector = nextSector + else + break + } + + continue + } + + // `existing` references sector which is no longer valid for this fixture + deref(existing) + existingItr.remove() + // println("Left sector ${values(existing.index)}") + existing = if (existingItr.hasNext()) existingItr.next() else null + } + + while (existing != null || existingItr.hasNext()) { + if (existing == null) + existing = existingItr.next() + + if (existing!!.index > newSectors0.last()) { + // println("Left sector ${values(existing.index)}") + existingItr.remove() + } + + existing = null + } + + addSectors.forEach { sectors.add(it) } + } + } + } + + fun clear() { + lock.withLock { + sectors.forEach { deref(it) } + sectors.clear() + } + } + + fun remove() { + lock.withLock { + isRemoved = true + clear() + fixtures.remove(this) + } + } + } + + fun remove() { + lock.withLock { + fixtures.forEach { it.clear() } + sectors.keys.forEach { it.remove(this) } + sectors.clear() + } + } + } + + /** + * [filter] might be invoked for same entry multiple times, regardless pf [distinct] + */ + fun query(rect: AABBi, filter: Predicate = Predicate { true }, distinct: Boolean = true, withEdges: Boolean = true): List { + return query(rect.toDoubleAABB(), filter, distinct, withEdges) + } + + /** + * [predicate] might be invoked for same entry multiple times, regardless pf [distinct] + */ + fun query(rect: AABB, predicate: Predicate = Predicate { true }, distinct: Boolean = true, withEdges: Boolean = true): List { + val entries = ArrayList() + + for (actualRegion in geometry.split(rect).first) { + val xMin = geometry.x.chunkFromCell(actualRegion.mins.x) + val xMax = geometry.x.chunkFromCell(actualRegion.maxs.x) + + val yMin = geometry.y.chunkFromCell(actualRegion.mins.y) + val yMax = geometry.y.chunkFromCell(actualRegion.maxs.y) + + for (x in xMin .. xMax) { + for (y in yMin .. yMax) { + val sector = map[index(x, y)] ?: continue + + for (entry in sector.entries) { + if (predicate.test(entry.value) && entry.intersects(actualRegion, withEdges)) { + entries.add(entry) + } + } + } + } + } + + if (distinct) { + if (entries.isEmpty()) + return listOf() + + entries.sort() + + val entries0 = ArrayList(entries.size) + var previous: Entry? = null + + for (entry in entries) { + if (entry != previous) { + previous = entry + entries0.add(entry.value) + } + } + + return entries0 + } else { + if (entries.isEmpty()) + return listOf() + + return entries.map { it.value } + } + } +} diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/World.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/World.kt index dabedc8a..0c39ae87 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/World.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/World.kt @@ -79,21 +79,7 @@ abstract class World, ChunkType : Chunk() - - for (ent in orphanedEntities) { - if (ent.chunkPos == pos) { - orphanedInThisChunk.add(ent) - } - } - - for (ent in orphanedInThisChunk) { - ent.chunk = chunk - } - - return chunk + return chunkFactory(ChunkPos(x, y)) } abstract val size: Int @@ -229,10 +215,10 @@ abstract class World, ChunkType : Chunk() val entities = Int2ObjectOpenHashMap() - val dynamicEntities = ReferenceOpenHashSet() - val tileEntities = ReferenceOpenHashSet() + val entityIndex = SpatialIndex(geometry) + val dynamicEntities = ArrayList() + val tileEntities = ArrayList() var playerSpawnPosition = Vector2d.ZERO protected set @@ -271,7 +257,12 @@ abstract class World, ChunkType : Chunk, ChunkType : Chunk { + fun queryTileCollisions(aabb: AABB): MutableList { val result = ArrayList() val tiles = aabb.encasingIntAABB() @@ -310,7 +301,7 @@ abstract class World, ChunkType : Chunk): Stream { - return queryCollisions(with.aabb.enlarge(1.0, 1.0)).stream() + return queryTileCollisions(with.aabb.enlarge(1.0, 1.0)).stream() .filter(filter) .map { with.intersect(it.poly) } .filterNotNull() diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/WorldGeometry.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/WorldGeometry.kt index 904a78e3..1f1a5941 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/WorldGeometry.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/WorldGeometry.kt @@ -4,6 +4,7 @@ import it.unimi.dsi.fastutil.objects.ObjectArrayList import it.unimi.dsi.fastutil.objects.ObjectArraySet import ru.dbotthepony.kommons.io.readVector2i import ru.dbotthepony.kommons.io.writeStruct2i +import ru.dbotthepony.kommons.util.AABB import ru.dbotthepony.kommons.util.AABBi import ru.dbotthepony.kommons.util.IStruct2d import ru.dbotthepony.kommons.util.IStruct2f @@ -105,11 +106,49 @@ data class WorldGeometry(val size: Vector2i, val loopX: Boolean, val loopY: Bool } } - fun tileRegion2Chunks(region: AABBi): Set { + /** + * TODO: While for entities it is completely legal to touch world border + * (e.g. have coordinate equal to world's size), it is not for tiles + * + * Currently, this splits rectangles with tile grid's point of view, + * where coordinate at exactly world border is treated as "wrap around" + */ + fun split(region: AABB): Pair, Vector2d> { + val splitX = x.split(region.mins.x, region.maxs.x) + val splitY = y.split(region.mins.y, region.maxs.y) + val offset = Vector2d(splitX.offset, splitY.offset) + + if (splitX.rangeB == null && splitY.rangeB == null) { + // confined + return listOf(AABB(Vector2d(splitX.rangeA.x, splitY.rangeA.x), Vector2d(splitX.rangeA.y, splitY.rangeA.y))) to offset + } else if (splitX.rangeB != null && splitY.rangeB == null) { + // wrapped around X axis + val a = AABB(Vector2d(splitX.rangeA.x, splitY.rangeA.x), Vector2d(splitX.rangeA.y, splitY.rangeA.y)) + val b = AABB(Vector2d(splitX.rangeB.x, splitY.rangeA.x), Vector2d(splitX.rangeB.y, splitY.rangeA.y)) + return listOf(a, b) to offset + } else if (splitX.rangeB == null && splitY.rangeB != null) { + // wrapped around Y axis + val a = AABB(Vector2d(splitX.rangeA.x, splitY.rangeA.x), Vector2d(splitX.rangeA.y, splitY.rangeA.y)) + val b = AABB(Vector2d(splitX.rangeA.x, splitY.rangeB.x), Vector2d(splitX.rangeA.y, splitY.rangeB.y)) + return listOf(a, b) to offset + } else { + // wrapped around X and Y axis + splitX.rangeB!! + splitY.rangeB!! + + val a = AABB(Vector2d(splitX.rangeA.x, splitY.rangeA.x), Vector2d(splitX.rangeA.y, splitY.rangeA.y)) + val b = AABB(Vector2d(splitX.rangeB.x, splitY.rangeA.x), Vector2d(splitX.rangeB.y, splitY.rangeA.y)) + val c = AABB(Vector2d(splitX.rangeA.x, splitY.rangeB.x), Vector2d(splitX.rangeA.y, splitY.rangeB.y)) + val d = AABB(Vector2d(splitX.rangeB.x, splitY.rangeB.x), Vector2d(splitX.rangeB.y, splitY.rangeB.y)) + return listOf(a, b, c, d) to offset + } + } + + fun region2Chunks(region: AABBi): Set { if (region.mins == region.maxs) return emptySet() - val result = ObjectArraySet() + val result = ObjectArrayList() for (actualRegion in split(region).first) { val xMin = this.x.chunkFromCell(actualRegion.mins.x) @@ -125,6 +164,53 @@ data class WorldGeometry(val size: Vector2i, val loopX: Boolean, val loopY: Bool } } - return result + result.sort() + + val itr = result.iterator() + var previous: ChunkPos? = null + + for (value in itr) { + if (value == previous) + itr.remove() + else + previous = value + } + + return ObjectArraySet.ofUnchecked(*result.toTypedArray()) + } + + fun region2Chunks(region: AABB): Set { + if (region.mins == region.maxs) + return emptySet() + + val result = ObjectArrayList() + + for (actualRegion in split(region).first) { + val xMin = this.x.chunkFromCell(actualRegion.mins.x) + val xMax = this.x.chunkFromCell(actualRegion.maxs.x) + + val yMin = this.y.chunkFromCell(actualRegion.mins.y) + val yMax = this.y.chunkFromCell(actualRegion.maxs.y) + + for (x in xMin .. xMax) { + for (y in yMin .. yMax) { + result.add(ChunkPos(x, y)) + } + } + } + + result.sort() + + val itr = result.iterator() + var previous: ChunkPos? = null + + for (value in itr) { + if (value == previous) + itr.remove() + else + previous = value + } + + return ObjectArraySet.ofUnchecked(*result.toTypedArray()) } } diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/AbstractEntity.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/AbstractEntity.kt index 809a7eeb..8626fdf2 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/AbstractEntity.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/AbstractEntity.kt @@ -12,45 +12,13 @@ import ru.dbotthepony.kstarbound.defs.JsonDriven import ru.dbotthepony.kstarbound.network.packets.EntityDestroyPacket import ru.dbotthepony.kstarbound.network.syncher.MasterElement import ru.dbotthepony.kstarbound.network.syncher.NetworkedGroup -import ru.dbotthepony.kstarbound.world.Chunk -import ru.dbotthepony.kstarbound.world.ChunkPos import ru.dbotthepony.kstarbound.world.LightCalculator +import ru.dbotthepony.kstarbound.world.SpatialIndex import ru.dbotthepony.kstarbound.world.World import java.io.DataOutputStream import java.util.function.Consumer -import kotlin.concurrent.withLock -abstract class AbstractEntity(path: String) : JsonDriven(path) { - /** - * The chunk this entity resides in - */ - var chunk: Chunk<*, *>? = null - set(value) { - if (innerWorld == null) { - throw IllegalStateException("Trying to set chunk this entity belong to before spawning in world") - } else if (value != null && innerWorld != value.world) { - throw IllegalArgumentException("$this belongs to $innerWorld, $value belongs to ${value.world}") - } else if (value == field) { - return - } - - val oldChunk = field - field = value - - world.lock.withLock { - if (oldChunk == null && value != null) { - world.orphanedEntities.remove(this) - value.addEntity(this) - } else if (oldChunk != null && value == null) { - world.orphanedEntities.add(this) - oldChunk.removeEntity(this) - } else if (oldChunk != null && value != null) { - value.transferEntity(this, oldChunk) - } - } - } - - abstract val chunkPos: ChunkPos +abstract class AbstractEntity(path: String) : JsonDriven(path), Comparable { abstract val position: Vector2d var entityID: Int = 0 @@ -68,6 +36,10 @@ abstract class AbstractEntity(path: String) : JsonDriven(path) { var connectionID: Int = 0 private set + final override fun compareTo(other: AbstractEntity): Int { + return entityID.compareTo(other.entityID) + } + private val exceptionLogger = Consumer { LOGGER.error("Error while executing queued task on $this", it) } @@ -110,6 +82,13 @@ abstract class AbstractEntity(path: String) : JsonDriven(path) { val networkGroup = MasterElement(NetworkedGroup()) abstract fun writeNetwork(stream: DataOutputStream, isLegacy: Boolean) + protected var spatialEntry: SpatialIndex.Entry? = null + private set + + open fun onNetworkUpdate() { + + } + fun joinWorld(world: World<*, *>) { if (innerWorld != null) throw IllegalStateException("Already spawned (in world $innerWorld)") @@ -126,7 +105,7 @@ abstract class AbstractEntity(path: String) : JsonDriven(path) { innerWorld = world world.entities[entityID] = this - world.orphanedEntities.add(this) + spatialEntry = world.entityIndex.Entry(this) onJoinWorld(world) } @@ -135,11 +114,10 @@ abstract class AbstractEntity(path: String) : JsonDriven(path) { world.ensureSameThread() mailbox.shutdownNow() - chunk = null check(world.entities.remove(entityID) == this) { "Tried to remove $this from $world, but removed something else!" } - world.orphanedEntities.remove(this) onRemove(world) - world.broadcast(EntityDestroyPacket(entityID, ByteArrayList(), false)) + spatialEntry?.remove() + spatialEntry = null innerWorld = null } @@ -160,7 +138,9 @@ abstract class AbstractEntity(path: String) : JsonDriven(path) { } protected open fun tickRemote() { - networkGroup.upstream.tickInterpolation(Starbound.TIMESTEP) + if (networkGroup.upstream.isInterpolating) { + networkGroup.upstream.tickInterpolation(Starbound.TIMESTEP) + } } protected open fun tickLocal() { diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/ActorMovementController.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/ActorMovementController.kt index f97c2ed7..08fb08a1 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/ActorMovementController.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/ActorMovementController.kt @@ -21,7 +21,7 @@ import kotlin.math.PI import kotlin.math.absoluteValue import kotlin.math.sign -class ActorMovementController : MovementController() { +class ActorMovementController() : MovementController() { var controlRun: Boolean = false var controlCrouch: Boolean = false var controlDown: Boolean = false diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/DynamicEntity.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/DynamicEntity.kt index 6b27d42f..6cbf1ace 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/DynamicEntity.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/DynamicEntity.kt @@ -2,13 +2,11 @@ package ru.dbotthepony.kstarbound.world.entities import ru.dbotthepony.kommons.math.RGBAColor import ru.dbotthepony.kommons.util.AABB -import ru.dbotthepony.kommons.vector.Vector2d import ru.dbotthepony.kstarbound.client.StarboundClient import ru.dbotthepony.kstarbound.client.render.LayeredRenderer import ru.dbotthepony.kstarbound.client.render.RenderLayer -import ru.dbotthepony.kstarbound.world.ChunkPos +import ru.dbotthepony.kstarbound.world.SpatialIndex import ru.dbotthepony.kstarbound.world.World -import java.util.function.Consumer /** * Entities with dynamics (Player, Drops, Projectiles, NPCs, etc) @@ -24,29 +22,28 @@ abstract class DynamicEntity(path: String) : AbstractEntity(path) { abstract val movement: MovementController - final override var chunkPos: ChunkPos = ChunkPos.ZERO - private set + override fun onNetworkUpdate() { + super.onNetworkUpdate() + movement.updateFixtures() + } + + override fun tickRemote() { + super.tickRemote() + + if (networkGroup.upstream.isInterpolating) { + movement.updateFixtures() + } + } override fun onJoinWorld(world: World<*, *>) { world.dynamicEntities.add(this) - movement.world = world + movement.initialize(world, spatialEntry) forceChunkRepos = true - - /*movement.positionListeners.addListener(Consumer { field -> - val oldChunkPos = world.geometry.chunkFromCell(old) - val newChunkPos = world.geometry.chunkFromCell(field) - - chunkPos = newChunkPos - - if (oldChunkPos != newChunkPos || forceChunkRepos) { - chunk = world.chunkMap[newChunkPos] - forceChunkRepos = false - } - })*/ } override fun onRemove(world: World<*, *>) { world.dynamicEntities.remove(this) + movement.remove() } override fun render(client: StarboundClient, layers: LayeredRenderer) { @@ -56,7 +53,7 @@ abstract class DynamicEntity(path: String) : AbstractEntity(path) { hitboxes.forEach { it.render(client) } - world.queryCollisions( + world.queryTileCollisions( hitboxes.stream().map { it.aabb }.reduce(AABB::combine).get().enlarge(2.0, 2.0) ).filter(movement::shouldCollideWithBody).forEach { it.poly.render(client, BLOCK_COLLISION_COLOR) } } diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/MovementController.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/MovementController.kt index 8132d35a..64607e9a 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/MovementController.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/MovementController.kt @@ -22,6 +22,7 @@ import ru.dbotthepony.kstarbound.network.syncher.networkedData import ru.dbotthepony.kstarbound.network.syncher.networkedFixedPoint import ru.dbotthepony.kstarbound.network.syncher.networkedFloat import ru.dbotthepony.kstarbound.network.syncher.networkedPoly +import ru.dbotthepony.kstarbound.world.SpatialIndex import ru.dbotthepony.kstarbound.world.World import ru.dbotthepony.kstarbound.world.physics.CollisionPoly import ru.dbotthepony.kstarbound.world.physics.CollisionType @@ -32,10 +33,25 @@ import kotlin.math.absoluteValue import kotlin.math.acos import kotlin.math.cos import kotlin.math.sin -import kotlin.properties.Delegates open class MovementController() { - var world: World<*, *> by Delegates.notNull() + private var world0: World<*, *>? = null + val world: World<*, *> get() = world0!! + private var spatialEntry: SpatialIndex<*>.Entry? = null + + fun initialize(world: World<*, *>, entry: SpatialIndex<*>.Entry?) { + fixtures.clear() + spatialEntry?.remove() + this.world0 = world + spatialEntry = entry + } + + fun remove() { + fixtures.clear() + spatialEntry?.remove() + spatialEntry = null + world0 = null + } val localHitboxes: Stream get() { return (movementParameters.collisionPoly?.map({ Stream.of(it) }, { it.stream() }) ?: return Stream.of()).map { it.rotate(rotation) + position } } @@ -79,11 +95,34 @@ open class MovementController() { private val relativeXSurfaceVelocity = networkGroup.add(networkedFixedPoint(0.0125).also { it.interpolator = Interpolator.Linear }) private val relativeYSurfaceVelocity = networkGroup.add(networkedFixedPoint(0.0125).also { it.interpolator = Interpolator.Linear }) + private val fixtures = ArrayList.Entry.Fixture>() + var fixturesChangeset: Int = 0 + private set + + fun updateFixtures() { + val spatialEntry = spatialEntry ?: return + fixturesChangeset++ + val localHitboxes = localHitboxes.toList() + + while (fixtures.size > localHitboxes.size) { + fixtures.last().remove() + } + + while (fixtures.size < localHitboxes.size) { + fixtures.add(spatialEntry.Fixture()) + } + + for ((i, hitbox) in localHitboxes.withIndex()) { + fixtures[i].move(hitbox.aabb) + } + } + var position: Vector2d get() = Vector2d(xPosition, yPosition) set(value) { xPosition = value.x yPosition = value.y + updateFixtures() } val positionListeners = Listenable.Impl() @@ -233,7 +272,7 @@ open class MovementController() { var queryBounds = aabb.enlarge(maximumCorrection, maximumCorrection) queryBounds = queryBounds.combine(queryBounds + movement) - val polies = world.queryCollisions(queryBounds).filter(this::shouldCollideWithBody) + val polies = world.queryTileCollisions(queryBounds).filter(this::shouldCollideWithBody) val results = ArrayList(localHitboxes.size) diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/TileEntity.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/TileEntity.kt index 4b84d44b..9a5d01d5 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/TileEntity.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/TileEntity.kt @@ -4,30 +4,18 @@ import com.google.gson.JsonObject import ru.dbotthepony.kommons.vector.Vector2d import ru.dbotthepony.kommons.vector.Vector2i import ru.dbotthepony.kstarbound.world.ChunkPos +import ru.dbotthepony.kstarbound.world.SpatialIndex import ru.dbotthepony.kstarbound.world.World /** * (Hopefully) Static world entities (Plants, Objects, etc), which reside on cell grid */ abstract class TileEntity(path: String) : AbstractEntity(path) { - private var forceChunkRepos = false - var tilePosition = Vector2i() set(value) { - val old = field - if (isSpawned) { field = world.geometry.wrap(value) - - val oldChunkPos = world.geometry.chunkFromCell(old) - val newChunkPos = world.geometry.chunkFromCell(field) - - chunkPos = newChunkPos - - if (oldChunkPos != newChunkPos || forceChunkRepos) { - chunk = world.chunkMap[newChunkPos] - forceChunkRepos = false - } + // spatialEntry?.fixture?.move() } else { field = value } @@ -36,12 +24,8 @@ abstract class TileEntity(path: String) : AbstractEntity(path) { override val position: Vector2d get() = tilePosition.toDoubleVector() - final override var chunkPos: ChunkPos = ChunkPos.ZERO - private set - override fun onJoinWorld(world: World<*, *>) { world.tileEntities.add(this) - forceChunkRepos = true tilePosition = tilePosition } diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/player/PlayerEntity.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/player/PlayerEntity.kt index 02819a1e..01aec673 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/player/PlayerEntity.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/player/PlayerEntity.kt @@ -3,6 +3,7 @@ package ru.dbotthepony.kstarbound.world.entities.player import com.google.gson.JsonObject import it.unimi.dsi.fastutil.bytes.ByteArrayList import ru.dbotthepony.kommons.io.writeBinaryString +import ru.dbotthepony.kommons.util.AABB import ru.dbotthepony.kommons.util.getValue import ru.dbotthepony.kommons.util.setValue import ru.dbotthepony.kommons.vector.Vector2d @@ -24,6 +25,9 @@ import ru.dbotthepony.kstarbound.network.syncher.networkedEnumExtraStupid import ru.dbotthepony.kstarbound.network.syncher.networkedEventCounter import ru.dbotthepony.kstarbound.network.syncher.networkedFixedPoint import ru.dbotthepony.kstarbound.network.syncher.networkedString +import ru.dbotthepony.kstarbound.world.SpatialIndex +import ru.dbotthepony.kstarbound.world.World +import ru.dbotthepony.kstarbound.world.entities.AbstractEntity import ru.dbotthepony.kstarbound.world.entities.Animator import ru.dbotthepony.kstarbound.world.entities.HumanoidActorEntity import ru.dbotthepony.kstarbound.world.entities.StatusController @@ -95,6 +99,29 @@ class PlayerEntity() : HumanoidActorEntity("/") { networkGroup.upstream.add(techController.networkGroup) } + private var fixturesChangeset = -1 + private var metaFixture: SpatialIndex.Entry.Fixture? = null + + override fun onJoinWorld(world: World<*, *>) { + super.onJoinWorld(world) + metaFixture = spatialEntry!!.Fixture() + } + + override fun onRemove(world: World<*, *>) { + super.onRemove(world) + metaFixture?.remove() + metaFixture = null + } + + override fun tickShared() { + super.tickShared() + + if (fixturesChangeset != movement.fixturesChangeset) { + fixturesChangeset = movement.fixturesChangeset + metaFixture!!.move(GlobalDefaults.player.metaBoundBox + position) + } + } + override val aimPosition: Vector2d get() = Vector2d(xAimPosition, yAimPosition) diff --git a/src/test/kotlin/ru/dbotthepony/kstarbound/test/WorldTests.kt b/src/test/kotlin/ru/dbotthepony/kstarbound/test/WorldTests.kt index 2e5e9921..51fd4917 100644 --- a/src/test/kotlin/ru/dbotthepony/kstarbound/test/WorldTests.kt +++ b/src/test/kotlin/ru/dbotthepony/kstarbound/test/WorldTests.kt @@ -1,9 +1,14 @@ package ru.dbotthepony.kstarbound.test +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +import ru.dbotthepony.kommons.util.AABB import ru.dbotthepony.kommons.util.AABBi +import ru.dbotthepony.kommons.vector.Vector2d import ru.dbotthepony.kommons.vector.Vector2i +import ru.dbotthepony.kstarbound.world.SpatialIndex import ru.dbotthepony.kstarbound.world.WorldGeometry object WorldTests { @@ -32,4 +37,118 @@ object WorldTests { } } } + + @Test + @DisplayName("Spatial index test") + fun spatialIndex() { + val geometry = WorldGeometry(Vector2i(3000, 2000), true, false) + val index = SpatialIndex(geometry) + + val entry = index.Entry(0) + + // simple + entry.fixture.move(AABB( + Vector2d(1.0, 1.0), + Vector2d(4.0, 4.0), + )) + + assertTrue(index.query(AABB( + Vector2d(14.0, 14.0), + Vector2d(15.0, 17.0), + )).isEmpty()) + + assertTrue(index.query(AABB( + Vector2d(14.0, 14.0), + Vector2d(15.0, 34.0), + )).isEmpty()) + + assertFalse(index.query(AABB( + Vector2d(0.0, 0.5), + Vector2d(1.5, 1.5), + )).isEmpty()) + + // move + entry.fixture.move(AABB( + Vector2d(14.0, 14.0), + Vector2d(15.0, 17.0), + )) + + assertTrue(index.query(AABB( + Vector2d(0.0, 0.5), + Vector2d(1.5, 1.5), + )).isEmpty()) + + assertFalse(index.query(AABB( + Vector2d(13.0, 13.0), + Vector2d(15.0, 15.0), + )).isEmpty()) + + // move to edge of two sectors + entry.fixture.move(AABB( + Vector2d(30.0, 30.0), + Vector2d(40.0, 30.0), + )) + + assertTrue(index.query(AABB( + Vector2d(13.0, 13.0), + Vector2d(15.0, 15.0), + )).isEmpty()) + + assertFalse(index.query(AABB( + Vector2d(31.0, 30.0), + Vector2d(32.0, 31.0), + )).isEmpty()) + + assertFalse(index.query(AABB( + Vector2d(38.0, 30.0), + Vector2d(39.0, 31.0), + )).isEmpty()) + + // move to edge of four sectors + entry.fixture.move(AABB( + Vector2d(30.0, 30.0), + Vector2d(40.0, 40.0), + )) + + assertFalse(index.query(AABB( + Vector2d(38.0, 30.0), + Vector2d(39.0, 31.0), + )).isEmpty()) + + assertFalse(index.query(AABB( + Vector2d(38.0, 33.0), + Vector2d(39.0, 34.0), + )).isEmpty()) + + assertFalse(index.query(AABB( + Vector2d(30.0, 33.0), + Vector2d(31.0, 34.0), + )).isEmpty()) + + // move further, trigger sector "removal" + entry.fixture.move(AABB( + Vector2d(60.0, 30.0), + Vector2d(70.0, 40.0), + )) + + assertTrue(index.query(AABB( + Vector2d(30.0, 33.0), + Vector2d(31.0, 34.0), + )).isEmpty()) + + assertTrue(index.query(AABB( + Vector2d(50.0, 33.0), + Vector2d(55.0, 34.0), + )).isEmpty()) + + assertFalse(index.query(AABB( + Vector2d(61.0, 33.0), + Vector2d(62.0, 34.0), + )).isEmpty()) + + assertFalse(index.query(AABB( + Vector2d(65.0, 33.0), + Vector2d(66.0, 34.0), + )).isEmpty()) + } }