More work on chunk map and chunk tickets

This commit is contained in:
DBotThePony 2024-02-01 18:33:11 +07:00
parent a028694010
commit 2b95bf5e3e
Signed by: DBot
GPG Key ID: DCC23B5715498507
11 changed files with 363 additions and 190 deletions

View File

@ -1004,7 +1004,7 @@ class StarboundClient : Closeable {
if (activeConnection != null) {
activeConnection.send(TrackedPositionPacket(camera.pos))
activeConnection.send(TrackedSizePacket(12, 12))
activeConnection.send(TrackedSizePacket(2, 2))
}
uberShaderPrograms.forEachValid { it.viewMatrix = viewportMatrixScreen }

View File

@ -24,8 +24,8 @@ data class TrackedSizePacket(val width: Int, val height: Int) : IServerPacket {
constructor(stream: DataInputStream) : this(stream.readUnsignedByte(), stream.readUnsignedByte())
init {
require(width in 0 .. 12) { "Too big chunk width to track: $width" }
require(height in 0 .. 12) { "Too big chunk height to track: $height" }
require(width in 1 .. 12) { "Bad chunk width to track: $width" }
require(height in 1 .. 12) { "Bad chunk height to track: $height" }
}
override fun write(stream: DataOutputStream) {

View File

@ -3,6 +3,8 @@ package ru.dbotthepony.kstarbound.io
import it.unimi.dsi.fastutil.ints.IntArraySet
import java.io.*
import java.util.*
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
private fun readHeader(reader: RandomAccessFile, required: Char) {
val read = reader.read()
@ -47,6 +49,7 @@ private operator fun ByteArray.compareTo(b: ByteArray): Int {
*/
class BTreeDB(val path: File) {
val reader = RandomAccessFile(path, "r")
private val lock = ReentrantLock()
init {
readHeader(reader, 'B')
@ -86,9 +89,10 @@ class BTreeDB(val path: File) {
val rootNodeIndex get() = if (useNodeTwo) rootNode2Index else rootNode1Index
val rootNodeIsLeaf get() = if (useNodeTwo) rootNode2IsLeaf else rootNode1IsLeaf
fun readBlockType() = TreeBlockType[reader.readString(2)]
fun readBlockType() = lock.withLock { TreeBlockType[reader.readString(2)] }
fun findAllKeys(index: Long = rootNodeIndex): List<ByteArray> {
lock.withLock {
seekBlock(index)
val list = ArrayList<ByteArray>()
@ -157,10 +161,12 @@ class BTreeDB(val path: File) {
return list
}
}
fun read(key: ByteArray): ByteArray? {
require(key.size == indexKeySize) { "Key provided is ${key.size} in size, while $indexKeySize is required" }
lock.withLock {
seekBlock(rootNodeIndex)
var type = readBlockType()
var iterations = 1000
@ -242,12 +248,16 @@ class BTreeDB(val path: File) {
return null
}
}
fun seekBlock(id: Long) {
require(id >= 0) { "Negative id $id" }
require(id * blockSize + blocksOffsetStart < reader.length()) { "Tried to seek block with $id, but it is outside of file's bounds (file size ${reader.length()} bytes, seeking ${id * blockSize + blocksOffsetStart})! (does not exist)" }
lock.withLock {
reader.seek(id * blockSize + blocksOffsetStart)
}
}
private inner class LeafInputStream(private var offset: Int) : InputStream() {
private var canRead = true

View File

@ -10,14 +10,14 @@ import java.util.concurrent.CompletableFuture
interface IChunkSource {
fun getTiles(pos: ChunkPos): CompletableFuture<KOptional<Object2DArray<out AbstractCell>>>
fun getObjects(pos: ChunkPos): CompletableFuture<KOptional<Collection<WorldObject>>>
fun getEntities(pos: ChunkPos): CompletableFuture<KOptional<Collection<WorldObject>>>
object Void : IChunkSource {
override fun getTiles(pos: ChunkPos): CompletableFuture<KOptional<Object2DArray<out AbstractCell>>> {
return CompletableFuture.completedFuture(KOptional.of(Object2DArray(CHUNK_SIZE, CHUNK_SIZE, AbstractCell.EMPTY)))
}
override fun getObjects(pos: ChunkPos): CompletableFuture<KOptional<Collection<WorldObject>>> {
override fun getEntities(pos: ChunkPos): CompletableFuture<KOptional<Collection<WorldObject>>> {
return CompletableFuture.completedFuture(KOptional.of(emptyList()))
}
}

View File

@ -1,13 +1,20 @@
package ru.dbotthepony.kstarbound.server.world
import org.apache.logging.log4j.LogManager
import ru.dbotthepony.kstarbound.Registries
import ru.dbotthepony.kstarbound.Starbound
import ru.dbotthepony.kstarbound.io.BTreeDB
import ru.dbotthepony.kstarbound.io.readVarInt
import ru.dbotthepony.kstarbound.json.VersionedJson
import ru.dbotthepony.kstarbound.util.KOptional
import ru.dbotthepony.kstarbound.util.get
import ru.dbotthepony.kstarbound.world.CHUNK_SIZE
import ru.dbotthepony.kstarbound.world.ChunkPos
import ru.dbotthepony.kstarbound.world.api.AbstractCell
import ru.dbotthepony.kstarbound.world.api.MutableCell
import ru.dbotthepony.kstarbound.world.entities.WorldObject
import ru.dbotthepony.kvector.arrays.Object2DArray
import ru.dbotthepony.kvector.vector.Vector2i
import java.io.BufferedInputStream
import java.io.ByteArrayInputStream
import java.io.DataInputStream
@ -17,10 +24,11 @@ import java.util.zip.InflaterInputStream
class LegacyChunkSource(val db: BTreeDB) : IChunkSource {
override fun getTiles(pos: ChunkPos): CompletableFuture<KOptional<Object2DArray<out AbstractCell>>> {
return CompletableFuture.supplyAsync {
val chunkX = pos.x
val chunkY = pos.y
val key = byteArrayOf(1, (chunkX shr 8).toByte(), chunkX.toByte(), (chunkY shr 8).toByte(), chunkY.toByte())
val data = db.read(key) ?: return CompletableFuture.completedFuture(KOptional.empty())
val data = db.read(key) ?: return@supplyAsync KOptional.empty()
val reader = DataInputStream(BufferedInputStream(InflaterInputStream(ByteArrayInputStream(data), Inflater())))
reader.skipBytes(3)
@ -33,10 +41,46 @@ class LegacyChunkSource(val db: BTreeDB) : IChunkSource {
}
}
return CompletableFuture.completedFuture(KOptional(result as Object2DArray<out AbstractCell>))
KOptional(result as Object2DArray<out AbstractCell>)
}
}
override fun getObjects(pos: ChunkPos): CompletableFuture<KOptional<Collection<WorldObject>>> {
return CompletableFuture.completedFuture(KOptional.of(listOf()))
override fun getEntities(pos: ChunkPos): CompletableFuture<KOptional<Collection<WorldObject>>> {
return CompletableFuture.supplyAsync {
val chunkX = pos.x
val chunkY = pos.y
val key = byteArrayOf(2, (chunkX shr 8).toByte(), chunkX.toByte(), (chunkY shr 8).toByte(), chunkY.toByte())
val data = db.read(key) ?: return@supplyAsync KOptional.empty()
val reader = DataInputStream(BufferedInputStream(InflaterInputStream(ByteArrayInputStream(data), Inflater())))
val i = reader.readVarInt()
val objects = ArrayList<WorldObject>()
for (i2 in 0 until i) {
val obj = VersionedJson(reader)
if (obj.identifier == "ObjectEntity") {
try {
val content = obj.content.asJsonObject
val prototype = Registries.worldObjects[content["name"]?.asString ?: throw IllegalArgumentException("Missing object name")] ?: throw IllegalArgumentException("No such object defined for '${content["name"]}'")
val pos = content.get("tilePosition", vectors) { throw IllegalArgumentException("No tilePosition was present in saved data") }
val result = WorldObject(prototype, pos)
result.deserialize(content)
objects.add(result)
} catch (err: Throwable) {
LOGGER.error("Unable to deserialize entity in chunk $pos", err)
}
} else {
LOGGER.error("Unknown entity type in chunk $pos: ${obj.identifier}")
}
}
return@supplyAsync KOptional(objects)
}
}
companion object {
private val vectors by lazy { Starbound.gson.getAdapter(Vector2i::class.java) }
private val LOGGER = LogManager.getLogger()
}
}

View File

@ -1,5 +1,6 @@
package ru.dbotthepony.kstarbound.server.world
import it.unimi.dsi.fastutil.objects.ObjectArraySet
import ru.dbotthepony.kstarbound.world.CHUNK_SIZE
import ru.dbotthepony.kstarbound.world.Chunk
import ru.dbotthepony.kstarbound.world.ChunkPos

View File

@ -10,11 +10,13 @@ import ru.dbotthepony.kstarbound.server.network.ServerPlayer
import ru.dbotthepony.kstarbound.util.KOptional
import ru.dbotthepony.kstarbound.util.composeFutures
import ru.dbotthepony.kstarbound.world.ChunkPos
import ru.dbotthepony.kstarbound.world.IChunkSubscriber
import ru.dbotthepony.kstarbound.world.World
import ru.dbotthepony.kstarbound.world.WorldGeometry
import ru.dbotthepony.kstarbound.world.api.AbstractCell
import ru.dbotthepony.kvector.arrays.Object2DArray
import ru.dbotthepony.kstarbound.world.entities.Entity
import ru.dbotthepony.kstarbound.world.entities.WorldObject
import java.io.Closeable
import java.util.concurrent.CompletableFuture
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.locks.LockSupport
import java.util.function.Consumer
@ -196,14 +198,25 @@ class ServerWorld(
ticketLists.add(this@TicketList)
if (chunkProviders.isNotEmpty()) {
val onFinish = Consumer<KOptional<Object2DArray<out AbstractCell>>> {
if (isValid && it.isPresent) {
val chunk = chunkMap.compute(pos) ?: return@Consumer
chunk.loadCells(it.value)
}
}
composeFutures(chunkProviders)
{ if (!isValid) CompletableFuture.completedFuture(KOptional.empty()) else it.getTiles(pos) }
.thenAccept(Consumer { tiles ->
if (!isValid || !tiles.isPresent) return@Consumer
composeFutures(chunkProviders) { it.getTiles(pos) }.thenAcceptAsync(onFinish, mailbox)
composeFutures(chunkProviders)
{ if (!isValid) CompletableFuture.completedFuture(KOptional.empty()) else it.getEntities(pos) }
.thenAcceptAsync(Consumer { ents ->
if (!isValid) return@Consumer
val chunk = chunkMap.compute(pos) ?: return@Consumer
chunk.loadCells(tiles.value)
ents.ifPresent {
for (obj in it) {
chunk.addObject(obj)
}
}
}, mailbox)
})
}
}
}

View File

@ -1,5 +1,7 @@
package ru.dbotthepony.kstarbound.world
import it.unimi.dsi.fastutil.objects.ObjectArrayList
import it.unimi.dsi.fastutil.objects.ObjectArraySet
import it.unimi.dsi.fastutil.objects.ReferenceOpenHashSet
import ru.dbotthepony.kstarbound.world.api.AbstractCell
import ru.dbotthepony.kstarbound.world.api.ICellAccess
@ -7,6 +9,7 @@ import ru.dbotthepony.kstarbound.world.api.ImmutableCell
import ru.dbotthepony.kstarbound.world.api.OffsetCellAccess
import ru.dbotthepony.kstarbound.world.api.TileView
import ru.dbotthepony.kstarbound.world.entities.Entity
import ru.dbotthepony.kstarbound.world.entities.WorldObject
import ru.dbotthepony.kvector.arrays.Object2DArray
import ru.dbotthepony.kvector.util2d.AABB
import ru.dbotthepony.kvector.vector.Vector2d
@ -41,6 +44,25 @@ abstract class Chunk<WorldType : World<WorldType, This>, This : Chunk<WorldType,
var backgroundChangeset = 0
private set
protected val entities = ReferenceOpenHashSet<Entity>()
protected val objects = ReferenceOpenHashSet<WorldObject>()
protected val subscribers = ObjectArraySet<IChunkSubscriber>()
// local cells' tile access
val localBackgroundView = TileView.Background(this)
val localForegroundView = TileView.Foreground(this)
// relative world cells access (accessing 0, 0 will lookup cell in world, relative to this chunk)
val worldView = OffsetCellAccess(world, pos.x * CHUNK_SIZE, pos.y * CHUNK_SIZE)
val worldBackgroundView = TileView.Background(worldView)
val worldForegroundView = TileView.Foreground(worldView)
val aabb = aabbBase + Vector2d(pos.x * CHUNK_SIZE.toDouble(), pos.y * CHUNK_SIZE.toDouble())
protected val cells = lazy {
Object2DArray(CHUNK_SIZE, CHUNK_SIZE, AbstractCell.NULL)
}
fun loadCells(source: Object2DArray<out AbstractCell>) {
val ours = cells.value
source.checkSizeEquals(ours)
@ -52,10 +74,6 @@ abstract class Chunk<WorldType : World<WorldType, This>, This : Chunk<WorldType,
}
}
protected val cells = lazy {
Object2DArray(CHUNK_SIZE, CHUNK_SIZE, AbstractCell.NULL)
}
override fun getCell(x: Int, y: Int): AbstractCell {
if (!cells.isInitialized())
return AbstractCell.NULL
@ -97,17 +115,6 @@ abstract class Chunk<WorldType : World<WorldType, This>, This : Chunk<WorldType,
return true
}
// local cells' tile access
val localBackgroundView = TileView.Background(this)
val localForegroundView = TileView.Foreground(this)
// relative world cells access (accessing 0, 0 will lookup cell in world, relative to this chunk)
val worldView = OffsetCellAccess(world, pos.x * CHUNK_SIZE, pos.y * CHUNK_SIZE)
val worldBackgroundView = TileView.Background(worldView)
val worldForegroundView = TileView.Foreground(worldView)
val aabb = aabbBase + Vector2d(pos.x * CHUNK_SIZE.toDouble(), pos.y * CHUNK_SIZE.toDouble())
protected open fun foregroundChanges(x: Int, y: Int, cell: ImmutableCell) {
cellChanges(x, y, cell)
tileChangeset++
@ -145,12 +152,13 @@ abstract class Chunk<WorldType : World<WorldType, This>, This : Chunk<WorldType,
return world.randomLongFor(x or pos.x shl CHUNK_SIZE_BITS, y or pos.y shl CHUNK_SIZE_BITS)
}
protected val entities = ReferenceOpenHashSet<Entity>()
fun addSubscriber(subscriber: IChunkSubscriber) {
subscribers.add(subscriber)
}
protected open fun onEntityAdded(entity: Entity) { }
protected open fun onEntityTransferedToThis(entity: Entity, otherChunk: This) { }
protected open fun onEntityTransferedFromThis(entity: Entity, otherChunk: This) { }
protected open fun onEntityRemoved(entity: Entity) { }
fun removeSubscriber(subscriber: IChunkSubscriber) {
subscribers.remove(subscriber)
}
fun addEntity(entity: Entity) {
world.lock.withLock {
@ -159,7 +167,7 @@ abstract class Chunk<WorldType : World<WorldType, This>, This : Chunk<WorldType,
}
changeset++
onEntityAdded(entity)
subscribers.forEach { it.onEntityAdded(entity) }
}
}
@ -177,8 +185,8 @@ abstract class Chunk<WorldType : World<WorldType, This>, This : Chunk<WorldType,
}
changeset++
onEntityTransferedToThis(entity, otherChunk as This)
otherChunk.onEntityTransferedFromThis(entity, this as This)
otherChunk.subscribers.forEach { it.onEntityRemoved(entity) }
subscribers.forEach { it.onEntityAdded(entity) }
if (!otherChunk.entities.remove(entity)) {
throw IllegalStateException("Unable to remove $entity from $otherChunk after transfer")
@ -193,7 +201,7 @@ abstract class Chunk<WorldType : World<WorldType, This>, This : Chunk<WorldType,
}
changeset++
onEntityRemoved(entity)
subscribers.forEach { it.onEntityRemoved(entity) }
}
}
@ -201,6 +209,49 @@ abstract class Chunk<WorldType : World<WorldType, This>, This : Chunk<WorldType,
return "Chunk(pos=$pos, entityCount=${entities.size}, world=$world)"
}
fun addObject(obj: WorldObject) {
world.lock.withLock {
if (!objects.add(obj))
throw IllegalStateException("$this already has object $obj!")
if (!world.objects.add(obj))
throw IllegalStateException("World $world already has object $obj!")
obj.spawn(world)
subscribers.forEach { it.onObjectAdded(obj) }
}
}
fun removeObject(obj: WorldObject) {
world.lock.withLock {
if (!objects.remove(obj))
throw IllegalStateException("$this does not have object $obj!")
if (!world.objects.remove(obj))
throw IllegalStateException("World $world does not have object $obj!")
subscribers.forEach { it.onObjectRemoved(obj) }
}
}
open fun remove() {
world.lock.withLock {
for (obj in ObjectArrayList(objects)) {
if (!world.objects.remove(obj)) {
throw IllegalStateException("World $world does not have object $obj!")
}
}
for (ent in ObjectArrayList(entities)) {
ent.chunk = null
}
}
}
open fun think() {
}
companion object {
private val aabbBase = AABB(
Vector2d.ZERO,

View File

@ -0,0 +1,11 @@
package ru.dbotthepony.kstarbound.world
import ru.dbotthepony.kstarbound.world.entities.Entity
import ru.dbotthepony.kstarbound.world.entities.WorldObject
interface IChunkSubscriber {
fun onEntityAdded(entity: Entity)
fun onEntityRemoved(entity: Entity)
fun onObjectAdded(obj: WorldObject)
fun onObjectRemoved(obj: WorldObject)
}

View File

@ -2,6 +2,7 @@ package ru.dbotthepony.kstarbound.world
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap
import it.unimi.dsi.fastutil.objects.ObjectArrayList
import it.unimi.dsi.fastutil.objects.ObjectArraySet
import it.unimi.dsi.fastutil.objects.ReferenceLinkedOpenHashSet
import it.unimi.dsi.fastutil.objects.ReferenceOpenHashSet
import ru.dbotthepony.kstarbound.math.*
@ -62,7 +63,7 @@ abstract class World<This : World<This, ChunkType>, ChunkType : Chunk<This, Chun
return chunkMap.setCell(x, y, cell)
}
abstract inner class ChunkMap {
abstract inner class ChunkMap : Iterable<ChunkType> {
abstract operator fun get(x: Int, y: Int): ChunkType?
abstract fun compute(x: Int, y: Int): ChunkType?
fun compute(pos: ChunkPos) = compute(pos.x, pos.y)
@ -94,6 +95,8 @@ abstract class World<This : World<This, ChunkType>, ChunkType : Chunk<This, Chun
return chunk
}
abstract val size: Int
}
// around 30% slower than ArrayChunkMap, but can support insanely large worlds
@ -141,12 +144,22 @@ abstract class World<This : World<This, ChunkType>, ChunkType : Chunk<This, Chun
}
override fun remove(x: Int, y: Int) {
map.remove(ChunkPos.toLong(geometry.x.chunk(x), geometry.y.chunk(y)))
lock.withLock {
map.remove(ChunkPos.toLong(geometry.x.chunk(x), geometry.y.chunk(y)))?.remove()
}
}
override fun iterator(): Iterator<ChunkType> {
return map.values.iterator()
}
override val size: Int
get() = map.size
}
inner class ArrayChunkMap : ChunkMap() {
private val map = Object2DArray.nulls<ChunkType>(divideUp(geometry.size.x, CHUNK_SIZE), divideUp(geometry.size.y, CHUNK_SIZE))
private val existing = ObjectArraySet<ChunkPos>()
private fun getRaw(x: Int, y: Int): ChunkType? {
return map[x, y]
@ -154,7 +167,7 @@ abstract class World<This : World<This, ChunkType>, ChunkType : Chunk<This, Chun
override fun compute(x: Int, y: Int): ChunkType? {
if (!geometry.x.inBoundsChunk(x) || !geometry.y.inBoundsChunk(y)) return null
return map[x, y] ?: lock.withLock { map[x, y] ?: create(x, y).also { map[x, y] = it } }
return map[x, y] ?: lock.withLock { map[x, y] ?: create(x, y).also { existing.add(ChunkPos(x, y)); map[x, y] = it } }
}
override fun getCell(x: Int, y: Int): AbstractCell {
@ -177,9 +190,37 @@ abstract class World<This : World<This, ChunkType>, ChunkType : Chunk<This, Chun
}
override fun remove(x: Int, y: Int) {
map[geometry.x.chunk(x), geometry.y.chunk(y)] = null
lock.withLock {
val x = geometry.x.chunk(x)
val y = geometry.y.chunk(y)
val get = map[x, y]
if (get != null) {
existing.remove(ChunkPos(x, y))
get.remove()
map[x, y] = null
}
}
}
override fun iterator(): Iterator<ChunkType> {
val parent = existing.iterator()
return object : Iterator<ChunkType> {
override fun hasNext(): Boolean {
return parent.hasNext()
}
override fun next(): ChunkType {
val (x, y) = parent.next()
return map[x, y] ?: throw ConcurrentModificationException()
}
}
}
override val size: Int
get() = existing.size
}
val chunkMap: ChunkMap = if (geometry.size.x <= 32000 && geometry.size.y <= 32000) ArrayChunkMap() else SparseChunkMap()
@ -193,7 +234,7 @@ abstract class World<This : World<This, ChunkType>, ChunkType : Chunk<This, Chun
fun think() {
try {
mailbox.executeQueuedTasks()
val entities = lock.withLock { ObjectArrayList(entities) }
val entities = ObjectArrayList(entities)
ForkJoinPool.commonPool().submit(ParallelPerform(entities.spliterator(), { it.movement.move() })).join()
mailbox.executeQueuedTasks()
@ -207,7 +248,12 @@ abstract class World<This : World<This, ChunkType>, ChunkType : Chunk<This, Chun
}
mailbox.executeQueuedTasks()
val objects = lock.withLock { ObjectArrayList(objects) }
lock
.withLock { ObjectArrayList(chunkMap.iterator()) }
.forEach { it.think() }
val objects = ObjectArrayList(objects)
for (ent in objects) {
ent.thinkShared()

View File

@ -14,6 +14,7 @@ import ru.dbotthepony.kstarbound.defs.JsonDriven
import ru.dbotthepony.kstarbound.defs.image.SpriteReference
import ru.dbotthepony.kstarbound.defs.`object`.ObjectDefinition
import ru.dbotthepony.kstarbound.defs.`object`.ObjectOrientation
import ru.dbotthepony.kstarbound.server.world.ServerWorld
import ru.dbotthepony.kstarbound.util.MailboxExecutorService
import ru.dbotthepony.kstarbound.util.get
import ru.dbotthepony.kstarbound.util.set
@ -24,17 +25,13 @@ import ru.dbotthepony.kstarbound.world.api.TileColor
import ru.dbotthepony.kvector.vector.RGBAColor
import ru.dbotthepony.kvector.vector.Vector2i
import kotlin.concurrent.withLock
import kotlin.properties.Delegates
open class WorldObject(
val world: World<*, *>,
val prototype: Registry.Entry<ObjectDefinition>,
val pos: Vector2i,
) : JsonDriven(prototype.file?.computeDirectory() ?: "/") {
constructor(world: World<*, *>, data: JsonObject) : this(
world,
Registries.worldObjects[data["name"]?.asString ?: throw IllegalArgumentException("Missing object name")] ?: throw IllegalArgumentException("No such object defined for '${data["name"]}'"),
data.get("tilePosition", vectors) { throw IllegalArgumentException("No tilePosition was present in saved data") }
) {
fun deserialize(data: JsonObject) {
direction = data.get("direction", directions) { Side.LEFT }
orientationIndex = data.get("orientationIndex", -1)
interactive = data.get("interactive", false)
@ -49,13 +46,16 @@ open class WorldObject(
}
}
val mailbox = MailboxExecutorService(world.mailbox.thread)
val mailbox = MailboxExecutorService()
var world: World<*, *> by Delegates.notNull()
private set
//
// internal runtime properties
//
val clientWorld get() = world as ClientWorld
val orientations = prototype.value.orientations
inline val clientWorld get() = world as ClientWorld
inline val serverWorld get() = world as ServerWorld
inline val orientations get() = prototype.value.orientations
protected val renderParamLocations = Object2ObjectOpenHashMap<String, () -> String?>()
private var frame = 0
set(value) {
@ -127,18 +127,15 @@ open class WorldObject(
protected open fun innerSpawn() {}
protected open fun innerRemove() {}
fun spawn() {
if (isSpawned) return
fun spawn(world: World<*, *>) {
check(!isSpawned) { "Already spawned in ${this.world}!" }
this.world = world
isSpawned = true
world.mailbox.execute {
world.objects.add(this)
innerSpawn()
invalidate()
}
}
open fun remove() {
fun remove() {
if (isRemoved || !isSpawned) return
isRemoved = true