Entity spatial index

This commit is contained in:
DBotThePony 2024-03-30 12:36:17 +07:00
parent f452cbeeb1
commit 6302661019
Signed by: DBot
GPG Key ID: DCC23B5715498507
21 changed files with 801 additions and 258 deletions

View File

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

View File

@ -308,7 +308,7 @@ class ClientWorld(
}
}
override fun tickInner() {
override fun tick0() {
}

View File

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

View File

@ -190,7 +190,7 @@ abstract class Connection(val side: ConnectionSide, val type: ConnectionType) :
val result = ArrayList<AABBi>()
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

View File

@ -37,7 +37,9 @@ class EntityUpdateSetPacket(val forConnection: Int, val deltas: Int2ObjectMap<By
connection.disconnect("Updating entity with ID $id outside of allowed range ${connection.entityIDRange}")
break
} else {
entities[id]?.networkGroup?.read(delta, Starbound.TIMESTEP * 3.0, connection.isLegacy)
val entity = entities[id] ?: continue
entity.networkGroup.read(delta, Starbound.TIMESTEP * 3.0, connection.isLegacy)
entity.onNetworkUpdate()
}
}
}

View File

@ -164,7 +164,7 @@ class ServerConnection(val server: StarboundServer, type: ConnectionType) : Conn
if (world == shipWorld) {
disconnect("ShipWorld refused to accept its owner: $it")
} else {
enqueueWarp(WarpAlias.OwnShip)
enqueueWarp(returnWarp ?: WarpAlias.OwnShip)
}
}
}
@ -242,6 +242,10 @@ class ServerConnection(val server: StarboundServer, type: ConnectionType) : Conn
shipWorld.thread.start()
enqueueWarp(WarpAlias.OwnShip)
warpingAllowed = true
if (server.channels.connections.size == 2) {
enqueueWarp(WarpAction.Player(server.channels.connections.first().uuid!!))
}
}
}.exceptionally {
LOGGER.error("Error while initializing shipworld for $this", it)

View File

@ -40,6 +40,7 @@ import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.locks.LockSupport
import java.util.concurrent.locks.ReentrantLock
import java.util.function.Consumer
import java.util.function.Predicate
import java.util.function.Supplier
import kotlin.concurrent.withLock
@ -104,7 +105,6 @@ class ServerWorld private constructor(
val spinner = ExecutionSpinner(mailbox::executeQueuedTasks, ::spin, Starbound.TIMESTEP_NANOS)
private val str = "Server World ${if (worldID == WorldID.Limbo) "limbo(${server.limboWorldIndex.getAndIncrement()})" else worldID.toString()}"
val thread = Thread(spinner, str)
val ticketListLock = ReentrantLock()
private val isClosed = AtomicBoolean()
@ -180,7 +180,7 @@ class ServerWorld private constructor(
return topMost
}
override fun tickInner() {
override fun tick0() {
val packet = StepUpdatePacket(ticks)
players.forEach {
@ -207,7 +207,11 @@ class ServerWorld private constructor(
val chunk = chunkMap[it.pos]
if (chunk != null) {
val unloadable = chunk.entities.filter { it.isApplicableForUnloading }
val unloadable = entityIndex
.query(
chunk.aabb,
predicate = Predicate { it.isApplicableForUnloading && chunk.aabb.isInside(it.position) },
distinct = true, withEdges = false)
storage.saveCells(it.pos, chunk.copyCells())
storage.saveEntities(it.pos, unloadable)
@ -237,13 +241,14 @@ class ServerWorld private constructor(
private val ticketMap = Long2ObjectOpenHashMap<TicketList>()
private val ticketLists = ArrayList<TicketList>()
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)

View File

@ -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<ChunkPos>()
val trackingRegions = client.trackingTileRegions()
for (region in client.trackingTileRegions()) {
newTrackedChunks.addAll(world.geometry.tileRegion2Chunks(region))
run {
val newTrackedChunks = ArrayList<ChunkPos>()
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<AbstractEntity>()
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))
}
}
}

View File

@ -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<WorldType : World<WorldType, This>, This : Chunk<WorldType,
var backgroundChangeset = 0
private set
val entities = ReferenceOpenHashSet<AbstractEntity>()
val dynamicEntities = ReferenceOpenHashSet<DynamicEntity>()
val tileEntities = ReferenceOpenHashSet<TileEntity>()
protected val subscribers = ObjectArraySet<IChunkListener>()
protected val subscribers = CopyOnWriteArraySet<IChunkListener>()
// local cells' tile access
val localBackgroundView = TileView.Background(this)
@ -245,94 +243,18 @@ abstract class Chunk<WorldType : World<WorldType, This>, This : Chunk<WorldType,
}
fun addListener(subscriber: IChunkListener): Boolean {
if (subscribers.add(subscriber)) {
entities.forEach { subscriber.onEntityAdded(it) }
return true
}
return false
return subscribers.add(subscriber)
}
fun removeListener(subscriber: IChunkListener): Boolean {
if (subscribers.remove(subscriber)) {
entities.forEach { subscriber.onEntityRemoved(it) }
return true
}
return false
}
fun addEntity(entity: AbstractEntity) {
world.lock.withLock {
if (!entities.add(entity))
throw IllegalArgumentException("Already having having entity $entity")
if (entity is TileEntity)
tileEntities.add(entity)
if (entity is DynamicEntity)
dynamicEntities.add(entity)
changeset++
subscribers.forEach { it.onEntityAdded(entity) }
}
}
fun transferEntity(entity: AbstractEntity, otherChunk: 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() {

View File

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

View File

@ -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) {}
}

View File

@ -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<T>(val geometry: WorldGeometry) {
private val lock = ReentrantLock()
private val map = Long2ObjectOpenHashMap<Sector>()
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<Int, Int> {
return index.ushr(32).toInt() to index.toInt()
}
private inner class Sector(val index: Long) : Comparable<Sector> {
val entries = ObjectAVLTreeSet<Entry>()
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<Entry> {
private val sectors = Object2IntAVLTreeMap<Sector>()
private val id = counter.getAndIncrement()
private val fixtures = ArrayList<Fixture>(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<Sector>()
private var boxes: List<AABB> = 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<Sector>(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<T> = Predicate { true }, distinct: Boolean = true, withEdges: Boolean = true): List<T> {
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<T> = Predicate { true }, distinct: Boolean = true, withEdges: Boolean = true): List<T> {
val entries = ArrayList<Entry>()
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<T>(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 }
}
}
}

View File

@ -79,21 +79,7 @@ abstract class World<This : World<This, ChunkType>, ChunkType : Chunk<This, Chun
operator fun get(pos: ChunkPos) = get(pos.x, pos.y)
protected fun create(x: Int, y: Int): ChunkType {
val pos = ChunkPos(x, y)
val chunk = chunkFactory(pos)
val orphanedInThisChunk = ArrayList<AbstractEntity>()
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<This : World<This, ChunkType>, ChunkType : Chunk<This, Chun
// generic lock
val lock = ReentrantLock()
val orphanedEntities = ReferenceOpenHashSet<AbstractEntity>()
val entities = Int2ObjectOpenHashMap<AbstractEntity>()
val dynamicEntities = ReferenceOpenHashSet<DynamicEntity>()
val tileEntities = ReferenceOpenHashSet<TileEntity>()
val entityIndex = SpatialIndex<AbstractEntity>(geometry)
val dynamicEntities = ArrayList<DynamicEntity>()
val tileEntities = ArrayList<TileEntity>()
var playerSpawnPosition = Vector2d.ZERO
protected set
@ -271,7 +257,12 @@ abstract class World<This : World<This, ChunkType>, ChunkType : Chunk<This, Chun
ticks++
mailbox.executeQueuedTasks()
Starbound.EXECUTOR.submit(ParallelPerform(dynamicEntities.spliterator(), { if (!it.isRemote) it.movement.move() })).join()
Starbound.EXECUTOR.submit(ParallelPerform(dynamicEntities.spliterator(), {
if (!it.isRemote) {
it.movement.move()
}
})).join()
mailbox.executeQueuedTasks()
entities.values.forEach { it.tick() }
@ -282,20 +273,20 @@ abstract class World<This : World<This, ChunkType>, ChunkType : Chunk<This, Chun
}
mailbox.executeQueuedTasks()
tickInner()
tick0()
} catch(err: Throwable) {
throw RuntimeException("Ticking world $this", err)
}
}
protected abstract fun tickInner()
protected abstract fun tick0()
protected abstract fun chunkFactory(pos: ChunkPos): ChunkType
override fun close() {
mailbox.shutdownNow()
}
fun queryCollisions(aabb: AABB): MutableList<CollisionPoly> {
fun queryTileCollisions(aabb: AABB): MutableList<CollisionPoly> {
val result = ArrayList<CollisionPoly>()
val tiles = aabb.encasingIntAABB()
@ -310,7 +301,7 @@ abstract class World<This : World<This, ChunkType>, ChunkType : Chunk<This, Chun
}
fun collide(with: Poly, filter: Predicate<CollisionPoly>): Stream<Poly.Penetration> {
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()

View File

@ -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<ChunkPos> {
/**
* 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<List<AABB>, 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<ChunkPos> {
if (region.mins == region.maxs)
return emptySet()
val result = ObjectArraySet<ChunkPos>()
val result = ObjectArrayList<ChunkPos>()
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<ChunkPos> {
if (region.mins == region.maxs)
return emptySet()
val result = ObjectArrayList<ChunkPos>()
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())
}
}

View File

@ -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<AbstractEntity> {
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<Throwable> {
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<AbstractEntity>.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() {

View File

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

View File

@ -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) }
}

View File

@ -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<Poly>
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<SpatialIndex<*>.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<Vector2d>()
@ -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<CollisionResult>(localHitboxes.size)

View File

@ -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
}

View File

@ -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<AbstractEntity>.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)

View File

@ -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<Int>(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())
}
}