From 53bb3bd84341c5e60ff69a4a2ec1cecc3fa6260b Mon Sep 17 00:00:00 2001 From: DBotThePony Date: Mon, 8 Apr 2024 12:14:42 +0700 Subject: [PATCH] Dungeons generation (need optimizing), BlockableEventLoop (finally normal event loop) --- ADDITIONS.md | 20 + gradle.properties | 2 +- .../kotlin/ru/dbotthepony/kstarbound/Ext.kt | 2 +- .../kotlin/ru/dbotthepony/kstarbound/Main.kt | 24 +- .../ru/dbotthepony/kstarbound/Registries.kt | 3 + .../ru/dbotthepony/kstarbound/Starbound.kt | 56 +- .../kstarbound/client/StarboundClient.kt | 514 +++++++++--------- .../kstarbound/client/render/TileRenderer.kt | 9 +- .../kstarbound/client/world/ClientWorld.kt | 6 +- .../kstarbound/defs/dungeon/DungeonBrush.kt | 321 +++++++++++ .../defs/dungeon/DungeonBrushType.kt | 468 ++++++++++++++++ .../defs/dungeon/DungeonDefinition.kt | 237 ++++++++ .../defs/dungeon/DungeonDirection.kt | 33 ++ .../kstarbound/defs/dungeon/DungeonPart.kt | 381 +++++++++++++ .../kstarbound/defs/dungeon/DungeonRule.kt | 357 ++++++++++++ .../kstarbound/defs/dungeon/DungeonTile.kt | 150 +++++ .../kstarbound/defs/dungeon/DungeonWorld.kt | 485 +++++++++++++++++ .../defs/dungeon/ImagePartReader.kt | 67 +++ .../kstarbound/defs/dungeon/ImageTileSet.kt | 64 +++ .../kstarbound/defs/dungeon/PartReader.kt | 9 + .../kstarbound/defs/dungeon/TileMap.kt | 24 + .../kstarbound/defs/dungeon/TiledMap.kt | 449 +++++++++++++++ .../defs/dungeon/TiledPartReader.kt | 53 ++ .../kstarbound/defs/dungeon/TiledTileSet.kt | 101 ++++ .../kstarbound/defs/dungeon/TiledTileSets.kt | 63 +++ .../kstarbound/defs/image/Image.kt | 43 +- .../defs/tile/BuiltinMetaMaterials.kt | 45 +- .../kstarbound/defs/world/BiomePlaceables.kt | 45 +- .../kstarbound/defs/world/WorldLayout.kt | 19 +- .../kstarbound/defs/world/WorldStructure.kt | 4 +- .../kstarbound/defs/world/WorldTemplate.kt | 84 ++- .../network/LegacyNetworkCellState.kt | 5 +- .../kstarbound/network/PacketRegistry.kt | 13 +- .../server/IntegratedStarboundServer.kt | 6 +- .../kstarbound/server/ServerConnection.kt | 16 +- .../kstarbound/server/StarboundServer.kt | 139 ++--- .../kstarbound/server/world/ServerChunk.kt | 343 +++++++++--- .../server/world/ServerSystemWorld.kt | 6 +- .../kstarbound/server/world/ServerUniverse.kt | 4 +- .../kstarbound/server/world/ServerWorld.kt | 145 ++--- .../server/world/ServerWorldTracker.kt | 2 +- .../kstarbound/util/BlockableEventLoop.kt | 450 +++++++++++++++ .../kstarbound/util/HashTableInterner.kt | 55 +- .../kstarbound/util/random/RandomUtils.kt | 5 + .../ru/dbotthepony/kstarbound/world/Chunk.kt | 12 +- .../dbotthepony/kstarbound/world/Direction.kt | 30 +- .../kstarbound/world/RayDirection.kt | 22 + .../kstarbound/world/Raycasting.kt | 24 +- .../ru/dbotthepony/kstarbound/world/World.kt | 21 +- .../kstarbound/world/api/ICellAccess.kt | 1 + .../world/api/MutableLiquidState.kt | 7 + .../world/entities/AbstractEntity.kt | 4 +- .../world/entities/ActorMovementController.kt | 17 +- .../world/entities/PathController.kt | 4 +- .../world/entities/tile/TileEntity.kt | 11 +- .../world/entities/tile/WorldObject.kt | 26 +- .../kstarbound/world/physics/Poly.kt | 41 +- .../terrain/IslandSurfaceTerrainSelector.kt | 6 +- .../world/terrain/KarstCaveTerrainSelector.kt | 16 +- .../world/terrain/WormCaveTerrainSelector.kt | 8 +- .../dbotthepony/kstarbound/test/MathTests.kt | 33 ++ 61 files changed, 4842 insertions(+), 768 deletions(-) create mode 100644 src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonBrush.kt create mode 100644 src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonBrushType.kt create mode 100644 src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonDefinition.kt create mode 100644 src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonDirection.kt create mode 100644 src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonPart.kt create mode 100644 src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonRule.kt create mode 100644 src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonTile.kt create mode 100644 src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonWorld.kt create mode 100644 src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/ImagePartReader.kt create mode 100644 src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/ImageTileSet.kt create mode 100644 src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/PartReader.kt create mode 100644 src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/TileMap.kt create mode 100644 src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/TiledMap.kt create mode 100644 src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/TiledPartReader.kt create mode 100644 src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/TiledTileSet.kt create mode 100644 src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/TiledTileSets.kt create mode 100644 src/main/kotlin/ru/dbotthepony/kstarbound/util/BlockableEventLoop.kt create mode 100644 src/main/kotlin/ru/dbotthepony/kstarbound/world/RayDirection.kt diff --git a/ADDITIONS.md b/ADDITIONS.md index b0e73315..6f28aea8 100644 --- a/ADDITIONS.md +++ b/ADDITIONS.md @@ -27,6 +27,26 @@ * Also two more properties were added: `sameStemHueShift` (defaults to `true`) and `sameFoliageHueShift` (defaults to `false`), which fixate hue shifts within same "stem-foliage" combination * Original engine always generates two tree types when processing placeable items, new engine however, allows to generate any number of trees. +#### Dungeons + * All brushes are now deterministic, and will produce _exact_ results given same seed (this fixes dungeons being generated differently on each machine despite players visiting exactly same coordinates in universe) + * `front` and `back` brushes now can properly accept detailed data as json object on second position (e.g. `["front", { "material": ... }]`), with following structure (previously, due to oversight in code, it was impossible to specify this structure through any means, because brush definition itself can't be an object): +```kotlin +val material: Registry.Ref = BuiltinMetaMaterials.EMPTY.ref +val modifier: Registry.Ref = BuiltinMetaMaterials.EMPTY_MOD.ref +val hueShift: Float = 0f +val modHueShift: Float = 0f +val color: TileColor = TileColor.DEFAULT +``` + * `item` brush now can accept proper item descriptors (in json object tag), + * Previous behavior remains unchanged (if specified as string, creates _randomized_ item, if as object, creates _exactly_ what have been specified) + * To stop randomizing as Tiled tileset brush, specify `"randomize"` as `false` + * `liquid` brush now can accept 'level' as second argument + * Previous behavior is unchanged, `["liquid", "water", true]` will result into infinite water as before, but `["liquid", "water", 0.5, false]` will spawn half-filled water + * In tiled, you already can do this using `"quantity"` property + * `dungeonid` brush has been hooked up to legacy dungeons and now can be directly specified inside `"brush"` (previously they were only accessible when using Tiled' tilesets). + * By default, they mark entire _part_ of dungeon with their ID. To mark specific tile inside dungeon with its own Dungeon ID, supply `true` as third value to brush (e.g `["dungeonid", 40000, true"]`) + * Tiled map behavior is unchanged, and marks their position only. + --------------- ### player.config diff --git a/gradle.properties b/gradle.properties index ddd86970..0692d0a5 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.12.2 +kommonsVersion=2.12.3 ffiVersion=2.2.13 lwjglVersion=3.3.0 diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/Ext.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/Ext.kt index bf963914..f6c59cef 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/Ext.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/Ext.kt @@ -44,7 +44,7 @@ operator fun ThreadLocal.setValue(thisRef: Any, property: KProperty<*>, v set(value) } -operator fun ImmutableMap.Builder.set(key: K, value: V): ImmutableMap.Builder = put(key, value) +operator fun ImmutableMap.Builder.set(key: K, value: V): ImmutableMap.Builder = put(key, value) fun String.sintern(): String = Starbound.STRINGS.intern(this) diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/Main.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/Main.kt index df936628..238524fd 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/Main.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/Main.kt @@ -1,28 +1,11 @@ package ru.dbotthepony.kstarbound -import kotlinx.coroutines.future.future import org.apache.logging.log4j.LogManager import org.lwjgl.Version -import ru.dbotthepony.kommons.io.ByteKey -import ru.dbotthepony.kommons.util.AABBi -import ru.dbotthepony.kommons.vector.Vector2d -import ru.dbotthepony.kommons.vector.Vector2i import ru.dbotthepony.kstarbound.client.StarboundClient -import ru.dbotthepony.kstarbound.io.BTreeDB5 import ru.dbotthepony.kstarbound.server.IntegratedStarboundServer -import ru.dbotthepony.kstarbound.server.world.LegacyWorldStorage -import ru.dbotthepony.kstarbound.server.world.ServerUniverse -import ru.dbotthepony.kstarbound.server.world.ServerWorld -import ru.dbotthepony.kstarbound.util.random.random -import ru.dbotthepony.kstarbound.util.random.staticRandomDouble -import ru.dbotthepony.kstarbound.world.WorldGeometry -import java.io.BufferedInputStream -import java.io.ByteArrayInputStream -import java.io.DataInputStream import java.io.File import java.net.InetSocketAddress -import java.util.zip.Inflater -import java.util.zip.InflaterInputStream private val LOGGER = LogManager.getLogger() @@ -32,12 +15,9 @@ fun main() { LOGGER.info("Running LWJGL ${Version.getVersion()}") - // println(VersionedJson(meta)) + val client = StarboundClient() - val client = StarboundClient.create().get() - Starbound.initializeGame() - - Starbound.mailboxInitialized.submit { + Starbound.initializeGame().thenApply { val server = IntegratedStarboundServer(client, File("./")) server.channels.createChannel(InetSocketAddress(21060)) } diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/Registries.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/Registries.kt index d23d8045..8cabe20c 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/Registries.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/Registries.kt @@ -31,6 +31,7 @@ import ru.dbotthepony.kstarbound.defs.npc.TenantDefinition import ru.dbotthepony.kstarbound.defs.`object`.ObjectDefinition import ru.dbotthepony.kstarbound.defs.actor.player.TechDefinition import ru.dbotthepony.kstarbound.defs.animation.ParticleConfig +import ru.dbotthepony.kstarbound.defs.dungeon.DungeonDefinition import ru.dbotthepony.kstarbound.defs.projectile.ProjectileDefinition import ru.dbotthepony.kstarbound.defs.quest.QuestTemplate import ru.dbotthepony.kstarbound.defs.tile.LiquidDefinition @@ -83,6 +84,7 @@ object Registries { val treeStemVariants = Registry("tree stem variant").also(registriesInternal::add).also { adapters.add(it.adapter()) } val treeFoliageVariants = Registry("tree foliage variant").also(registriesInternal::add).also { adapters.add(it.adapter()) } val bushVariants = Registry("bush variant").also(registriesInternal::add).also { adapters.add(it.adapter()) } + val dungeons = Registry("dungeon").also(registriesInternal::add).also { adapters.add(it.adapter()) } private fun key(mapper: (T) -> String): (T) -> Pair { return { mapper.invoke(it) to null } @@ -160,6 +162,7 @@ object Registries { tasks.addAll(loadRegistry(treeStemVariants, fileTree["modularstem"] ?: listOf(), key(TreeVariant.StemData::name))) tasks.addAll(loadRegistry(treeFoliageVariants, fileTree["modularfoliage"] ?: listOf(), key(TreeVariant.FoliageData::name))) tasks.addAll(loadRegistry(bushVariants, fileTree["bush"] ?: listOf(), key(BushVariant.Data::name))) + tasks.addAll(loadRegistry(dungeons, fileTree["dungeon"] ?: listOf(), key(DungeonDefinition::name))) tasks.addAll(loadCombined(jsonFunctions, fileTree["functions"] ?: listOf())) tasks.addAll(loadCombined(json2Functions, fileTree["2functions"] ?: listOf())) diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/Starbound.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/Starbound.kt index d636742f..f324d6b8 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/Starbound.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/Starbound.kt @@ -1,6 +1,7 @@ package ru.dbotthepony.kstarbound import com.github.benmanes.caffeine.cache.Interner +import com.github.benmanes.caffeine.cache.Scheduler import com.google.gson.* import it.unimi.dsi.fastutil.objects.Object2ObjectFunction import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap @@ -57,6 +58,7 @@ import ru.dbotthepony.kstarbound.item.ItemStack import ru.dbotthepony.kstarbound.json.JsonAdapterTypeFactory import ru.dbotthepony.kstarbound.json.JsonPath import ru.dbotthepony.kstarbound.json.NativeLegacy +import ru.dbotthepony.kstarbound.util.BlockableEventLoop import ru.dbotthepony.kstarbound.util.Directives import ru.dbotthepony.kstarbound.util.ExceptionLogger import ru.dbotthepony.kstarbound.util.SBPattern @@ -68,6 +70,7 @@ import java.io.* import java.lang.ref.Cleaner import java.text.DateFormat import java.util.concurrent.CompletableFuture +import java.util.concurrent.Executor import java.util.concurrent.ExecutorService import java.util.concurrent.ForkJoinPool import java.util.concurrent.Future @@ -85,12 +88,14 @@ import java.util.stream.Collector import kotlin.NoSuchElementException import kotlin.collections.ArrayList -object Starbound : ISBFileLocator { +object Starbound : BlockableEventLoop("Universe Thread"), Scheduler, ISBFileLocator { const val ENGINE_VERSION = "0.0.1" const val NATIVE_PROTOCOL_VERSION = 748 const val LEGACY_PROTOCOL_VERSION = 747 const val TIMESTEP = 1.0 / 60.0 + const val SYSTEM_WORLD_TIMESTEP = 1.0 / 20.0 const val TIMESTEP_NANOS = (TIMESTEP * 1_000_000_000L).toLong() + const val SYSTEM_WORLD_TIMESTEP_NANOS = (SYSTEM_WORLD_TIMESTEP * 1_000_000_000L).toLong() // compile flags. uuuugh const val DEDUP_CELL_STATES = true @@ -106,14 +111,13 @@ object Starbound : ISBFileLocator { private val LOGGER = LogManager.getLogger() - val thread = Thread(::universeThread, "Universe Thread") - val mailbox = MailboxExecutorService(thread).also { it.exceptionHandler = ExceptionLogger(LOGGER) } - val mailboxBootstrapped = MailboxExecutorService(thread).also { it.exceptionHandler = ExceptionLogger(LOGGER) } - val mailboxInitialized = MailboxExecutorService(thread).also { it.exceptionHandler = ExceptionLogger(LOGGER) } + override fun schedule(executor: Executor, command: Runnable, delay: Long, unit: TimeUnit): Future<*> { + return schedule(Runnable { executor.execute(command) }, delay, unit) + } init { - thread.isDaemon = true - thread.start() + isDaemon = true + start() } private val ioPoolCounter = AtomicInteger() @@ -135,8 +139,6 @@ object Starbound : ISBFileLocator { val EXECUTOR: ForkJoinPool = ForkJoinPool.commonPool() @JvmField val COROUTINE_EXECUTOR = EXECUTOR.asCoroutineDispatcher() - @JvmField - val COROUTINES = CoroutineScope(COROUTINE_EXECUTOR) @JvmField val CLEANER: Cleaner = Cleaner.create { @@ -512,8 +514,6 @@ object Starbound : ISBFileLocator { LOGGER.info("Finished reading PAK archives") bootstrapped = true bootstrapping = false - - checkMailbox() } private fun doInitialize() { @@ -559,8 +559,6 @@ object Starbound : ISBFileLocator { } }) - checkMailbox() - val tasks = ArrayList>() tasks.addAll(Registries.load(ext2files)) @@ -573,7 +571,6 @@ object Starbound : ISBFileLocator { while (tasks.isNotEmpty()) { tasks.removeIf { it.isDone } - checkMailbox() loaded = toLoad - tasks.size loadingProgress = (total - tasks.size) / total LockSupport.parkNanos(5_000_000L) @@ -588,22 +585,12 @@ object Starbound : ISBFileLocator { initialized = true } - fun initializeGame(): Future<*> { - return mailbox.submit { doInitialize() } + fun initializeGame(): CompletableFuture<*> { + return submit { doInitialize() } } - fun bootstrapGame(): Future<*> { - return mailbox.submit { doBootstrap() } - } - - private fun checkMailbox() { - mailbox.executeQueuedTasks() - - if (bootstrapped) - mailboxBootstrapped.executeQueuedTasks() - - if (initialized) - mailboxInitialized.executeQueuedTasks() + fun bootstrapGame(): CompletableFuture<*> { + return submit { doBootstrap() } } private var fontPath: File? = null @@ -614,11 +601,11 @@ object Starbound : ISBFileLocator { if (fontPath != null) return CompletableFuture.completedFuture(fontPath) - return CompletableFuture.supplyAsync(Supplier { + return supplyAsync { val fontPath = Starbound.fontPath if (fontPath != null) - return@Supplier fontPath + return@supplyAsync fontPath val file = locate("/hobo.ttf") @@ -630,15 +617,8 @@ object Starbound : ISBFileLocator { val tempPath = File(System.getProperty("java.io.tmpdir"), "sb-hobo.ttf") tempPath.writeBytes(file.read().array()) Starbound.fontPath = tempPath - return@Supplier tempPath + return@supplyAsync tempPath } - }, mailboxBootstrapped) - } - - private fun universeThread() { - while (true) { - checkMailbox() - LockSupport.park() } } } diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/client/StarboundClient.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/client/StarboundClient.kt index 3a3ec6cc..17ef3029 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/client/StarboundClient.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/client/StarboundClient.kt @@ -2,7 +2,6 @@ package ru.dbotthepony.kstarbound.client import com.github.benmanes.caffeine.cache.Cache import com.github.benmanes.caffeine.cache.Caffeine -import com.github.benmanes.caffeine.cache.Scheduler import io.netty.channel.Channel import io.netty.channel.local.LocalAddress import org.apache.logging.log4j.LogManager @@ -61,12 +60,10 @@ import ru.dbotthepony.kstarbound.client.world.ClientWorld import ru.dbotthepony.kstarbound.defs.image.Image import ru.dbotthepony.kstarbound.math.roundTowardsNegativeInfinity import ru.dbotthepony.kstarbound.math.roundTowardsPositiveInfinity -import ru.dbotthepony.kstarbound.server.StarboundServer -import ru.dbotthepony.kstarbound.util.ExceptionLogger -import ru.dbotthepony.kstarbound.util.ExecutionSpinner -import ru.dbotthepony.kstarbound.util.MailboxExecutorService +import ru.dbotthepony.kstarbound.util.BlockableEventLoop import ru.dbotthepony.kstarbound.util.formatBytesShort import ru.dbotthepony.kstarbound.world.Direction +import ru.dbotthepony.kstarbound.world.RayDirection import ru.dbotthepony.kstarbound.world.LightCalculator import ru.dbotthepony.kstarbound.world.api.ICellAccess import ru.dbotthepony.kstarbound.world.api.AbstractCell @@ -80,9 +77,9 @@ import java.nio.ByteBuffer import java.nio.ByteOrder import java.time.Duration import java.util.* -import java.util.concurrent.CompletableFuture import java.util.concurrent.ForkJoinPool import java.util.concurrent.ForkJoinWorkerThread +import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.locks.ReentrantLock import java.util.function.Consumer @@ -93,11 +90,14 @@ import kotlin.math.absoluteValue import kotlin.math.roundToInt import kotlin.properties.Delegates -class StarboundClient private constructor(val clientID: Int) : Closeable { - val window: Long +class StarboundClient private constructor(val clientID: Int) : BlockableEventLoop("Client Thread $clientID"), Closeable { + constructor() : this(COUNTER.getAndIncrement()) + + var window: Long = 0L + private set val camera = Camera(this) val input = UserInput() - val thread: Thread = Thread.currentThread() + val thread: Thread = this private val threadCounter = AtomicInteger() // client specific executor which will accept tasks which involve probable @@ -119,8 +119,10 @@ class StarboundClient private constructor(val clientID: Int) : Closeable { } }, null, false) - val mailbox = MailboxExecutorService(thread).also { it.exceptionHandler = ExceptionLogger(LOGGER) } - val capabilities: GLCapabilities + @Deprecated("Use this directly", replaceWith = ReplaceWith("this")) + val mailbox = this + var capabilities: GLCapabilities by Delegates.notNull() + private set var viewportX: Int = 0 private set @@ -150,14 +152,11 @@ class StarboundClient private constructor(val clientID: Int) : Closeable { var fullbright = true - var shouldTerminate = false - private set - - var viewportMatrixScreen: Matrix3f + var viewportMatrixScreen: Matrix3f = Matrix3f.rowMajor() private set get() = Matrix3f.unmodifiable(field) - var viewportMatrixWorld: Matrix3f + var viewportMatrixWorld: Matrix3f = Matrix3f.rowMajor() private set get() = Matrix3f.unmodifiable(field) @@ -185,7 +184,6 @@ class StarboundClient private constructor(val clientID: Int) : Closeable { private val scissorStack = LinkedList() private val onDrawGUI = ArrayList<() -> Unit>() private val onViewportChanged = ArrayList<(width: Int, height: Int) -> Unit>() - private val terminateCallbacks = ArrayList<() -> Unit>() private val openglCleanQueue = ReferenceQueue() private var openglCleanQueueHead: CleanRef? = null @@ -201,126 +199,18 @@ class StarboundClient private constructor(val clientID: Int) : Closeable { var openglObjectsCleaned = 0L private set + var maxTextureBlocks: Int = 0 + private set + var maxUserTextureBlocks: Int = 0 // available textures blocks for generic use + private set + var maxVertexAttribBindPoints: Int = 0 + private set + + var lightMapLocation = maxTextureBlocks - 1 + private set + init { - check(CLIENTS.get() == null) { "Already has OpenGL context existing at ${Thread.currentThread()}!" } CLIENTS.set(this) - - lock.lock() - - try { - clients++ - - if (!glfwInitialized) { - check(GLFW.glfwInit()) { "Unable to initialize GLFW" } - glfwInitialized = true - - GLFWErrorCallback.create { error, description -> - LOGGER.error("LWJGL error {}: {}", error, description) - }.set() - } - } finally { - lock.unlock() - } - - GLFW.glfwDefaultWindowHints() - - GLFW.glfwWindowHint(GLFW.GLFW_VISIBLE, GLFW.GLFW_FALSE) - GLFW.glfwWindowHint(GLFW.GLFW_RESIZABLE, GLFW.GLFW_TRUE) - GLFW.glfwWindowHint(GLFW.GLFW_CONTEXT_VERSION_MAJOR, 4) - GLFW.glfwWindowHint(GLFW.GLFW_CONTEXT_VERSION_MINOR, 5) - GLFW.glfwWindowHint(GLFW.GLFW_OPENGL_PROFILE, GLFW.GLFW_OPENGL_CORE_PROFILE) - GLFW.glfwWindowHint(GLFW.GLFW_OPENGL_FORWARD_COMPAT, GLFW.GLFW_TRUE) - - window = GLFW.glfwCreateWindow(800, 600, "KStarbound: Locating files...", MemoryUtil.NULL, MemoryUtil.NULL) - require(window != MemoryUtil.NULL) { "Unable to create GLFW window" } - - input.installCallback(window) - - GLFW.glfwMakeContextCurrent(window) - - // This line is critical for LWJGL's interoperation with GLFW's - // OpenGL context, or any context that is managed externally. - // LWJGL detects the context that is current in the current thread, - // creates the GLCapabilities instance and makes the OpenGL - // bindings available for use. - capabilities = GL.createCapabilities() - - GLFW.glfwSetFramebufferSizeCallback(window) { _, w, h -> - if (w == 0 || h == 0) { - isRenderingGame = false - } else { - isRenderingGame = true - setViewport(0, 0, w, h) - viewportMatrixScreen = updateViewportMatrixScreen() - viewportMatrixWorld = updateViewportMatrixWorld() - - for (callback in onViewportChanged) { - callback.invoke(w, h) - } - } - } - - var stack = MemoryStack.stackPush() - - try { - val pWidth = stack.mallocInt(1) - val pHeight = stack.mallocInt(1) - - GLFW.glfwGetWindowSize(window, pWidth, pHeight) - - val vidmode = GLFW.glfwGetVideoMode(GLFW.glfwGetPrimaryMonitor())!! - - GLFW.glfwSetWindowPos( - window, - (vidmode.width() - pWidth[0]) / 2, - (vidmode.height() - pHeight[0]) / 2 - ) - - setViewport(0, 0, pWidth[0], pHeight[0]) - viewportMatrixScreen = updateViewportMatrixScreen() - viewportMatrixWorld = updateViewportMatrixWorld() - } finally { - stack.close() - } - - stack = MemoryStack.stackPush() - - try { - val pWidth = stack.mallocInt(1) - val pHeight = stack.mallocInt(1) - val pChannels = stack.mallocInt(1) - - val readFromDisk = readInternalBytes("starbound_icon.png") - val buff = ByteBuffer.allocateDirect(readFromDisk.size) - buff.put(readFromDisk) - buff.position(0) - - val data = STBImage.stbi_load_from_memory(buff, pWidth, pHeight, pChannels, 4) ?: throw IllegalStateException("Unable to decode starbound_icon.png") - val img = GLFWImage.malloc() - img.set(pWidth[0], pHeight[0], data) - - GLFW.nglfwSetWindowIcon(window, 1, memAddressSafe(img)) - img.free() - } finally { - stack.close() - } - - // vsync - GLFW.glfwSwapInterval(0) - - GLFW.glfwShowWindow(window) - } - - val maxTextureBlocks = glGetInteger(GL_MAX_TEXTURE_IMAGE_UNITS) - val maxUserTextureBlocks = maxTextureBlocks - 1 // available textures blocks for generic use - val maxVertexAttribBindPoints = glGetInteger(GL_MAX_VERTEX_ATTRIB_BINDINGS) - - init { - LOGGER.info("OpenGL Version: ${glGetString(GL_VERSION)}") - LOGGER.info("OpenGL Vendor: ${glGetString(GL_VENDOR)}") - LOGGER.info("OpenGL Renderer: ${glGetString(GL_RENDERER)}") - LOGGER.debug("Max supported texture image units: $maxTextureBlocks") - LOGGER.debug("Max supported vertex attribute bind points: $maxVertexAttribBindPoints") } val stack = Matrix3fStack() @@ -328,7 +218,7 @@ class StarboundClient private constructor(val clientID: Int) : Closeable { // минимальное время хранения 5 минут и... val named2DTextures0: Cache = Caffeine.newBuilder() .expireAfterAccess(Duration.ofMinutes(1)) - .scheduler(Scheduler.systemScheduler()) + .scheduler(Starbound) .build() // ...бесконечное хранение пока кто-то все ещё использует текстуру @@ -340,8 +230,6 @@ class StarboundClient private constructor(val clientID: Int) : Closeable { private val fontShaderPrograms = ArrayList>() private val uberShaderPrograms = ArrayList>() - val lightMapLocation = maxTextureBlocks - 1 - fun addShaderProgram(program: GLShaderProgram) { if (program is UberShader) { uberShaderPrograms.add(WeakReference(program)) @@ -365,9 +253,7 @@ class StarboundClient private constructor(val clientID: Int) : Closeable { } } - private fun executeQueuedTasks() { - mailbox.executeQueuedTasks() - + private fun performOpenGLCleanup() { var next = openglCleanQueue.poll() as CleanRef? var i = 0 @@ -414,7 +300,8 @@ class StarboundClient private constructor(val clientID: Int) : Closeable { var framebuffer by GLObjectTracker(::glBindFramebuffer, GL_FRAMEBUFFER) var program by GLObjectTracker(::glUseProgram) - val textures2D = GLTexturesTracker(maxTextureBlocks) + var textures2D: GLTexturesTracker by Delegates.notNull() + private set var clearColor by GLGenericTracker(RGBAColor.WHITE) { val (r, g, b, a) = it @@ -432,37 +319,23 @@ class StarboundClient private constructor(val clientID: Int) : Closeable { private var fontInitialized = false - init { - Starbound.loadFont().thenAcceptAsync(Consumer { - try { - font = Font(it.canonicalPath) - fontInitialized = true - } catch (err: Throwable) { - LOGGER.fatal("Unable to load font", err) - } - }, mailbox) - } - val programs = GLPrograms() - init { - glActiveTexture(GL_TEXTURE0) - checkForGLError() - } + val whiteTexture by lazy(LazyThreadSafetyMode.NONE) { + val texture = GLTexture2D(1, 1, GL_RGB8) - val whiteTexture = GLTexture2D(1, 1, GL_RGB8) - val missingTexture = GLTexture2D(8, 8, GL_RGB8) - - init { val buffer = ByteBuffer.allocateDirect(3) buffer.put(0xFF.toByte()) buffer.put(0xFF.toByte()) buffer.put(0xFF.toByte()) buffer.position(0) - whiteTexture.upload(GL_RGB, GL_UNSIGNED_BYTE, buffer) + texture.upload(GL_RGB, GL_UNSIGNED_BYTE, buffer) + + texture } - init { + val missingTexture by lazy(LazyThreadSafetyMode.NONE) { + val texture = GLTexture2D(8, 8, GL_RGB8) val buffer = ByteBuffer.allocateDirect(3 * 8 * 8) for (row in 0 until 4) { @@ -494,7 +367,8 @@ class StarboundClient private constructor(val clientID: Int) : Closeable { } buffer.position(0) - missingTexture.upload(GL_RGB, GL_UNSIGNED_BYTE, buffer) + texture.upload(GL_RGB, GL_UNSIGNED_BYTE, buffer) + texture } fun setViewport(x: Int, y: Int, width: Int, height: Int) { @@ -557,14 +431,6 @@ class StarboundClient private constructor(val clientID: Int) : Closeable { val currentScissorRect get() = scissorStack.lastOrNull() - fun ensureSameThread() { - if (thread !== Thread.currentThread()) { - throw IllegalAccessException("Trying to access $this outside of $thread!") - } - } - - fun isSameThread() = thread === Thread.currentThread() - fun newEBO() = BufferObject.EBO() fun newVBO() = BufferObject.VBO() fun newVAO() = VertexArrayObject() @@ -675,14 +541,6 @@ class StarboundClient private constructor(val clientID: Int) : Closeable { val tileRenderers = TileRenderers(this) var world: ClientWorld? = null - init { - clearColor = RGBAColor.SLATE_GRAY - - blend = true - blendFunc = BlendFunc.MULTIPLY_WITH_ALPHA - } - - val spinner = ExecutionSpinner(::executeQueuedTasks, ::renderFrame, Starbound.TIMESTEP_NANOS) val settings = ClientSettings() val viewportCells: ICellAccess = object : ICellAccess { @@ -702,7 +560,7 @@ class StarboundClient private constructor(val clientID: Int) : Closeable { var viewportLighting = LightCalculator(viewportCells, viewportCellWidth, viewportCellHeight) private set - var viewportLightingTexture = GLTexture2D(1, 1, GL_RGB8) + var viewportLightingTexture: GLTexture2D by Delegates.notNull() private set private var viewportLightingMem: ByteBuffer? = null @@ -754,8 +612,8 @@ class StarboundClient private constructor(val clientID: Int) : Closeable { private fun drawPerformanceBasic(onlyMemory: Boolean) { val runtime = Runtime.getRuntime() - if (!onlyMemory) font.render("Latency: ${(spinner.averageRenderWait * 1_00000.0).toInt() / 100f}ms", scale = 0.4f) - if (!onlyMemory) font.render("Frame: ${(spinner.averageRenderTime * 1_00000.0).toInt() / 100f}ms", y = font.lineHeight * 0.6f, scale = 0.4f) + //if (!onlyMemory) font.render("Latency: ${(spinner.averageRenderWait * 1_00000.0).toInt() / 100f}ms", scale = 0.4f) + //if (!onlyMemory) font.render("Frame: ${(spinner.averageRenderTime * 1_00000.0).toInt() / 100f}ms", y = font.lineHeight * 0.6f, scale = 0.4f) font.render("JVM Heap: ${formatBytesShort(runtime.totalMemory() - runtime.freeMemory())}", y = font.lineHeight * 1.2f, scale = 0.4f) if (!onlyMemory) font.render("OGL C: $openglObjectsCreated D: $openglObjectsCleaned A: ${openglObjectsCreated - openglObjectsCleaned}", y = font.lineHeight * 1.8f, scale = 0.4f) } @@ -763,7 +621,7 @@ class StarboundClient private constructor(val clientID: Int) : Closeable { private var renderedLoadingScreen = false private fun renderLoadingScreen() { - executeQueuedTasks() + performOpenGLCleanup() updateViewportParams() clearColor = RGBAColor.BLACK @@ -905,29 +763,24 @@ class StarboundClient private constructor(val clientID: Int) : Closeable { } } - private fun renderFrame(): Boolean { - if (GLFW.glfwWindowShouldClose(window)) { - close() - return false - } - + private fun renderFrame() { val world = world if (!isRenderingGame) { - executeQueuedTasks() + performOpenGLCleanup() GLFW.glfwPollEvents() if (world != null && Starbound.initialized) world.tick() activeConnection?.flush() - return true + return } if (!Starbound.initialized || !fontInitialized) { - executeQueuedTasks() + performOpenGLCleanup() renderLoadingScreen() - return true + return } if (renderedLoadingScreen) { @@ -937,7 +790,7 @@ class StarboundClient private constructor(val clientID: Int) : Closeable { input.think() camera.think(Starbound.TIMESTEP) - executeQueuedTasks() + performOpenGLCleanup() layers.clear() @@ -978,78 +831,216 @@ class StarboundClient private constructor(val clientID: Int) : Closeable { GLFW.glfwSwapBuffers(window) GLFW.glfwPollEvents() - executeQueuedTasks() + performOpenGLCleanup() activeConnection?.flush() - - return true } - private fun spin() { + private fun tick() { try { - while (!shouldTerminate && spinner.spin()) { - val ply = activeConnection?.character + val ply = activeConnection?.character - if (ply != null) { - camera.pos = ply.position + if (ply != null) { + camera.pos = ply.position - ply.movement.controlMove = if (input.KEY_A_DOWN) Direction.LEFT else if (input.KEY_D_DOWN) Direction.RIGHT else null - ply.movement.controlJump = input.KEY_SPACE_DOWN - ply.movement.controlRun = !input.KEY_LEFT_SHIFT_DOWN - } else { - camera.pos += Vector2d( - (if (input.KEY_A_DOWN) -Starbound.TIMESTEP * 32f / settings.zoom else 0.0) + (if (input.KEY_D_DOWN) Starbound.TIMESTEP * 32f / settings.zoom else 0.0), - (if (input.KEY_W_DOWN) Starbound.TIMESTEP * 32f / settings.zoom else 0.0) + (if (input.KEY_S_DOWN) -Starbound.TIMESTEP * 32f / settings.zoom else 0.0) - ) + ply.movement.controlMove = if (input.KEY_A_DOWN) Direction.LEFT else if (input.KEY_D_DOWN) Direction.RIGHT else null + ply.movement.controlJump = input.KEY_SPACE_DOWN + ply.movement.controlRun = !input.KEY_LEFT_SHIFT_DOWN + } else { + camera.pos += Vector2d( + (if (input.KEY_A_DOWN) -Starbound.TIMESTEP * 32f / settings.zoom else 0.0) + (if (input.KEY_D_DOWN) Starbound.TIMESTEP * 32f / settings.zoom else 0.0), + (if (input.KEY_W_DOWN) Starbound.TIMESTEP * 32f / settings.zoom else 0.0) + (if (input.KEY_S_DOWN) -Starbound.TIMESTEP * 32f / settings.zoom else 0.0) + ) - camera.pos = world?.geometry?.wrap(camera.pos) ?: camera.pos - } + camera.pos = world?.geometry?.wrap(camera.pos) ?: camera.pos + } - if (input.KEY_ESCAPE_PRESSED) { - GLFW.glfwSetWindowShouldClose(window, true) - } + renderFrame() + performOpenGLCleanup() + + if (input.KEY_ESCAPE_PRESSED) { + GLFW.glfwSetWindowShouldClose(window, true) + } + + if (GLFW.glfwWindowShouldClose(window)) { + close() } } catch (err: Throwable) { - LOGGER.fatal("Exception in client loop", err) - } finally { - executor.shutdown() - - lock.lock() - - try { - if (window != MemoryUtil.NULL) { - Callbacks.glfwFreeCallbacks(window) - GLFW.glfwDestroyWindow(window) - } - - if (--clients == 0) { - GLFW.glfwTerminate() - GLFW.glfwSetErrorCallback(null)?.free() - glfwInitialized = false - } - - shouldTerminate = true - - for (callback in terminateCallbacks) { - callback.invoke() - } - } catch (err: Throwable) { - LOGGER.fatal("Exception while destroying client", err) - } finally { - lock.unlock() - } + LOGGER.fatal("Exception in main game logic", err) + shutdownNow() } } - fun onTermination(lambda: () -> Unit) { - terminateCallbacks.add(lambda) + override fun performShutdown() { + executor.shutdown() + + lock.lock() + + try { + if (window != MemoryUtil.NULL) { + Callbacks.glfwFreeCallbacks(window) + GLFW.glfwDestroyWindow(window) + } + + if (--clients == 0) { + GLFW.glfwTerminate() + GLFW.glfwSetErrorCallback(null)?.free() + glfwInitialized = false + } + } catch (err: Throwable) { + LOGGER.fatal("Exception while destroying client", err) + } finally { + lock.unlock() + } } override fun close() { - shouldTerminate = true + shutdown() + } + + private fun initialize() { + check(CLIENTS.get() == null) { "Already has OpenGL context existing at ${currentThread()}!" } + CLIENTS.set(this) + + lock.lock() + + try { + clients++ + + if (!glfwInitialized) { + check(GLFW.glfwInit()) { "Unable to initialize GLFW" } + glfwInitialized = true + + GLFWErrorCallback.create { error, description -> + LOGGER.error("LWJGL error {}: {}", error, description) + }.set() + } + } finally { + lock.unlock() + } + + GLFW.glfwDefaultWindowHints() + + GLFW.glfwWindowHint(GLFW.GLFW_VISIBLE, GLFW.GLFW_FALSE) + GLFW.glfwWindowHint(GLFW.GLFW_RESIZABLE, GLFW.GLFW_TRUE) + GLFW.glfwWindowHint(GLFW.GLFW_CONTEXT_VERSION_MAJOR, 4) + GLFW.glfwWindowHint(GLFW.GLFW_CONTEXT_VERSION_MINOR, 5) + GLFW.glfwWindowHint(GLFW.GLFW_OPENGL_PROFILE, GLFW.GLFW_OPENGL_CORE_PROFILE) + GLFW.glfwWindowHint(GLFW.GLFW_OPENGL_FORWARD_COMPAT, GLFW.GLFW_TRUE) + + window = GLFW.glfwCreateWindow(800, 600, "KStarbound: Locating files...", MemoryUtil.NULL, MemoryUtil.NULL) + require(window != MemoryUtil.NULL) { "Unable to create GLFW window" } + + input.installCallback(window) + + GLFW.glfwMakeContextCurrent(window) + + // This line is critical for LWJGL's interoperation with GLFW's + // OpenGL context, or any context that is managed externally. + // LWJGL detects the context that is current in the current thread, + // creates the GLCapabilities instance and makes the OpenGL + // bindings available for use. + capabilities = GL.createCapabilities() + + GLFW.glfwSetFramebufferSizeCallback(window) { _, w, h -> + if (w == 0 || h == 0) { + isRenderingGame = false + } else { + isRenderingGame = true + setViewport(0, 0, w, h) + viewportMatrixScreen = updateViewportMatrixScreen() + viewportMatrixWorld = updateViewportMatrixWorld() + + for (callback in onViewportChanged) { + callback.invoke(w, h) + } + } + } + + var stack = MemoryStack.stackPush() + + try { + val pWidth = stack.mallocInt(1) + val pHeight = stack.mallocInt(1) + + GLFW.glfwGetWindowSize(window, pWidth, pHeight) + + val vidmode = GLFW.glfwGetVideoMode(GLFW.glfwGetPrimaryMonitor())!! + + GLFW.glfwSetWindowPos( + window, + (vidmode.width() - pWidth[0]) / 2, + (vidmode.height() - pHeight[0]) / 2 + ) + + setViewport(0, 0, pWidth[0], pHeight[0]) + viewportMatrixScreen = updateViewportMatrixScreen() + viewportMatrixWorld = updateViewportMatrixWorld() + } finally { + stack.close() + } + + stack = MemoryStack.stackPush() + + try { + val pWidth = stack.mallocInt(1) + val pHeight = stack.mallocInt(1) + val pChannels = stack.mallocInt(1) + + val readFromDisk = readInternalBytes("starbound_icon.png") + val buff = ByteBuffer.allocateDirect(readFromDisk.size) + buff.put(readFromDisk) + buff.position(0) + + val data = STBImage.stbi_load_from_memory(buff, pWidth, pHeight, pChannels, 4) ?: throw IllegalStateException("Unable to decode starbound_icon.png") + val img = GLFWImage.malloc() + img.set(pWidth[0], pHeight[0], data) + + GLFW.nglfwSetWindowIcon(window, 1, memAddressSafe(img)) + img.free() + } finally { + stack.close() + } + + // vsync + GLFW.glfwSwapInterval(0) + + GLFW.glfwShowWindow(window) + + maxTextureBlocks = glGetInteger(GL_MAX_TEXTURE_IMAGE_UNITS) + maxUserTextureBlocks = maxTextureBlocks - 1 // available textures blocks for generic use + maxVertexAttribBindPoints = glGetInteger(GL_MAX_VERTEX_ATTRIB_BINDINGS) + + textures2D = GLTexturesTracker(maxTextureBlocks) + + LOGGER.info("OpenGL Version: ${glGetString(GL_VERSION)}") + LOGGER.info("OpenGL Vendor: ${glGetString(GL_VENDOR)}") + LOGGER.info("OpenGL Renderer: ${glGetString(GL_RENDERER)}") + LOGGER.debug("Max supported texture image units: $maxTextureBlocks") + LOGGER.debug("Max supported vertex attribute bind points: $maxVertexAttribBindPoints") + + lightMapLocation = maxTextureBlocks - 1 + + clearColor = RGBAColor.SLATE_GRAY + + blend = true + blendFunc = BlendFunc.MULTIPLY_WITH_ALPHA + + viewportLightingTexture = GLTexture2D(1, 1, GL_RGB8) + + Starbound.loadFont().thenAcceptAsync(Consumer { + try { + font = Font(it.canonicalPath) + fontInitialized = true + } catch (err: Throwable) { + LOGGER.fatal("Unable to load font", err) + } + }, this) } init { + CLIENTS.remove() + input.addScrollCallback { _, x, y -> if (y > 0.0) { settings.zoom *= y.toFloat() * 2f @@ -1057,30 +1048,17 @@ class StarboundClient private constructor(val clientID: Int) : Closeable { settings.zoom /= -y.toFloat() * 2f } } + + execute { initialize() } + scheduleAtFixedRate(::tick, 0L, Starbound.TIMESTEP_NANOS, TimeUnit.NANOSECONDS) + start() + } + + override fun toString(): String { + return "StarboundClient[$clientID]" } companion object { - fun create(): CompletableFuture { - val future = CompletableFuture() - val clientID = COUNTER.getAndIncrement() - - val thread = Thread(Runnable { - val client = try { - StarboundClient(clientID) - } catch (err: Throwable) { - future.completeExceptionally(err) - throw err - } - - future.complete(client) - client.spin() - }, "Client Thread $clientID") - - thread.start() - - return future - } - private val COUNTER = AtomicInteger() private val LOGGER = LogManager.getLogger(StarboundClient::class.java) private val CLIENTS = ThreadLocal() diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/client/render/TileRenderer.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/client/render/TileRenderer.kt index 97f184e8..3b254065 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/client/render/TileRenderer.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/client/render/TileRenderer.kt @@ -9,6 +9,7 @@ import ru.dbotthepony.kommons.math.RGBAColor import ru.dbotthepony.kommons.vector.Vector2i import ru.dbotthepony.kstarbound.world.PIXELS_IN_STARBOUND_UNITf import ru.dbotthepony.kstarbound.Registries +import ru.dbotthepony.kstarbound.Starbound import ru.dbotthepony.kstarbound.client.StarboundClient import ru.dbotthepony.kstarbound.client.gl.* import ru.dbotthepony.kstarbound.client.gl.shader.UberShader @@ -30,22 +31,22 @@ import kotlin.math.roundToInt class TileRenderers(val client: StarboundClient) { private val foreground: Cache = Caffeine.newBuilder() .expireAfterAccess(Duration.ofMinutes(5)) - .scheduler(Scheduler.systemScheduler()) + .scheduler(Starbound) .build() private val background: Cache = Caffeine.newBuilder() .expireAfterAccess(Duration.ofMinutes(5)) - .scheduler(Scheduler.systemScheduler()) + .scheduler(Starbound) .build() private val matCache: Cache = Caffeine.newBuilder() .expireAfterAccess(Duration.ofMinutes(5)) - .scheduler(Scheduler.systemScheduler()) + .scheduler(Starbound) .build() private val modCache: Cache = Caffeine.newBuilder() .expireAfterAccess(Duration.ofMinutes(5)) - .scheduler(Scheduler.systemScheduler()) + .scheduler(Starbound) .build() fun getMaterialRenderer(defName: String): TileRenderer { 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 f09d4aa0..3f6bb8bd 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/client/world/ClientWorld.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/client/world/ClientWorld.kt @@ -20,6 +20,7 @@ import ru.dbotthepony.kstarbound.defs.tile.LiquidDefinition import ru.dbotthepony.kstarbound.defs.world.WorldTemplate import ru.dbotthepony.kstarbound.math.roundTowardsNegativeInfinity import ru.dbotthepony.kstarbound.math.roundTowardsPositiveInfinity +import ru.dbotthepony.kstarbound.util.BlockableEventLoop import ru.dbotthepony.kstarbound.world.CHUNK_SIZE import ru.dbotthepony.kstarbound.world.ChunkPos import ru.dbotthepony.kstarbound.world.World @@ -62,9 +63,8 @@ class ClientWorld( return geometry.loopY || value in 0 .. renderRegionsY } - override fun isSameThread(): Boolean { - return client.isSameThread() - } + override val eventLoop: BlockableEventLoop + get() = client inner class RenderRegion(val x: Int, val y: Int) { inner class Layer(private val view: ITileAccess, private val isBackground: Boolean) { diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonBrush.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonBrush.kt new file mode 100644 index 00000000..c2193758 --- /dev/null +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonBrush.kt @@ -0,0 +1,321 @@ +package ru.dbotthepony.kstarbound.defs.dungeon + +import com.google.common.collect.ImmutableList +import com.google.gson.Gson +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import com.google.gson.JsonSyntaxException +import com.google.gson.TypeAdapter +import com.google.gson.annotations.JsonAdapter +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonWriter +import org.apache.logging.log4j.LogManager +import ru.dbotthepony.kommons.vector.Vector2d +import ru.dbotthepony.kstarbound.Registry +import ru.dbotthepony.kstarbound.defs.item.ItemDescriptor +import ru.dbotthepony.kstarbound.defs.`object`.ObjectDefinition +import ru.dbotthepony.kstarbound.defs.tile.BuiltinMetaMaterials +import ru.dbotthepony.kstarbound.defs.tile.LiquidDefinition +import ru.dbotthepony.kstarbound.defs.tile.NO_DUNGEON_ID +import ru.dbotthepony.kstarbound.defs.tile.TileDefinition +import ru.dbotthepony.kstarbound.defs.tile.TileModifierDefinition +import ru.dbotthepony.kstarbound.defs.tile.isRealModifier +import ru.dbotthepony.kstarbound.defs.tile.orEmptyLiquid +import ru.dbotthepony.kstarbound.defs.tile.orEmptyModifier +import ru.dbotthepony.kstarbound.defs.tile.orEmptyTile +import ru.dbotthepony.kstarbound.json.builder.JsonFactory +import ru.dbotthepony.kstarbound.json.builder.JsonIgnore +import ru.dbotthepony.kstarbound.util.random.random +import ru.dbotthepony.kstarbound.world.Direction +import ru.dbotthepony.kstarbound.world.api.AbstractLiquidState +import ru.dbotthepony.kstarbound.world.api.MutableLiquidState +import ru.dbotthepony.kstarbound.world.api.TileColor + +@JsonAdapter(DungeonBrush.Adapter::class) +abstract class DungeonBrush { + enum class Phase { + CLEAR, + DUNGEON_ID, // move dungeon id to very top so it takes effect right away + PLACE_WALLS, + PLACE_MODS, + PLACE_OBJECTS, + PLACE_BIOME_TREES, + PLACE_BIOME_ITEMS, + DO_WIRING, + DROP_ITEMS, + PLACE_NPCS; + } + + class Adapter(gson: Gson) : TypeAdapter() { + private val arrays = gson.getAdapter(JsonArray::class.java) + + override fun write(out: JsonWriter, value: DungeonBrush) { + TODO("Not yet implemented") + } + + override fun read(`in`: JsonReader): DungeonBrush { + val read = arrays.read(`in`) + // don't delegate to EnumAdapter since this can have bad consequences + // such as defaulting to CLEAR action and printing a warning in console + val type = DungeonBrushType.entries.firstOrNull { it.jsonName == read[0].asString } ?: throw NoSuchElementException("Unknown brush type ${read[0].asString}!") + + try { + return type.createLegacy(read) + } catch (err: Throwable) { + throw JsonSyntaxException("Reading dungeon brush $type", err) + } + } + } + + abstract fun execute(x: Int, y: Int, phase: Phase, world: DungeonWorld) + + object Invalid : DungeonBrush() { + override fun execute(x: Int, y: Int, phase: Phase, world: DungeonWorld) { + // do nothing + } + } + + // in original engine this brush is non-deterministic. + data class Random(val children: ImmutableList) : DungeonBrush() { + override fun execute(x: Int, y: Int, phase: Phase, world: DungeonWorld) { + if (children.isEmpty()) + return + + children.random(world.random).execute(x, y, phase, world) + } + } + + object Clear : DungeonBrush() { + override fun execute(x: Int, y: Int, phase: Phase, world: DungeonWorld) { + if (phase !== Phase.CLEAR) + return + + // TODO: delete objects too? + world.setLiquid(x, y, AbstractLiquidState.EMPTY) + world.setForeground(x, y, BuiltinMetaMaterials.EMPTY) + world.setForeground(x, y, BuiltinMetaMaterials.EMPTY_MOD) + world.setBackground(x, y, BuiltinMetaMaterials.EMPTY) + world.setBackground(x, y, BuiltinMetaMaterials.EMPTY_MOD) + + world.setDungeonID(x, y) + } + + override fun toString(): String { + return "Clear" + } + } + + // In original engine due to oversight in Brush::parse and parseFrontBrush + // detailed + data class Tile( + val isBackground: Boolean, + val material: Registry.Ref = BuiltinMetaMaterials.EMPTY.ref, + val modifier: Registry.Ref = BuiltinMetaMaterials.EMPTY_MOD.ref, + val hueShift: Float = 0f, + val modHueShift: Float = 0f, + val color: TileColor = TileColor.DEFAULT, + ) : DungeonBrush() { + constructor(isBackground: Boolean, data: JsonData) : this( + isBackground, + data.material, data.modifier, + data.modHueShift, data.hueShift, + TileColor.entries.firstOrNull { it.lowercase == data.colorVariant.lowercase() } ?: TileColor.entries[data.colorVariant.toIntOrNull() ?: throw JsonSyntaxException("Invalid color variant: ${data.colorVariant}")] + ) + + constructor(isBackground: Boolean, material: Registry.Ref, data: TiledData) : this( + isBackground, material, + data.modifier, data.hueshift, + data.modhueshift, + TileColor.entries.firstOrNull { it.lowercase == data.colorVariant.lowercase() } ?: TileColor.entries[data.colorVariant.toIntOrNull() ?: throw JsonSyntaxException("Invalid color variant: ${data.colorVariant}")] + ) + + @JsonFactory + data class JsonData( + val material: Registry.Ref = BuiltinMetaMaterials.EMPTY.ref, + val modifier: Registry.Ref = BuiltinMetaMaterials.EMPTY_MOD.ref, + val hueShift: Float = 0f, + val modHueShift: Float = 0f, + val colorVariant: String = "0", // HOLY FUCKING SHIT + ) + + @JsonFactory + data class TiledData( + val modifier: Registry.Ref = BuiltinMetaMaterials.EMPTY_MOD.ref, + val hueshift: Float = 0f, + val modhueshift: Float = 0f, + val colorVariant: String = "0", // HOLY FUCKING SHIT + ) + + override fun execute(x: Int, y: Int, phase: Phase, world: DungeonWorld) { + if (phase !== Phase.PLACE_WALLS) + return + + if (isBackground) + world.setBackground(x, y, material.orEmptyTile, hueShift, color) + else + world.setForeground(x, y, material.orEmptyTile, hueShift, color) + + if (!isBackground && material.orEmptyTile.value.collisionKind.isSolidCollision) { + world.setLiquid(x, y, AbstractLiquidState.EMPTY) + } + + if (modifier.isRealModifier) { + if (isBackground) + world.setBackground(x, y, modifier.orEmptyModifier, modHueShift) + else + world.setForeground(x, y, modifier.orEmptyModifier, modHueShift) + } + } + } + + data class WorldObject( + val obj: Registry.Ref, + val direction: Direction = Direction.LEFT, + val parameters: JsonObject = JsonObject(), + ) : DungeonBrush() { + constructor(obj: Registry.Ref, extra: Extra) : this(obj, extra.direction, extra.parameters) + + @JsonFactory + data class Extra( + val direction: Direction = Direction.LEFT, + val parameters: JsonObject = JsonObject(), + ) + + override fun execute(x: Int, y: Int, phase: Phase, world: DungeonWorld) { + if (phase == Phase.PLACE_OBJECTS && obj.isPresent) { + world.placeObject(x, y, obj.entry!!, direction, parameters) + } + } + } + + data class Vehicle(val vehicle: String, val parameters: JsonObject = JsonObject()) : DungeonBrush() { + override fun execute(x: Int, y: Int, phase: Phase, world: DungeonWorld) { + LOGGER.warn("NYI: Vehicle at $x, $y") + } + } + + object BiomeItems : DungeonBrush() { + override fun execute(x: Int, y: Int, phase: Phase, world: DungeonWorld) { + if (phase === Phase.PLACE_BIOME_ITEMS) { + world.placeBiomeItems(x, y) + } + } + + override fun toString(): String { + return "Biome item" + } + } + + object BiomeTree : DungeonBrush() { + override fun execute(x: Int, y: Int, phase: Phase, world: DungeonWorld) { + if (phase === Phase.PLACE_BIOME_TREES) { + world.placeBiomeTree(x, y) + } + } + + override fun toString(): String { + return "Biome tree" + } + } + + data class DropItem(val item: ItemDescriptor, val randomize: Boolean) : DungeonBrush() { + override fun execute(x: Int, y: Int, phase: Phase, world: DungeonWorld) { + if (phase === Phase.DROP_ITEMS) { + if (randomize) { + world.dropRandomizedItem(x, y, item) + } else { + world.dropItem(x, y, item) + } + } + } + } + + data class NPC(val data: JsonObject) : DungeonBrush() { + override fun execute(x: Int, y: Int, phase: Phase, world: DungeonWorld) { + if (phase === Phase.PLACE_NPCS) { + LOGGER.warn("NYI: NPC at $x, $y") + } + } + } + + data class Stagehand(val data: JsonObject) : DungeonBrush() { + override fun execute(x: Int, y: Int, phase: Phase, world: DungeonWorld) { + if (phase === Phase.PLACE_NPCS) { + LOGGER.warn("NYI: Stagehand at $x, $y") + } + } + } + + @JsonFactory + data class Surface(val variant: Int = 0, val mod: Registry.Ref = BuiltinMetaMaterials.EMPTY_MOD.ref, @JsonIgnore val isBackground: Boolean = false) : DungeonBrush() { + val material = BuiltinMetaMaterials.BIOME_META_MATERIALS.getOrNull(variant) ?: throw IllegalArgumentException("Invalid biome metamaterial $variant") + + override fun execute(x: Int, y: Int, phase: Phase, world: DungeonWorld) { + if (phase == Phase.PLACE_WALLS) { + if (!isBackground) // TODO: is this intentional? + world.setForeground(x, y, material) + + world.setBackground(x, y, material) + } else if (phase == Phase.PLACE_MODS) { + if (mod.isRealModifier) { + if (isBackground) + world.setBackground(x, y, mod.orEmptyModifier) + else + world.setForeground(x, y, mod.orEmptyModifier) + } else if (!isBackground && world.needsForegroundBiomeMod(x, y)) { + world.setForeground(x, y, BuiltinMetaMaterials.BIOME_MOD) + } else if (isBackground && world.needsBackgroundBiomeMod(x, y)) { + world.setBackground(x, y, BuiltinMetaMaterials.BIOME_MOD) + } + } + } + } + + data class Liquid(val liquid: Registry.Ref, val level: Float = 1f, val isInfinite: Boolean = false) : DungeonBrush() { + override fun execute(x: Int, y: Int, phase: Phase, world: DungeonWorld) { + if (phase == Phase.PLACE_WALLS) { + world.requestLiquid(x, y, MutableLiquidState(liquid.orEmptyLiquid, level, 1f, isInfinite)) + } + } + } + + @JsonFactory + data class Wire(val group: String, val local: Boolean = false) : DungeonBrush() { + override fun execute(x: Int, y: Int, phase: Phase, world: DungeonWorld) { + if (phase === Phase.DO_WIRING) { + world.placeWiring(x, y, group, local) + } + } + } + + object info_player_start : DungeonBrush() { + override fun execute(x: Int, y: Int, phase: Phase, world: DungeonWorld) { + if (phase === Phase.PLACE_NPCS) { + world.playerStart = Vector2d(x.toDouble(), y.toDouble()) + } + } + + override fun toString(): String { + return "info_player_start" + } + } + + data class DungeonID(val id: Int, val perTile: Boolean = false) : DungeonBrush() { + init { + require(id in 0 .. NO_DUNGEON_ID) { "Dungeon ID out of range: $id" } + } + + override fun execute(x: Int, y: Int, phase: Phase, world: DungeonWorld) { + if (phase == Phase.DUNGEON_ID) { + if (perTile) + world.setDungeonID(x, y, id) + else + world.setDungeonID(id) + } + } + } + + companion object { + private val LOGGER = LogManager.getLogger() + } +} diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonBrushType.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonBrushType.kt new file mode 100644 index 00000000..879cc781 --- /dev/null +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonBrushType.kt @@ -0,0 +1,468 @@ +package ru.dbotthepony.kstarbound.defs.dungeon + +import com.google.common.collect.ImmutableList +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import com.google.gson.JsonPrimitive +import com.google.gson.JsonSyntaxException +import com.google.gson.reflect.TypeToken +import ru.dbotthepony.kommons.gson.contains +import ru.dbotthepony.kommons.gson.get +import ru.dbotthepony.kommons.gson.set +import ru.dbotthepony.kstarbound.Registries +import ru.dbotthepony.kstarbound.Registry +import ru.dbotthepony.kstarbound.Starbound +import ru.dbotthepony.kstarbound.defs.item.ItemDescriptor +import ru.dbotthepony.kstarbound.defs.`object`.ObjectDefinition +import ru.dbotthepony.kstarbound.defs.tile.BuiltinMetaMaterials +import ru.dbotthepony.kstarbound.defs.tile.LiquidDefinition +import ru.dbotthepony.kstarbound.defs.tile.NO_DUNGEON_ID +import ru.dbotthepony.kstarbound.defs.tile.TileDefinition +import ru.dbotthepony.kstarbound.defs.tile.TileModifierDefinition +import ru.dbotthepony.kstarbound.json.builder.IStringSerializable +import ru.dbotthepony.kstarbound.world.Direction + +enum class DungeonBrushType(override val jsonName: String) : IStringSerializable { + INVALID("invalid") { + override fun createLegacy(json: JsonArray): DungeonBrush { + return DungeonBrush.Invalid + } + + override fun readTiled(json: JsonObject): DungeonBrush? { + if ("invalid" in json) { + return DungeonBrush.Invalid + } + + return null + } + }, + + CLEAR("clear") { + override fun createLegacy(json: JsonArray): DungeonBrush { + return DungeonBrush.Clear + } + + override fun readTiled(json: JsonObject): DungeonBrush? { + if (json["clear"]?.asString == "true") { + return DungeonBrush.Clear + } + + return null + } + }, + + RANDOM("random") { + private val adapter by lazy { + Starbound.gson.getAdapter(object : TypeToken>() {}) + } + + override fun createLegacy(json: JsonArray): DungeonBrush { + return DungeonBrush.Random(adapter.fromJsonTree(json[1])) + } + + override fun readTiled(json: JsonObject): DungeonBrush? { + return null + } + }, + + FOREGROUND("front") { + private val adapter by lazy { + Starbound.gson.getAdapter(DungeonBrush.Tile.JsonData::class.java) + } + + private val adapterTiled by lazy { + Starbound.gson.getAdapter(DungeonBrush.Tile.TiledData::class.java) + } + + private val adapter0 by lazy { + Starbound.gson.getAdapter(object : TypeToken>() {}) + } + + private val adapter1 by lazy { + Starbound.gson.getAdapter(object : TypeToken>() {}) + } + + override fun createLegacy(json: JsonArray): DungeonBrush { + if (json.size() == 3) { + return DungeonBrush.Tile(false, adapter0.fromJsonTree(json[1]), adapter1.fromJsonTree(json[2])) + } else if (json.size() == 2) { + if (json[1] is JsonObject) { + return DungeonBrush.Tile(false, adapter.fromJsonTree(json[1])) + } else { + return DungeonBrush.Tile(false, adapter0.fromJsonTree(json[1])) + } + } + + throw IllegalArgumentException("Invalid tile modifier brush: $json") + } + + override fun readTiled(json: JsonObject): DungeonBrush? { + if ("front" in json) { + return DungeonBrush.Tile(false, Registries.tiles.ref(json["front"].asString), adapterTiled.fromJsonTree(json)) + } else if (json["layer"].asString == "front" && "material" in json) { + return DungeonBrush.Tile(false, Registries.tiles.ref(json["material"].asString), adapterTiled.fromJsonTree(json)) + } + + return null + } + }, + + BACKGROUND("back") { + private val adapter by lazy { + Starbound.gson.getAdapter(DungeonBrush.Tile.JsonData::class.java) + } + + private val adapterTiled by lazy { + Starbound.gson.getAdapter(DungeonBrush.Tile.TiledData::class.java) + } + + private val adapter0 by lazy { + Starbound.gson.getAdapter(object : TypeToken>() {}) + } + + private val adapter1 by lazy { + Starbound.gson.getAdapter(object : TypeToken>() {}) + } + + override fun createLegacy(json: JsonArray): DungeonBrush { + if (json.size() == 3) { + return DungeonBrush.Tile(true, adapter0.fromJsonTree(json[1]), adapter1.fromJsonTree(json[2])) + } else if (json.size() == 2) { + if (json[1] is JsonObject) { + return DungeonBrush.Tile(true, adapter.fromJsonTree(json[1])) + } else { + return DungeonBrush.Tile(true, adapter0.fromJsonTree(json[1])) + } + } + + throw IllegalArgumentException("Invalid tile modifier brush: $json") + } + + override fun readTiled(json: JsonObject): DungeonBrush? { + if ("back" in json) { + return DungeonBrush.Tile(true, Registries.tiles.ref(json["back"].asString), adapterTiled.fromJsonTree(json)) + } else if (json["layer"].asString == "back" && "material" in json) { + return DungeonBrush.Tile(true, Registries.tiles.ref(json["material"].asString), adapterTiled.fromJsonTree(json)) + } + + return null + } + }, + + OBJECT("object") { + private val adapter0 by lazy { + Starbound.gson.getAdapter(object : TypeToken>() {}) + } + + private val adapter1 by lazy { + Starbound.gson.getAdapter(DungeonBrush.WorldObject.Extra::class.java) + } + + private val adapterObject by lazy { + Starbound.gson.getAdapter(JsonObject::class.java) + } + + override fun createLegacy(json: JsonArray): DungeonBrush { + if (json.size() > 2) { + return DungeonBrush.WorldObject(adapter0.fromJsonTree(json[1]), adapter1.fromJsonTree(json[2])) + } + + return DungeonBrush.WorldObject(adapter0.fromJsonTree(json[1])) + } + + override fun readTiled(json: JsonObject): DungeonBrush? { + if ("object" in json) { + val ref = Registries.worldObjects.ref(json["object"].asString) + var direction = if ("tilesetDirection" in json) Direction.entries.first { it.jsonName == json["tilesetDirection"].asString } else Direction.RIGHT + + if ("flipX" in json) + direction = direction.opposite + + var parameters = json.get("parameters") + + if (parameters == null || parameters.isJsonNull) + parameters = JsonObject() + else if (parameters is JsonPrimitive) + parameters = adapterObject.fromJson(parameters.asString) + + return DungeonBrush.WorldObject(ref, direction, parameters as JsonObject) + } + + return null + } + }, + + VEHICLE("vehicle") { + override fun createLegacy(json: JsonArray): DungeonBrush { + return DungeonBrush.Vehicle(json[1].asString, if (json.size() >= 3) json[2].asJsonObject else JsonObject()) + } + + override fun readTiled(json: JsonObject): DungeonBrush? { + if ("vehicle" in json) { + return DungeonBrush.Vehicle(json["vehicle"].asString, json.get("parameters") { JsonObject() }) + } + + return null + } + }, + + BIOME_ITEMS("biomeitems") { + override fun createLegacy(json: JsonArray): DungeonBrush { + return DungeonBrush.BiomeItems + } + + override fun readTiled(json: JsonObject): DungeonBrush? { + if ("biomeitems" in json) + return DungeonBrush.BiomeItems + + return null + } + }, + + BIOME_TREE("biometree") { + override fun createLegacy(json: JsonArray): DungeonBrush { + return DungeonBrush.BiomeTree + } + + override fun readTiled(json: JsonObject): DungeonBrush? { + if ("biometree" in json) + return DungeonBrush.BiomeTree + + return null + } + }, + + DROP_ITEM("item") { + override fun createLegacy(json: JsonArray): DungeonBrush { + return DungeonBrush.DropItem(ItemDescriptor(json[1]), json[1] !is JsonObject) + } + + override fun readTiled(json: JsonObject): DungeonBrush? { + if ("item" in json) { + return DungeonBrush.DropItem(ItemDescriptor(json["item"].asString, json.get("count", 1L), json.get("parameters") { JsonObject() }), json.get("randomize", true)) + } + + return null + } + }, + + NPC("npc") { + override fun createLegacy(json: JsonArray): DungeonBrush { + return DungeonBrush.NPC(json[1].asJsonObject) + } + + private val adapterObject by lazy { + Starbound.gson.getAdapter(JsonObject::class.java) + } + + override fun readTiled(json: JsonObject): DungeonBrush? { + if ("npc" in json) { + val brush = JsonObject() + + brush["kind"] = "npc" + brush["species"] = json["npc"] // this may be a single species or a comma + // separated list to be parsed later + + if ("seed" in json) { + brush["seed"] = json["seed"] + } else { + brush["seed"] = "stable" + } + + if ("typeName" in json) { + brush["typeName"] = json["typeName"] + } + + var parameters = json.get("parameters") + + if (parameters == null || parameters.isJsonNull) + parameters = JsonObject() + else if (parameters is JsonPrimitive) + parameters = adapterObject.fromJson(parameters.asString) + + brush["parameters"] = parameters + return DungeonBrush.NPC(brush) + } else if ("monster" in json) { + val brush = JsonObject() + + brush["kind"] = "monster" + brush["typeName"] = json["monster"] + + if ("seed" in json) { + brush["seed"] = json["seed"] + } else { + brush["seed"] = "stable" + } + + var parameters = json.get("parameters") + + if (parameters == null || parameters.isJsonNull) + parameters = JsonObject() + else if (parameters is JsonPrimitive) + parameters = adapterObject.fromJson(parameters.asString) + + brush["parameters"] = parameters + return DungeonBrush.NPC(brush) + } + + return null + } + }, + + STAGEHAND("stagehand") { + override fun createLegacy(json: JsonArray): DungeonBrush { + return DungeonBrush.Stagehand(json[1].asJsonObject) + } + + private val adapterObject by lazy { + Starbound.gson.getAdapter(JsonObject::class.java) + } + + override fun readTiled(json: JsonObject): DungeonBrush? { + if ("stagehand" in json) { + val brush = JsonObject() + + brush["type"] = json["stagehand"] + + var parameters = json.get("parameters") + + if (parameters == null || parameters.isJsonNull) + parameters = JsonObject() + else if (parameters is JsonPrimitive) + parameters = adapterObject.fromJson(parameters.asString) + + brush["parameters"] = parameters + + if ("broadcastArea" in json) // this is set properly as array inside TiledMap + brush["parameters"].asJsonObject["broadcastArea"] = json["broadcastArea"] + + if (json["stagehand"].asString == "radiomessage" && "radiomessage" in json) // why lock behind radiomessage type? + brush["parameters"].asJsonObject["radiomessage"] = json["radiomessage"] + + return DungeonBrush.Stagehand(brush) + } + + return null + } + }, + + SURFACE("surface") { + private val adapter0 by lazy { + Starbound.gson.getAdapter(DungeonBrush.Surface::class.java) + } + + override fun createLegacy(json: JsonArray): DungeonBrush { + return adapter0.fromJsonTree(if (json.size() == 1) JsonObject() else json[1]) + } + + override fun readTiled(json: JsonObject): DungeonBrush? { + if (json["layer"].asString == "front" && "surface" in json) { + val variant = json["surface"].asString.toIntOrNull() ?: 0 + val mod = json["mod"]?.asString?.let { Registries.tileModifiers.ref(it) } ?: BuiltinMetaMaterials.EMPTY_MOD.ref + return DungeonBrush.Surface(variant, mod, false) + } + + return null + } + }, + + SURFACE_BACKGROUND("surfacebackground") { + private val adapter0 by lazy { + Starbound.gson.getAdapter(DungeonBrush.Surface::class.java) + } + + override fun createLegacy(json: JsonArray): DungeonBrush { + return adapter0.fromJsonTree(if (json.size() == 1) JsonObject() else json[1]).copy(isBackground = true) + } + + override fun readTiled(json: JsonObject): DungeonBrush? { + if (json["layer"].asString == "back" && "surface" in json) { + val variant = json["surface"].asString.toIntOrNull() ?: 0 + val mod = json["mod"]?.asString?.let { Registries.tileModifiers.ref(it) } ?: BuiltinMetaMaterials.EMPTY_MOD.ref + return DungeonBrush.Surface(variant, mod, true) + } + + return null + } + }, + + LIQUID("liquid") { + private val adapter0 by lazy { + Starbound.gson.getAdapter(object : TypeToken>() {}) + } + + override fun createLegacy(json: JsonArray): DungeonBrush { + if (json.size() == 1) + throw JsonSyntaxException("No liquid specified") + + if (json.size() == 2) + return DungeonBrush.Liquid(adapter0.fromJsonTree(json[1])) + else if (json.size() == 3) + return DungeonBrush.Liquid(adapter0.fromJsonTree(json[1]), 1f, json[2].asBoolean) + else if (json.size() == 4) + return DungeonBrush.Liquid(adapter0.fromJsonTree(json[1]), json[2].asFloat, json[3].asBoolean) + else + throw JsonSyntaxException("Invalid liquid data: $json") + } + + override fun readTiled(json: JsonObject): DungeonBrush? { + if ("liquid" in json) { + val quantity = json["quantity"]?.asFloat ?: 1f + val source = "source" in json + return DungeonBrush.Liquid(Registries.liquid.ref(json["liquid"].asString), quantity, source) + } + + return null + } + }, + + WIRE("wire") { + private val adapter0 by lazy { + Starbound.gson.getAdapter(DungeonBrush.Wire::class.java) + } + + override fun createLegacy(json: JsonArray): DungeonBrush { + return adapter0.fromJsonTree(if (json.size() == 1) JsonObject() else json[1]) + } + + override fun readTiled(json: JsonObject): DungeonBrush? { + if ("wire" in json) { + return DungeonBrush.Wire(json["wire"].asString, "local" in json) + } + + return null + } + }, + + PLAYER_START("playerstart") { + override fun createLegacy(json: JsonArray): DungeonBrush { + return DungeonBrush.info_player_start + } + + override fun readTiled(json: JsonObject): DungeonBrush? { + if ("playerstart" in json) { + return DungeonBrush.info_player_start + } + + return null + } + }, + + DUNGEON_ID("dungeonid") { + override fun createLegacy(json: JsonArray): DungeonBrush { + return DungeonBrush.DungeonID(json[1].asInt, if (json.size() >= 3) json[2].asBoolean else false) + } + + override fun readTiled(json: JsonObject): DungeonBrush? { + if ("dungeonid" in json) { + return DungeonBrush.DungeonID(json["dungeonid"].asString.toIntOrNull() ?: NO_DUNGEON_ID, false) + } + + return null + } + } + ; + + abstract fun createLegacy(json: JsonArray): DungeonBrush + abstract fun readTiled(json: JsonObject): DungeonBrush? +} diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonDefinition.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonDefinition.kt new file mode 100644 index 00000000..5e19eac3 --- /dev/null +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonDefinition.kt @@ -0,0 +1,237 @@ +package ru.dbotthepony.kstarbound.defs.dungeon + +import com.google.common.collect.ImmutableList +import com.google.common.collect.ImmutableMap +import com.google.common.collect.ImmutableSet +import com.google.gson.JsonSyntaxException +import it.unimi.dsi.fastutil.objects.Object2IntArrayMap +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.future.asCompletableFuture +import org.apache.logging.log4j.LogManager +import ru.dbotthepony.kommons.util.Either +import ru.dbotthepony.kommons.vector.Vector2d +import ru.dbotthepony.kommons.vector.Vector2i +import ru.dbotthepony.kstarbound.Starbound +import ru.dbotthepony.kstarbound.defs.tile.NO_DUNGEON_ID +import ru.dbotthepony.kstarbound.json.builder.JsonFactory +import ru.dbotthepony.kstarbound.server.world.ServerWorld +import ru.dbotthepony.kstarbound.util.random.random +import java.util.concurrent.CompletableFuture +import java.util.random.RandomGenerator + +// Dungeons in Starbound are separated into two categories: +// A. Dungeons described using specific tileset (palette, defined through JSON) and corresponding image maps (chunks) +// this is the legacy (original) way to define dungeons +// B. Dungeons described using Tiled's format stored as JSON +// this is the new way to define dungeons + +// There is no preference how you define dungeons in new engine, both are handled +// with equal care. Tiled dungeons are good for cases where you manually place stuff (actual dungeons), +// image maps dungeons are good for creating terrain features using automated tools (since +// making automated tools output images is way easier than making them output TMX format). + +// Example of above is using third-party complex noise algorithms +// which are not available in game engine. This way you can generate a variety of custom +// terrain features without having to add new terrain selector into engine. +// But keep in mind that adding dungeons into game is not free and +// comes with memory cost. +@JsonFactory +data class DungeonDefinition( + val metadata: Metadata, + // relevant for PNG defined dungeons + val tiles: ImageTileSet = ImageTileSet(), + val parts: ImmutableList, +) { + @JsonFactory + data class Metadata( + val name: String, + val displayName: String = "", + val species: String = "", // why is it required to be present in original code? + val protected: Boolean = false, + val maxRadius: Double = 100.0, + val maxParts: Int = 100, + val extendSurfaceFreeSpace: Int = 0, + val rules: ImmutableList = ImmutableList.of(), + val anchor: ImmutableSet = ImmutableSet.of(), + val gravity: Either? = null, + val breathable: Boolean? = null, + ) { + init { + require(maxRadius > 0.0) { "Non positive maxRadius. What are you trying to achieve?" } + require(anchor.isNotEmpty()) { "No anchors are specified, dungeon won't be able to spawn in world" } + } + } + + val name: String + get() = metadata.name + + init { + parts.forEach { it.bind(this) } + + for (anchor in metadata.anchor) { + if (!parts.any { it.name == anchor }) { + throw JsonSyntaxException("Dungeon contains $anchor as anchor, but there is no such part") + } + } + } + + val partMap: ImmutableMap = parts.stream().map { it.name to it }.collect(ImmutableMap.toImmutableMap({ it.first }, { it.second })) + val anchorParts: ImmutableList = metadata.anchor.stream().map { anchor -> parts.first { it.name == anchor } }.collect(ImmutableList.toImmutableList()) + + private fun connectableParts(connector: DungeonPart.JigsawConnector): List { + val result = ArrayList() + + for (part in parts) { + if (!part.doesNotConnectTo(connector.part)) { + for (pconnector in part.connectors) { + if (pconnector.connectsTo(connector)) { + result.add(pconnector) + } + } + } + } + + return result + } + + private fun choosePart(parts: MutableList, random: RandomGenerator): DungeonPart.JigsawConnector { + val sum = parts.sumOf { it.part.chance } + val sample = random.nextDouble(sum) + var weighting = 0.0 + val itr = parts.iterator() + + for (part in itr) { + weighting += part.part.chance + + if (weighting >= sample) { + itr.remove() + return part + } + } + + return parts.removeLast() + } + + fun validAnchors(world: ServerWorld): List { + return anchorParts.filter { world.template.threatLevel in it.minimumThreatLevel .. it.maximumThreatLevel } + } + + private suspend fun generate0(anchor: DungeonPart, world: DungeonWorld, x: Int, y: Int, forcePlacement: Boolean, dungeonID: Int) { + val placementCounter = Object2IntArrayMap() + val basePos = Vector2i(x, y) + val openSet = ArrayDeque>() + + anchor.place(basePos, world, dungeonID) + + var piecesPlaced = 1 + placementCounter[anchor.name] = 1 + + openSet.add(basePos to anchor) + + val origin = basePos + anchor.reader.size / 2 + val closedConnectors = HashSet() + + while (openSet.isNotEmpty()) { + val (parentPos, parent) = openSet.removeFirst() + + for (connector in parent.connectors) { + val connectorPos = parentPos + connector.offset + + if (!closedConnectors.add(connectorPos)) + continue + + val candidates = connectableParts(connector) + .filter { world.parent.template.threatLevel in it.part.minimumThreatLevel .. it.part.maximumThreatLevel } + .toMutableList() + + while (candidates.isNotEmpty()) { + val candidate = choosePart(candidates, world.random) + val partPos = connectorPos - candidate.offset + candidate.direction.positionAdjustment + val optionPos = connectorPos + candidate.direction.positionAdjustment + + if (!candidate.part.ignoresPartMaximum) { + if (piecesPlaced >= metadata.maxParts) { + continue + } + + if ((partPos - origin).length > metadata.maxRadius) { + continue + } + } + + if (!candidate.part.allowsPlacement(placementCounter.getInt(candidate.part.name))) { + continue + } else if (!candidate.part.checkPartCombinationsAllowed(placementCounter)) { + continue + } else if (candidate.part.collidesWithPlaces(partPos.x, partPos.y, world)) { + continue + } + + if (forcePlacement || candidate.part.canPlace(partPos.x, partPos.y, world)) { + candidate.part.place(partPos, world, dungeonID) + + piecesPlaced++ + placementCounter[candidate.part.name] = placementCounter.getInt(candidate.part.name) + 1 + + closedConnectors.add(partPos) + closedConnectors.add(optionPos) + + openSet.add(partPos to candidate.part) + } + } + } + } + + world.pressurizeLiquids() + } + + fun generate(world: ServerWorld, random: RandomGenerator, x: Int, y: Int, markSurfaceAndTerrain: Boolean, forcePlacement: Boolean, dungeonID: Int = 0, terrainSurfaceSpaceExtends: Int = 0, commit: Boolean = true): CompletableFuture { + require(dungeonID in 0 .. NO_DUNGEON_ID) { "Dungeon ID out of range: $dungeonID" } + val dungeonWorld = DungeonWorld(world, random, if (markSurfaceAndTerrain) y else null, terrainSurfaceSpaceExtends) + + val validAnchors = anchorParts.filter { world.template.threatLevel in it.minimumThreatLevel .. it.maximumThreatLevel } + + if (validAnchors.isEmpty()) { + LOGGER.error("Can't place dungeon ${metadata.name} because it has no valid anchors for threat level ${world.template.threatLevel}") + return CompletableFuture.completedFuture(dungeonWorld) + } + + val anchor = validAnchors.random(world.random) + + return CoroutineScope(Starbound.COROUTINE_EXECUTOR) + .async { + if (forcePlacement || anchor.canPlace(x, y - anchor.placementLevelConstraint, world)) { + generate0(anchor, dungeonWorld, x, y - anchor.placementLevelConstraint, forcePlacement, dungeonID) + + if (commit) { + dungeonWorld.commit() + } + } + + dungeonWorld + } + .asCompletableFuture() + } + + fun build(anchor: DungeonPart, world: ServerWorld, random: RandomGenerator, x: Int, y: Int, dungeonID: Int = NO_DUNGEON_ID, markSurfaceAndTerrain: Boolean = false, forcePlacement: Boolean = false, terrainSurfaceSpaceExtends: Int = 0, commit: Boolean = true): CompletableFuture { + require(anchor in anchorParts) { "$anchor does not belong to $name" } + val dungeonWorld = DungeonWorld(world, random, if (markSurfaceAndTerrain) y else null, terrainSurfaceSpaceExtends) + + return CoroutineScope(Starbound.COROUTINE_EXECUTOR) + .async { + generate0(anchor, dungeonWorld, x, y, forcePlacement, dungeonID) + + if (commit) { + dungeonWorld.commit() + } + + dungeonWorld + } + .asCompletableFuture() + } + + companion object { + private val LOGGER = LogManager.getLogger() + } +} diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonDirection.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonDirection.kt new file mode 100644 index 00000000..5d768567 --- /dev/null +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonDirection.kt @@ -0,0 +1,33 @@ +package ru.dbotthepony.kstarbound.defs.dungeon + +import ru.dbotthepony.kommons.vector.Vector2i +import ru.dbotthepony.kstarbound.json.builder.IStringSerializable + +enum class DungeonDirection(override val jsonName: String, val positionAdjustment: Vector2i) : IStringSerializable { + LEFT("left", Vector2i.NEGATIVE_X) { + override val opposite: DungeonDirection + get() = RIGHT + }, + RIGHT("right", Vector2i.POSITIVE_X) { + override val opposite: DungeonDirection + get() = LEFT + }, + UP("up", Vector2i.POSITIVE_Y) { + override val opposite: DungeonDirection + get() = UP + }, + DOWN("down", Vector2i.NEGATIVE_Y) { + override val opposite: DungeonDirection + get() = DOWN + }, + UNKNOWN("unknown", Vector2i.ZERO) { + override val opposite: DungeonDirection + get() = throw NoSuchElementException() + }, + ANY("any", Vector2i.ZERO) { + override val opposite: DungeonDirection + get() = this + }; + + abstract val opposite: DungeonDirection +} diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonPart.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonPart.kt new file mode 100644 index 00000000..2282042c --- /dev/null +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonPart.kt @@ -0,0 +1,381 @@ +package ru.dbotthepony.kstarbound.defs.dungeon + +import com.google.common.collect.ImmutableList +import com.google.common.collect.ImmutableSet +import com.google.gson.Gson +import com.google.gson.JsonArray +import com.google.gson.TypeAdapter +import com.google.gson.annotations.JsonAdapter +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonWriter +import kotlinx.coroutines.future.await +import org.apache.logging.log4j.LogManager +import ru.dbotthepony.kommons.util.AABBi +import ru.dbotthepony.kommons.util.KOptional +import ru.dbotthepony.kommons.vector.Vector2i +import ru.dbotthepony.kstarbound.defs.image.Image +import ru.dbotthepony.kstarbound.json.builder.JsonFactory +import ru.dbotthepony.kstarbound.json.stream +import ru.dbotthepony.kstarbound.server.world.ServerChunk +import ru.dbotthepony.kstarbound.server.world.ServerWorld +import ru.dbotthepony.kstarbound.util.AssetPathStack +import ru.dbotthepony.kstarbound.world.entities.tile.TileEntity +import ru.dbotthepony.kstarbound.world.entities.tile.WorldObject +import java.io.FileNotFoundException +import java.util.stream.Stream +import kotlin.properties.Delegates + +@JsonAdapter(DungeonPart.Adapter::class) +class DungeonPart(data: JsonData) { + @JsonFactory + data class JsonData( + val name: String, + val rules: ImmutableList, + val chance: Double = 1.0, + val markDungeonId: Boolean = true, + val overrideAllowAlways: Boolean = false, + val minimumThreatLevel: Double = Double.NEGATIVE_INFINITY, + val maximumThreatLevel: Double = Double.POSITIVE_INFINITY, + val clearAnchoredObjects: Boolean = true, + val def: JsonArray, + ) + + val name = data.name + val rules = data.rules + val chance = data.chance.coerceIn(0.0001, 1.0) + val markDungeonId = data.markDungeonId + val overrideAllowAlways = data.overrideAllowAlways + val minimumThreatLevel = data.minimumThreatLevel + val maximumThreatLevel = data.maximumThreatLevel + val clearAnchoredObjects = data.clearAnchoredObjects + + data class JigsawConnector(val part: DungeonPart, val index: String, val forwardOnly: Boolean, val direction: DungeonDirection, val offset: Vector2i) { + fun connectsTo(other: JigsawConnector): Boolean { + if (forwardOnly || index != other.index) + return false + + if (direction == DungeonDirection.ANY || other.direction == DungeonDirection.ANY) + return true + + return direction == other.direction.opposite + } + } + + var connectors: ImmutableList by Delegates.notNull() + private set + + var anchor: Vector2i by Delegates.notNull() + private set + + // should be used only when placing actual dungeons, and not microdungeons + val placementLevelConstraint: Int by lazy { + var air = reader.size.y + var airX = 0 + var ground = 0 + var groundX = 0 + var liquid = 0 + var liquidX = 0 + + reader.iterateTiles { x, y, tile -> + for (rule in tile.rules) { + if (rule.requiresSolid && y > ground) { + ground = y + groundX = x + } + + if (rule.requiresOpen && y < air) { + air = y + airX = x + } + + if ((rule === DungeonRule.MustContainLiquid || rule === DungeonRule.MustNotContainLiquid) && y > liquid) { + liquid = y + liquidX = x + } + } + } + + ground = ground.coerceAtLeast(liquid) + + if (air < ground) { + throw IllegalArgumentException("Invalid ground vs air constraint, ground at: $groundX,$ground; air at: $airX,$air; liquid at: $liquidX,$liquid for part $name") + } else { + air + } + } + + val ignoresPartMaximum: Boolean = rules.any { it.ignorePartMaximum } + + var dungeon by Delegates.notNull() + private set + + val reader: PartReader + + init { + if (data.def[0].asString == "image") { + if (data.def[1].isJsonPrimitive) { + reader = ImagePartReader(this, ImmutableList.of(Image.get(AssetPathStack.remap(data.def[1].asString)) ?: throw FileNotFoundException("Unable to locate image file ${data.def[1].asString} (${AssetPathStack.remap(data.def[1].asString)}) for dungeon part $name!"))) + } else { + // assume array of images + reader = ImagePartReader( + this, + data.def[1].asJsonArray + .stream() + .map { it.asString } + .map { Image.get(AssetPathStack.remap(it)) ?: throw FileNotFoundException("Unable to locate image file $it (${AssetPathStack.remap(it)}) for dungeon part $name!") } + .collect(ImmutableList.toImmutableList()) + ) + } + } else if (data.def[0].asString == "tmx") { + if (data.def[1].isJsonPrimitive) { + reader = TiledPartReader(this, Stream.of(data.def[1].asString)) + } else { + reader = TiledPartReader(this, data.def[1].asJsonArray.stream().map { it.asString }) + } + } else { + throw IllegalArgumentException("Unknown part type ${data.def[0].asString}!") + } + } + + fun bind(def: DungeonDefinition) { + dungeon = def + reader.bind(def) + + val connectors = ArrayList() + + var cx = 0 + var cy = 0 + var cc = 0 + + var lowestAir = reader.size.y + var highestGround = -1 + var highestLiquid = -1 + + reader.iterateTiles { x, y, tile -> + if (tile.connector != null) { + var direction = tile.connector.direction + + if (direction == DungeonDirection.UNKNOWN) + direction = pickByNeighbours(x, y) + + if (direction == DungeonDirection.UNKNOWN) + direction = pickByEdge(x, y) + + connectors.add(JigsawConnector(this, tile.connector.index, tile.connector.forward, direction, Vector2i(x, y))) + } + + if (tile.collidesWithPlaces) { + cx += x + cy += y + cc++ + } + + if (tile.requiresOpen && y < lowestAir) { + lowestAir = y + } + + if (tile.requiresSolid && y > highestGround) { + highestGround = y + } + + if (tile.requiresLiquid && y > highestLiquid) { + highestLiquid = y + } + } + + this.connectors = ImmutableList.copyOf(connectors) + + highestGround = highestGround.coerceAtLeast(highestLiquid) + + if (highestGround == -1) + highestGround = lowestAir - 1 + + if (lowestAir == reader.size.y) + lowestAir = highestGround + 1 + + if (cc == 0) { + cx = reader.size.x / 2 + cy = reader.size.y / 2 + } else { + cx /= cc + cy /= cc + } + + if (highestGround != -1) { + cy = highestGround + 1 + } + + anchor = Vector2i(cx, cy) + } + + fun doesNotConnectTo(other: DungeonPart): Boolean { + return rules.any { it.doesNotConnectToPart(other.name) } || other.rules.any { it.doesNotConnectToPart(name) } + } + + fun checkPartCombinationsAllowed(counter: Map): Boolean { + return rules.all { it.checkPartCombinationsAllowed(counter) } + } + + fun allowsPlacement(spawnCount: Int): Boolean { + return rules.all { it.allowSpawnCount(spawnCount) } + } + + suspend fun canPlace(x: Int, y: Int, world: DungeonWorld): Boolean { + if (overrideAllowAlways || reader.size.x == 0 || reader.size.y == 0) + return true + + return world.waitForRegionAndJoin(Vector2i(x, y), reader.size) { + reader.walkTiles { tx, ty, tile -> + if (!tile.canPlace(x + tx, y + ty, world)) { + return@walkTiles KOptional(false) + } + + return@walkTiles KOptional() + } + }.orElse(true) + } + + fun canPlace(x: Int, y: Int, world: ServerWorld): Boolean { + if (overrideAllowAlways || reader.size.x == 0 || reader.size.y == 0) + return true + + return reader.walkTiles { tx, ty, tile -> + if (!tile.canPlace(x + tx, y + ty, world)) { + return@walkTiles KOptional(false) + } + + return@walkTiles KOptional() + }.orElse(true) + } + + suspend fun place(pos: Vector2i, world: DungeonWorld, dungeonID: Int) { + val (x, y) = pos + val markForRemoval = HashSet() + + reader.iterateTiles { tx, ty, tile -> + if (tile.hasBrushes) { + markForRemoval.add(world.geometry.wrap(Vector2i(x + tx, y + ty))) + } + } + + // Mark entities for removal, and remove them when dungeon is actually placed in world + world.waitForRegionAndJoin(Vector2i(x, y), reader.size) { + val entities = world.parent.entityIndex.query(AABBi(Vector2i(x, y), Vector2i(x, y) + reader.size)) + + for (entity in entities) { + if (entity !is TileEntity) + continue + + var markForDeath = false + + if (entity.occupySpaces.any { it in markForRemoval }) { + markForDeath = true + } else if (clearAnchoredObjects) { + if (entity.roots.any { it in markForRemoval }) { + markForDeath = true + } else if (entity is WorldObject && entity.anchorPositions.any { it in markForRemoval }) { + markForDeath = true + } + } + + if (markForDeath) { + // mark all spaces for removal so "is tile empty" placement rules properly account + // for marked-for-death tiles occupied by existing entities + entity.occupySpaces.forEach { world.clearTileEntityAt(it) } + world.clearTileEntity(entity) + } + } + } + + if (markDungeonId) { + world.setDungeonID(dungeonID) + } else { + world.setDungeonID() + } + + for (phase in DungeonBrush.Phase.entries) { + reader.iterateTiles { tx, ty, tile -> + if (tile.usesPlaces || !world.hasPlacement(x + tx, y + ty)) { + tile.place(x + tx, y + ty, phase, world) + } + } + } + + reader.iterateTiles { tx, ty, tile -> + if (tile.usesPlaces) { + world.touchPlacement(x + tx, y + ty) + } + + if (tile.hasBrushes) { + world.touch(x + tx, y + ty) + } + } + + world.finishPart() + } + + fun tileUsesPlaces(x: Int, y: Int): Boolean { + return reader.walkTilesAt(x, y) { x, y, tile -> if (tile.usesPlaces) KOptional(true) else KOptional() }.orElse(false) + } + + fun collidesWithPlaces(x: Int, y: Int, world: DungeonWorld): Boolean { + return reader.walkTiles { tx, ty, tile -> if (tile.collidesWithPlaces && world.hasPlacement(tx + x, ty + y)) KOptional(true) else KOptional() }.orElse(false) + } + + fun pickByNeighbours(x: Int, y: Int): DungeonDirection { + // if on a border use that, corners use the left/right direction + if (x == 0) + return DungeonDirection.LEFT + else if (x == reader.size.x - 1) + return DungeonDirection.RIGHT + else if (y == 0) + return DungeonDirection.DOWN + else if (y == reader.size.y - 1) + return DungeonDirection.UP + + // scans around the connector, the direction where it finds a solid is where + // it assume the connection comes from + if (tileUsesPlaces(x + 1, y) && !tileUsesPlaces(x - 1, y)) + return DungeonDirection.LEFT + + if (tileUsesPlaces(x - 1, y) && !tileUsesPlaces(x + 1, y)) + return DungeonDirection.RIGHT + + if (tileUsesPlaces(x, y + 1) && !tileUsesPlaces(x, y - 1)) + return DungeonDirection.DOWN + + if (tileUsesPlaces(x, y - 1) && !tileUsesPlaces(x, y + 1)) + return DungeonDirection.UP + + return DungeonDirection.UNKNOWN + } + + fun pickByEdge(x: Int, y: Int): DungeonDirection { + val dxb = reader.size.x - x + val dyb = reader.size.y - y + + return when (x.coerceAtMost(dxb).coerceAtMost(y).coerceAtMost(dyb)) { + x -> DungeonDirection.LEFT + dxb -> DungeonDirection.RIGHT + y -> DungeonDirection.DOWN + dyb -> DungeonDirection.UP + else -> throw RuntimeException() + } + } + + class Adapter(gson: Gson) : TypeAdapter() { + private val data = gson.getAdapter(JsonData::class.java) + + override fun write(out: JsonWriter, value: DungeonPart) { + TODO("Not yet implemented") + } + + override fun read(`in`: JsonReader): DungeonPart { + return DungeonPart(data.read(`in`)) + } + } + + companion object { + private val LOGGER = LogManager.getLogger() + } +} diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonRule.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonRule.kt new file mode 100644 index 00000000..9812f2c8 --- /dev/null +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonRule.kt @@ -0,0 +1,357 @@ +package ru.dbotthepony.kstarbound.defs.dungeon + +import com.google.common.collect.ImmutableSet +import com.google.gson.Gson +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import com.google.gson.JsonSyntaxException +import com.google.gson.TypeAdapter +import com.google.gson.annotations.JsonAdapter +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonWriter +import org.apache.logging.log4j.LogManager +import ru.dbotthepony.kommons.gson.contains +import ru.dbotthepony.kstarbound.defs.tile.isEmptyLiquid +import ru.dbotthepony.kstarbound.defs.tile.isEmptyTile +import ru.dbotthepony.kstarbound.defs.tile.isNotEmptyLiquid +import ru.dbotthepony.kstarbound.defs.tile.isNotEmptyTile +import ru.dbotthepony.kstarbound.defs.tile.isObjectTile +import ru.dbotthepony.kstarbound.json.builder.IStringSerializable +import ru.dbotthepony.kstarbound.json.stream +import ru.dbotthepony.kstarbound.server.world.ServerWorld + +@JsonAdapter(DungeonRule.Adapter::class) +abstract class DungeonRule { + enum class Type(override val jsonName: String, val factory: (JsonArray) -> DungeonRule) : IStringSerializable { + NOOP("", { Noop }) { + override fun readTiled(json: JsonObject, flipX: Boolean, flipY: Boolean): DungeonRule? { + return null + } + }, + + MUST_CONTAIN_LIQUID("worldGenMustContainLiquid", { MustContainLiquid }) { + override fun readTiled(json: JsonObject, flipX: Boolean, flipY: Boolean): DungeonRule? { + if ("worldGenMustContainLiquid" in json) + return MustContainLiquid + + return null + } + }, + + MUST_NOT_CONTAIN_LIQUID("worldGenMustNotContainLiquid", { MustNotContainLiquid }) { + override fun readTiled(json: JsonObject, flipX: Boolean, flipY: Boolean): DungeonRule? { + if ("worldGenMustNotContainLiquid" in json) + return MustNotContainLiquid + + return null + } + }, + + HAVE_SOLID_FOREGROUND("worldGenMustContainSolidForeground", { HaveSolidForeground }) { + override fun readTiled(json: JsonObject, flipX: Boolean, flipY: Boolean): DungeonRule? { + if (json["layer"].asString == "front" && "worldGenMustContainSolid" in json) + return HaveSolidForeground + + return null + } + }, + + HAVE_EMPTY_FOREGROUND("worldGenMustContainAirForeground", { HaveEmptyForeground }) { + override fun readTiled(json: JsonObject, flipX: Boolean, flipY: Boolean): DungeonRule? { + if (json["layer"].asString == "front" && "worldGenMustContainAir" in json) + return HaveEmptyForeground + + return null + } + }, + + HAVE_SOLID_BACKGROUND("worldGenMustContainSolidBackground", { HaveSolidBackground }) { + override fun readTiled(json: JsonObject, flipX: Boolean, flipY: Boolean): DungeonRule? { + if (json["layer"].asString == "back" && "worldGenMustContainSolid" in json) + return HaveSolidBackground + + return null + } + }, + + HAVE_EMPTY_BACKGROUND("worldGenMustContainAirBackground", { HaveEmptyBackground }) { + override fun readTiled(json: JsonObject, flipX: Boolean, flipY: Boolean): DungeonRule? { + if (json["layer"].asString == "back" && "worldGenMustContainAir" in json) + return HaveEmptyBackground + + return null + } + }, + + ALLOW_OVERDRAWING("allowOverdrawing", { AllowOverdrawing }) { + override fun readTiled(json: JsonObject, flipX: Boolean, flipY: Boolean): DungeonRule? { + if ("allowOverdrawing" in json) + return AllowOverdrawing + + return null + } + }, + + IGNORE_PART_MAXIMUM("ignorePartMaximumRule", { IgnorePartMaximum }) { + override fun readTiled(json: JsonObject, flipX: Boolean, flipY: Boolean): DungeonRule? { + return null + } + }, + + MAX_SPAWN_COUNT("maxSpawnCount", ::MaxSpawnCount) { + override fun readTiled(json: JsonObject, flipX: Boolean, flipY: Boolean): DungeonRule? { + return null + } + }, + + DO_NOT_CONNECT_TO_PART("doNotConnectToPart", ::DoNotConnectToPart) { + override fun readTiled(json: JsonObject, flipX: Boolean, flipY: Boolean): DungeonRule? { + return null + } + }, + + DO_NOT_COMBINE_WITH("doNotCombineWith", ::DoNotCombineWith) { + override fun readTiled(json: JsonObject, flipX: Boolean, flipY: Boolean): DungeonRule? { + return null + } + }; + + abstract fun readTiled(json: JsonObject, flipX: Boolean = false, flipY: Boolean = false): DungeonRule? + } + + open val requiresSolid: Boolean + get() = false + + open val requiresLiquid: Boolean + get() = false + + open val requiresOpen: Boolean + get() = false + + open val allowOverdrawing: Boolean + get() = false + + open val ignorePartMaximum: Boolean + get() = false + + open fun doesNotConnectToPart(name: String): Boolean { + return false + } + + open fun checkTileCanPlace(x: Int, y: Int, world: DungeonWorld): Boolean { + return true + } + + open fun checkTileCanPlace(x: Int, y: Int, world: ServerWorld): Boolean { + return true + } + + open fun checkPartCombinationsAllowed(placements: Map): Boolean { + return true + } + + open fun allowSpawnCount(currentCount: Int): Boolean { + return true + } + + object Noop : DungeonRule() + + object MustContainLiquid : DungeonRule() { + override val requiresLiquid: Boolean + get() = true + + override fun checkTileCanPlace(x: Int, y: Int, world: DungeonWorld): Boolean { + val cell = world.parent.template.cellInfo(x, y) + return cell.oceanLiquid.isNotEmptyLiquid && cell.oceanLiquidLevel > y + } + + override fun toString(): String { + return "Must contain liquid" + } + } + + object MustNotContainLiquid : DungeonRule() { + override fun checkTileCanPlace(x: Int, y: Int, world: DungeonWorld): Boolean { + val cell = world.parent.template.cellInfo(x, y) + return cell.oceanLiquid.isEmptyLiquid || cell.oceanLiquidLevel <= y + } + + override fun toString(): String { + return "Must not contain liquid" + } + } + + object HaveSolidForeground : DungeonRule() { + override val requiresSolid: Boolean + get() = true + + override fun checkTileCanPlace(x: Int, y: Int, world: DungeonWorld): Boolean { + if (world.markSurfaceLevel != null) + return y < world.markSurfaceLevel + + val cell = world.parent.chunkMap.getCell(x, y) + + if (cell.foreground.material.isObjectTile && world.isClearingTileEntityAt(x, y)) + return false + + return cell.foreground.material.isNotEmptyTile && !world.isClearingTileEntityAt(x, y) + } + + override fun checkTileCanPlace(x: Int, y: Int, world: ServerWorld): Boolean { + val cell = world.chunkMap.getCell(x, y) + return cell.foreground.material.isNotEmptyTile + } + + override fun toString(): String { + return "Solid foreground" + } + } + + object HaveEmptyForeground : DungeonRule() { + override val requiresOpen: Boolean + get() = true + + override fun checkTileCanPlace(x: Int, y: Int, world: DungeonWorld): Boolean { + if (world.markSurfaceLevel != null) + return y >= world.markSurfaceLevel + + val cell = world.parent.chunkMap.getCell(x, y) + return cell.foreground.material.isEmptyTile || cell.foreground.material.isObjectTile && world.isClearingTileEntityAt(x, y) + } + + override fun checkTileCanPlace(x: Int, y: Int, world: ServerWorld): Boolean { + val cell = world.chunkMap.getCell(x, y) + return cell.foreground.material.isEmptyTile + } + + override fun toString(): String { + return "Empty foreground" + } + } + + object HaveSolidBackground : DungeonRule() { + override val requiresSolid: Boolean + get() = true + + override fun checkTileCanPlace(x: Int, y: Int, world: DungeonWorld): Boolean { + if (world.markSurfaceLevel != null) + return y < world.markSurfaceLevel + + val cell = world.parent.chunkMap.getCell(x, y) + + if (cell.background.material.isObjectTile && world.isClearingTileEntityAt(x, y)) + return false + + return cell.background.material.isNotEmptyTile + } + + override fun checkTileCanPlace(x: Int, y: Int, world: ServerWorld): Boolean { + val cell = world.chunkMap.getCell(x, y) + return cell.background.material.isNotEmptyTile + } + + override fun toString(): String { + return "Solid background" + } + } + + object HaveEmptyBackground : DungeonRule() { + override val requiresOpen: Boolean + get() = true + + override fun checkTileCanPlace(x: Int, y: Int, world: DungeonWorld): Boolean { + if (world.markSurfaceLevel != null) + return y >= world.markSurfaceLevel + + val cell = world.parent.chunkMap.getCell(x, y) + return cell.background.material.isEmptyTile || cell.background.material.isObjectTile && world.isClearingTileEntityAt(x, y) + } + + override fun checkTileCanPlace(x: Int, y: Int, world: ServerWorld): Boolean { + val cell = world.chunkMap.getCell(x, y) + return cell.background.material.isEmptyTile + } + + override fun toString(): String { + return "Empty background" + } + } + + object AllowOverdrawing : DungeonRule() { + override val allowOverdrawing: Boolean + get() = true + + override fun toString(): String { + return "Allow overdrawing" + } + } + + object IgnorePartMaximum : DungeonRule() { + override val ignorePartMaximum: Boolean + get() = true + + override fun toString(): String { + return "Ignore part maximum" + } + } + + data class MaxSpawnCount(val count: Int) : DungeonRule() { + constructor(json: JsonArray) : this(json[1].asJsonArray[0].asInt) + + override fun allowSpawnCount(currentCount: Int): Boolean { + return currentCount < count + } + + override fun toString(): String { + return "Max spawn count = $count" + } + } + + data class DoNotConnectToPart(val parts: ImmutableSet) : DungeonRule() { + constructor(json: JsonArray) : this(json[1].asJsonArray.stream().map { it.asString }.collect(ImmutableSet.toImmutableSet())) + + override fun doesNotConnectToPart(name: String): Boolean { + return name in parts + } + + override fun toString(): String { + return "Do not connect to $parts" + } + } + + data class DoNotCombineWith(val parts: ImmutableSet) : DungeonRule() { + constructor(json: JsonArray) : this(json[1].asJsonArray.stream().map { it.asString }.collect(ImmutableSet.toImmutableSet())) + + override fun checkPartCombinationsAllowed(placements: Map): Boolean { + return placements.keys.none { it in parts } + } + + override fun toString(): String { + return "Do not combine with $parts" + } + } + + class Adapter(gson: Gson) : TypeAdapter() { + private val arrays = gson.getAdapter(JsonArray::class.java) + private val types = gson.getAdapter(Type::class.java) + + override fun write(out: JsonWriter, value: DungeonRule) { + throw UnsupportedOperationException("Dungeon Rules can't be serialized at this moment") + } + + override fun read(`in`: JsonReader): DungeonRule { + val read = arrays.read(`in`) + + if (read.isEmpty) { + throw JsonSyntaxException("Empty rule") + } + + val type = types.fromJsonTree(read[0]) + return type.factory(read) + } + } + + companion object { + private val LOGGER = LogManager.getLogger() + } +} diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonTile.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonTile.kt new file mode 100644 index 00000000..093ac44d --- /dev/null +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonTile.kt @@ -0,0 +1,150 @@ +package ru.dbotthepony.kstarbound.defs.dungeon + +import com.github.benmanes.caffeine.cache.Interner +import com.google.common.collect.ImmutableList +import com.google.gson.Gson +import com.google.gson.JsonObject +import com.google.gson.TypeAdapter +import com.google.gson.annotations.JsonAdapter +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonWriter +import ru.dbotthepony.kommons.gson.contains +import ru.dbotthepony.kommons.util.Either +import ru.dbotthepony.kommons.vector.Vector4i +import ru.dbotthepony.kstarbound.Starbound +import ru.dbotthepony.kstarbound.defs.tile.NO_DUNGEON_ID +import ru.dbotthepony.kstarbound.json.builder.JsonFactory +import ru.dbotthepony.kstarbound.json.getAdapter +import ru.dbotthepony.kstarbound.server.world.ServerWorld + +@JsonAdapter(DungeonTile.Adapter::class) +data class DungeonTile( + val brushes: ImmutableList, + val rules: ImmutableList, + val index: Int, + val connector: JigsawConnector?, +) { + data class JigsawConnector( + val index: String, + val forward: Boolean, + val direction: DungeonDirection + ) + + @JsonFactory + data class BasicData( + val brush: ImmutableList = ImmutableList.of(), + val rules: ImmutableList = ImmutableList.of(), + val direction: DungeonDirection = DungeonDirection.UNKNOWN, + + // original engine supports specifying pixels as strings + // but I don't see it being used anywhere in original game assets + // /shrug lets support anyway + val value: Either, + val connector: Boolean = false, + val connectForwardOnly: Boolean = false, + ) + + // whenever this tile CAN BE overdrawn by other tile + val allowOverdrawing: Boolean = rules.any { it.allowOverdrawing } + + // "modifyPlaces" in original code + val hasBrushes: Boolean get() = brushes.isNotEmpty() + val hasRules: Boolean get() = rules.isNotEmpty() + val usesPlaces: Boolean get() = hasBrushes && !allowOverdrawing + val collidesWithPlaces: Boolean get() = usesPlaces + + val requiresOpen: Boolean get() = rules.any { it.requiresOpen } + val requiresLiquid: Boolean get() = rules.any { it.requiresLiquid } + val requiresSolid: Boolean get() = rules.any { it.requiresSolid } + + // empty tiles can be cut off from canPlace and place loops + // (since they don't do anything other than padding region coordinates) + val isEmpty: Boolean get() = !hasBrushes && !hasRules + + // sucks that we have to join world thread when doing this check + // this hinders parallelized computations by noticeable margin, + // and also makes dungeon placement in world completely serial + // (one dungeon can be in process of generating at time) + // TODO: find a way around this, to make dungeons less restricted by this + // but thats also not a priority, since this check happens quite quickly + // to have any noticeable impact on world's performance + fun canPlace(x: Int, y: Int, world: DungeonWorld): Boolean { + val cell = world.parent.chunkMap.getCell(x, y) + + if (cell.dungeonId != NO_DUNGEON_ID) + return false + + if (!world.geometry.x.isValidCellIndex(x) || !world.geometry.y.isValidCellIndex(y)) + return false + + return rules.none { !it.checkTileCanPlace(x, y, world) } + } + + fun canPlace(x: Int, y: Int, world: ServerWorld): Boolean { + val cell = world.chunkMap.getCell(x, y) + + if (cell.dungeonId != NO_DUNGEON_ID) + return false + + if (!world.geometry.x.isValidCellIndex(x) || !world.geometry.y.isValidCellIndex(y)) + return false + + return rules.none { !it.checkTileCanPlace(x, y, world) } + } + + fun place(x: Int, y: Int, phase: DungeonBrush.Phase, world: DungeonWorld) { + brushes.forEach { it.execute(x, y, phase, world) } + } + + // weird custom parsing rules but ok + class Adapter(gson: Gson) : TypeAdapter() { + private val objects = gson.getAdapter(JsonObject::class.java) + private val data = gson.getAdapter(BasicData::class.java) + private val values = gson.getAdapter>() + + private fun parseIndex(value: String): Int { + val split = value.split(',') + + require(split.size == 4) { "Invalid color string: $value" } + var index = 0 + + for (v in split.map { it.toInt() }.asReversed()) + index = index.shl(8) or v.and(0xFF) + + return index + } + + override fun write(out: JsonWriter, value: DungeonTile) { + TODO("Not yet implemented") + } + + override fun read(`in`: JsonReader): DungeonTile { + val read = objects.read(`in`) + val (brushes, rules, direction, rawIndex, connector, connectForwardOnly) = data.fromJsonTree(read) + var connectorIndex = rawIndex + + if ("connector-value" in read) { + connectorIndex = values.fromJsonTree(read["connector-value"]) + } + + val index = rawIndex.map({ it.w.and(0xFF).shl(24) or it.z.and(0xFF).shl(16) or it.y.and(0xFF).shl(8) or it.x.and(0xFF) }, { parseIndex(it) }) + + if (connector) { + return INTERNER.intern(DungeonTile(brushes, rules, index, JigsawConnector( + index = connectorIndex.map({ it.w.and(0xFF).shl(24) or it.z.and(0xFF).shl(16) or it.y.and(0xFF).shl(8) or it.x.and(0xFF) }, { parseIndex(it) }).toString(), + forward = connectForwardOnly, + direction = direction + ))) + } else { + return INTERNER.intern(DungeonTile(brushes, rules, index, null)) + } + } + } + + companion object { + val INTERNER: Interner = if (Starbound.DEDUP_CELL_STATES) Starbound.interner(5) else Interner { it } + val EMPTY: DungeonTile = INTERNER.intern(DungeonTile(ImmutableList.of(), ImmutableList.of(), 0, null)) + val CLEAR: DungeonTile = INTERNER.intern(DungeonTile(ImmutableList.of(DungeonBrush.Clear), ImmutableList.of(), 0, null)) + } +} + diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonWorld.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonWorld.kt new file mode 100644 index 00000000..8a937be8 --- /dev/null +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonWorld.kt @@ -0,0 +1,485 @@ +package ru.dbotthepony.kstarbound.defs.dungeon + +import com.google.gson.JsonObject +import it.unimi.dsi.fastutil.longs.Long2ObjectFunction +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap +import kotlinx.coroutines.future.await +import ru.dbotthepony.kommons.util.AABBi +import ru.dbotthepony.kommons.vector.Vector2d +import ru.dbotthepony.kommons.vector.Vector2i +import ru.dbotthepony.kstarbound.Registry +import ru.dbotthepony.kstarbound.defs.item.ItemDescriptor +import ru.dbotthepony.kstarbound.defs.`object`.ObjectDefinition +import ru.dbotthepony.kstarbound.defs.tile.BuiltinMetaMaterials +import ru.dbotthepony.kstarbound.defs.tile.LiquidDefinition +import ru.dbotthepony.kstarbound.defs.tile.NO_DUNGEON_ID +import ru.dbotthepony.kstarbound.defs.tile.TileDefinition +import ru.dbotthepony.kstarbound.defs.tile.TileModifierDefinition +import ru.dbotthepony.kstarbound.defs.tile.isNotEmptyTile +import ru.dbotthepony.kstarbound.server.world.ServerChunk +import ru.dbotthepony.kstarbound.server.world.ServerWorld +import ru.dbotthepony.kstarbound.world.ChunkPos +import ru.dbotthepony.kstarbound.world.Direction +import ru.dbotthepony.kstarbound.world.api.AbstractLiquidState +import ru.dbotthepony.kstarbound.world.api.MutableTileState +import ru.dbotthepony.kstarbound.world.api.TileColor +import ru.dbotthepony.kstarbound.world.entities.tile.TileEntity +import ru.dbotthepony.kstarbound.world.physics.Poly +import java.util.Collections +import java.util.function.Consumer +import java.util.random.RandomGenerator + +// Facade world for generating dungeons, so generation can be performed without affecting world state, +// and if error occurs, won't require world's rollback, as well allowing dungeon to be generated mostly +// off world's thread. +class DungeonWorld(val parent: ServerWorld, val random: RandomGenerator, val markSurfaceLevel: Int? = null, val terrainSurfaceSpaceExtends: Int = 0) { + val geometry = parent.geometry + + data class Material( + val material: Registry.Entry = BuiltinMetaMaterials.EMPTY, + val hueShift: Float = 0f, + val color: TileColor = TileColor.DEFAULT, + ) { + fun apply(to: MutableTileState) { + to.material = material + to.hueShift = hueShift + to.color = color + } + } + + data class Modifier( + val modifier: Registry.Entry = BuiltinMetaMaterials.EMPTY_MOD, + val hueShift: Float = 0f + ) { + fun apply(to: MutableTileState) { + to.modifier = modifier + to.modifierHueShift = hueShift + } + } + + data class PlacedObject( + val prototype: Registry.Entry, + val direction: Direction = Direction.LEFT, + val parameters: JsonObject = JsonObject() + ) + + private val liquid = HashMap() + private val foregroundMaterial = HashMap() + private val foregroundModifier = HashMap() + private val backgroundMaterial = HashMap() + private val backgroundModifier = HashMap() + + // for entity spaces which should be considered empty if they + // are occupied by tile entity + private val clearTileEntitiesAt = HashSet() + + // entities themselves to be removed + private val tileEntitiesToRemove = HashSet() + + fun clearTileEntityAt(x: Int, y: Int) { + clearTileEntitiesAt.add(geometry.wrap(Vector2i(x, y))) + } + + fun clearTileEntityAt(position: Vector2i) { + clearTileEntitiesAt.add(geometry.wrap(position)) + } + + fun isClearingTileEntityAt(x: Int, y: Int): Boolean { + return geometry.wrap(Vector2i(x, y)) in clearTileEntitiesAt + } + + fun clearTileEntity(entity: TileEntity) { + tileEntitiesToRemove.add(entity) + } + + private val touchedTiles = HashSet() + private val protectTile = HashSet() + + private val boundingBoxes = ArrayList() + + private var currentBoundingBox: AABBi? = null + + fun touched(): Set = Collections.unmodifiableSet(touchedTiles) + + fun touch(x: Int, y: Int) { + val wrapped = geometry.wrap(Vector2i(x, y)) + touchedTiles.add(wrapped) + + if (currentBoundingBox == null) { + currentBoundingBox = AABBi(wrapped, wrapped) + } else { + currentBoundingBox = currentBoundingBox!!.expand(wrapped) + } + + if (dungeonID != -1 && wrapped !in dungeonIDs) { + dungeonIDs[wrapped] = dungeonID + } + } + + fun isTouched(x: Int, y: Int): Boolean { + return touchedTiles.contains(geometry.wrap(Vector2i(x, y))) + } + + fun touchPlacement(x: Int, y: Int) { + protectTile.add(geometry.wrap(Vector2i(x, y))) + } + + fun hasPlacement(x: Int, y: Int): Boolean { + return protectTile.contains(geometry.wrap(Vector2i(x, y))) + } + + private val biomeItems = HashSet() + private val biomeTrees = HashSet() + private val itemDrops = HashMap>() + private val randomizedItemDrops = HashMap>() + + private val dungeonIDs = HashMap() + private var dungeonID = -1 + + private val pendingLiquids = HashMap() + + private val openLocalWires = HashMap>() + private val globalWires = HashMap>() + private val localWires = ArrayList>() + + private val placedObjects = HashMap() + + var playerStart: Vector2d? = null + + fun finishPart() { + localWires.addAll(openLocalWires.values) + openLocalWires.clear() + + if (currentBoundingBox != null) { + boundingBoxes.add(currentBoundingBox!!) + currentBoundingBox = null + } + } + + fun placeObject(x: Int, y: Int, prototype: Registry.Entry, direction: Direction = Direction.LEFT, parameters: JsonObject = JsonObject()) { + placedObjects[geometry.wrap(Vector2i(x, y))] = PlacedObject(prototype, direction, parameters) + } + + fun placeWiring(x: Int, y: Int, group: String, partLocal: Boolean) { + val table = if (partLocal) openLocalWires else globalWires + table.computeIfAbsent(group) { HashSet() }.add(geometry.wrap(Vector2i(x, y))) + } + + fun requestLiquid(x: Int, y: Int, liquid: AbstractLiquidState) { + pendingLiquids[geometry.wrap(Vector2i(x, y))] = liquid + } + + fun setDungeonID(x: Int, y: Int, id: Int) { + dungeonIDs[geometry.wrap(Vector2i(x, y))] = id + } + + fun setDungeonID(x: Int, y: Int) { + dungeonIDs.remove(geometry.wrap(Vector2i(x, y))) + } + + fun setDungeonID(id: Int) { + require(id in 0 .. NO_DUNGEON_ID) { "Dungeon ID out of range: $id" } + dungeonID = id + } + + fun setDungeonID() { + dungeonID = -1 + } + + fun placeBiomeTree(x: Int, y: Int) { + biomeTrees.add(geometry.wrap(Vector2i(x, y))) + } + + fun placeBiomeItems(x: Int, y: Int) { + biomeItems.add(geometry.wrap(Vector2i(x, y))) + } + + fun dropItem(x: Int, y: Int, item: ItemDescriptor) { + itemDrops.computeIfAbsent(geometry.wrap(Vector2i(x, y))) { ArrayList() }.add(item) + } + + fun dropRandomizedItem(x: Int, y: Int, item: ItemDescriptor) { + randomizedItemDrops.computeIfAbsent(geometry.wrap(Vector2i(x, y))) { ArrayList() }.add(item) + } + + fun setLiquid(x: Int, y: Int, liquid: AbstractLiquidState) { + this.liquid[geometry.wrap(Vector2i(x, y))] = liquid + } + + fun getLiquid(x: Int, y: Int): AbstractLiquidState { + return this.liquid[geometry.wrap(Vector2i(x, y))] ?: AbstractLiquidState.EMPTY + } + + fun setForeground(x: Int, y: Int, tile: Material) { + this.foregroundMaterial[geometry.wrap(Vector2i(x, y))] = tile + } + + fun setForeground(x: Int, y: Int, tile: Modifier) { + this.foregroundModifier[geometry.wrap(Vector2i(x, y))] = tile + } + + fun setForeground(x: Int, y: Int, material: Registry.Entry, hueShift: Float = 0f, color: TileColor = TileColor.DEFAULT) { + setForeground(x, y, Material(material, hueShift, color)) + } + + fun setForeground(x: Int, y: Int, modifier: Registry.Entry, hueShift: Float = 0f) { + setForeground(x, y, Modifier(modifier, hueShift)) + } + + fun setBackground(x: Int, y: Int, tile: Material) { + this.backgroundMaterial[geometry.wrap(Vector2i(x, y))] = tile + } + + fun setBackground(x: Int, y: Int, tile: Modifier) { + this.backgroundModifier[geometry.wrap(Vector2i(x, y))] = tile + } + + fun setBackground(x: Int, y: Int, material: Registry.Entry, hueShift: Float = 0f, color: TileColor = TileColor.DEFAULT) { + setBackground(x, y, Material(material, hueShift, color)) + } + + fun setBackground(x: Int, y: Int, modifier: Registry.Entry, hueShift: Float = 0f) { + setBackground(x, y, Modifier(modifier, hueShift)) + } + + fun needsForegroundBiomeMod(x: Int, y: Int): Boolean { + val pos = geometry.wrap(Vector2i(x, y)) + val material = foregroundMaterial[pos] ?: return false + + if (material.material !in BuiltinMetaMaterials.BIOME_META_MATERIALS) + return false + + val above = geometry.wrap(Vector2i(x, y + 1)) + return foregroundMaterial[above]?.material?.isNotEmptyTile == false + } + + fun needsBackgroundBiomeMod(x: Int, y: Int): Boolean { + val pos = geometry.wrap(Vector2i(x, y)) + val material = backgroundMaterial[pos] ?: return false + + if (material.material !in BuiltinMetaMaterials.BIOME_META_MATERIALS) + return false + + val above = geometry.wrap(Vector2i(x, y + 1)) + return backgroundMaterial[above]?.material?.isNotEmptyTile == false + } + + suspend inline fun waitForRegion(region: AABBi, block: () -> T): T { + val tickets = ArrayList() + + return try { + tickets.addAll(parent.permanentChunkTicket(region, ServerChunk.State.TERRAIN)) + tickets.forEach { it.chunk.await() } + block() + } finally { + tickets.forEach { it.cancel() } + } + } + + suspend inline fun waitForRegionAndJoin(region: AABBi, crossinline block: () -> T): T { + return waitForRegion(region) { + parent.eventLoop.supplyAsync { block() }.await() + } + } + + suspend inline fun waitForRegion(position: Vector2i, size: Vector2i, block: () -> T): T { + return waitForRegion(AABBi(position, position + size), block) + } + + suspend inline fun waitForRegionAndJoin(position: Vector2i, size: Vector2i, crossinline block: () -> T): T { + return waitForRegionAndJoin(AABBi(position, position + size), block) + } + + fun pressurizeLiquids() { + // For each liquid type, find each contiguous region of liquid, then + // pressurize that region based on the highest position in the region + + val unpressurizedLiquids = HashMap, HashSet>() + + for ((pos, liquid) in pendingLiquids) { + unpressurizedLiquids.computeIfAbsent(liquid.state) { HashSet() }.add(pos) + } + + for (unpressurized in unpressurizedLiquids.values) { + while (unpressurized.isNotEmpty()) { + // Start with the first unpressurized block as the open set. + val firstBlock = unpressurized.first() + unpressurized.remove(firstBlock) + + var openSet = ArrayList() + val contiguousRegion = HashSet() + + openSet.add(firstBlock) + contiguousRegion.add(firstBlock) + + // For each element in the previous open set, add all connected blocks + // in + // the unpressurized set to the new open set and to the total contiguous + // region, taking them from the unpressurized set. + while (openSet.isNotEmpty()) { + val oldOpenSet = openSet + openSet = ArrayList() + + for (node in oldOpenSet) { + for (dir in offsets) { + val pos = node + dir + + if (unpressurized.remove(pos)) { + contiguousRegion.add(pos) + openSet.add(pos) + } + } + } + } + + // Once we have found no more blocks in the unpressurized set to add to + // the open set, then we have taken a contiguous region out of the + // unpressurized set. Pressurize it based on the highest point. + val highestPoint = contiguousRegion.maxOf { it.y } + contiguousRegion.forEach { + val state = pendingLiquids[it]!!.mutable() + state.pressure = 1f + highestPoint - it.y + pendingLiquids[it] = state.immutable() + } + } + } + + for ((pos, liquid) in pendingLiquids.entries) { + setLiquid(pos.x, pos.y, liquid) + } + + pendingLiquids.clear() + } + + private fun applyCellChangesAt(pos: Vector2i, chunk: ServerChunk) { + val cell = chunk.getCell(pos - chunk.pos.tile).mutable() + + backgroundMaterial[pos]?.apply(cell.background) + foregroundMaterial[pos]?.apply(cell.foreground) + backgroundModifier[pos]?.apply(cell.background) + foregroundModifier[pos]?.apply(cell.foreground) + + val dungeonId = dungeonIDs[pos] + val liquid = liquid[pos] + + if (liquid != null) { + cell.liquid.from(liquid) + } + + if (dungeonId != null) { + cell.dungeonId = dungeonId + } + + chunk.replaceBiomeBlocks(cell, parent.template.cellInfo(pos.x, pos.y)) + chunk.setCell(pos - chunk.pos.tile, cell) + } + + suspend fun commit() { + val tickets = ArrayList() + + try { + val terrainBlendingVertexes = ArrayList() + val spaceBlendingVertexes = ArrayList() + + for (box in boundingBoxes) { + // don't schedule generating terrain until we have specified custom terrain regions! (they affect terrain generation) + // tickets.addAll(parent.permanentChunkTicket(box, ServerChunk.State.TERRAIN)) + + if (markSurfaceLevel != null) { + // Mark the regions of the dungeon above the dungeon surface as needing + // space, and the regions below the surface as needing terrain + + if (box.mins.y < markSurfaceLevel) { + val mins = box.mins + val maxs = box.maxs.copy(y = box.maxs.y.coerceAtMost(markSurfaceLevel)) + + terrainBlendingVertexes.add(Vector2d(mins.x.toDouble(), mins.y.toDouble())) + terrainBlendingVertexes.add(Vector2d(maxs.x.toDouble(), mins.y.toDouble())) + terrainBlendingVertexes.add(Vector2d(maxs.x.toDouble(), maxs.y.toDouble())) + terrainBlendingVertexes.add(Vector2d(mins.x.toDouble(), maxs.y.toDouble())) + } + + if (box.maxs.y > markSurfaceLevel) { + val mins = box.mins.copy(y = box.mins.y.coerceAtLeast(markSurfaceLevel)) + val maxs = box.maxs + + spaceBlendingVertexes.add(Vector2d(mins.x.toDouble(), mins.y.toDouble())) + spaceBlendingVertexes.add(Vector2d(maxs.x.toDouble(), mins.y.toDouble())) + spaceBlendingVertexes.add(Vector2d(maxs.x.toDouble(), maxs.y.toDouble() + terrainSurfaceSpaceExtends)) + spaceBlendingVertexes.add(Vector2d(mins.x.toDouble(), maxs.y.toDouble() + terrainSurfaceSpaceExtends)) + } + } + } + + parent.eventLoop.supplyAsync { + if (terrainBlendingVertexes.isNotEmpty()) { + parent.template.addCustomTerrainRegion(Poly.quickhull(terrainBlendingVertexes)) + } + + if (spaceBlendingVertexes.isNotEmpty()) { + parent.template.addCustomSpaceRegion(Poly.quickhull(spaceBlendingVertexes)) + } + }.await() + + for (box in boundingBoxes) { + tickets.addAll(parent.permanentChunkTicket(box, ServerChunk.State.TERRAIN)) + } + + // apply tiles to world per-chunk + // this way we don't need to wait on all chunks to be loaded + // and apply changes chunks which have been loaded right away + val tilePositionsRaw = ArrayList() + + tilePositionsRaw.addAll(foregroundMaterial.keys) + tilePositionsRaw.addAll(foregroundModifier.keys) + tilePositionsRaw.addAll(backgroundMaterial.keys) + tilePositionsRaw.addAll(backgroundModifier.keys) + + tilePositionsRaw.sortWith { o1, o2 -> + val cmp = o1.x.compareTo(o2.x) + if (cmp == 0) o1.y.compareTo(o2.y) else cmp + } + + val regions = Long2ObjectOpenHashMap>() + var previous: Vector2i? = null + + for (pos in tilePositionsRaw) { + if (pos != previous) { + regions.computeIfAbsent(ChunkPos.toLong(geometry.x.chunkFromCell(pos.x), geometry.y.chunkFromCell(pos.y)), Long2ObjectFunction { ArrayList() }).add(pos) + previous = pos + } + } + + val seenTickets = HashSet() + + for (ticket in tickets.filter { seenTickets.add(it.pos) }) { + // make changes to chunk only inside world's thread once it has reached TILES state + ticket.chunk.thenAcceptAsync(Consumer { + regions.get(ticket.pos.toLong())?.forEach { applyCellChangesAt(it, ticket.chunk.get()) } + }, parent.eventLoop) + } + + // wait for all chunks to be loaded + tickets.forEach { it.chunk.await() } + + // at this point all chunks are available, and we applied changes to tiles + + // and finally, schedule chunks to be loaded into FULL state + // this way, big dungeons won't get cut off when chunks being saved + // to disk because of multiple chunks outside player tracking area + // But this might trigger cascading world generation + // (big dungeon generates another big dungeon, and another, and so on), + // tough, so need to take care! + for (box in boundingBoxes) { + // specify timer as 0 so ticket gets removed on next world tick + parent.temporaryChunkTicket(box, 0, ServerChunk.State.FULL) + } + } finally { + tickets.forEach { it.cancel() } + } + } + + companion object{ + private val offsets = listOf(Vector2i.POSITIVE_Y, Vector2i.NEGATIVE_Y, Vector2i.POSITIVE_X, Vector2i.NEGATIVE_X) + } +} diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/ImagePartReader.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/ImagePartReader.kt new file mode 100644 index 00000000..7a1efed3 --- /dev/null +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/ImagePartReader.kt @@ -0,0 +1,67 @@ +package ru.dbotthepony.kstarbound.defs.dungeon + +import com.google.common.collect.ImmutableList +import ru.dbotthepony.kommons.arrays.Object2DArray +import ru.dbotthepony.kommons.math.RGBAColor +import ru.dbotthepony.kommons.util.KOptional +import ru.dbotthepony.kommons.vector.Vector2i +import ru.dbotthepony.kstarbound.defs.image.Image + +class ImagePartReader(part: DungeonPart, val images: ImmutableList) : PartReader(part) { + override val size: Vector2i + get() = if (images.isEmpty()) Vector2i.ZERO else images.first().size + + // it is much cheaper to just read all images and store 2D array + // of references than loading / keeping images themselves around + // `Image` class doesn't actually keep pixel data around for too long, + // if it doesn't get accessed in some time it gets purged from ram + private val layers = Array(images.size) { + Object2DArray.nulls(images[it].width, images[it].height) + } as Array> + + override fun bind(def: DungeonDefinition) { + check(def.tiles.isNotEmpty) { "Image parts require 'tiles' palette to be present in .dungeon definition" } + + for ((i, image) in images.withIndex()) { + val layer = layers[i] + + for (y in 0 until image.height) { + for (x in 0 until image.width) { + val color = image[x, y] + val tile = part.dungeon.tiles[color] + + if (tile == null) { + val parse = RGBAColor.abgr(color) + throw IllegalStateException("Unknown tile on ${image.path} at $x, $y: [${parse.redInt}, ${parse.greenInt}, ${parse.blueInt}, ${parse.alphaInt}] (index $color)") + } + + layer[x, y] = tile + } + } + } + } + + override fun walkTiles(callback: TileCallback): KOptional { + for (layer in layers) { + for (y in 0 until layer.rows) { + for (x in 0 until layer.columns) { + val get = callback(x, y, layer[x, y]) + if (get.isPresent) return get + } + } + } + + return KOptional() + } + + override fun walkTilesAt(x: Int, y: Int, callback: TileCallback): KOptional { + for (layer in layers) { + if (x in 0 until layer.columns && y in 0 until layer.rows) { + val get = callback(x, y, layer[x, y]) + if (get.isPresent) return get + } + } + + return KOptional() + } +} diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/ImageTileSet.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/ImageTileSet.kt new file mode 100644 index 00000000..db417451 --- /dev/null +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/ImageTileSet.kt @@ -0,0 +1,64 @@ +package ru.dbotthepony.kstarbound.defs.dungeon + +import com.google.common.collect.ImmutableList +import com.google.gson.Gson +import com.google.gson.TypeAdapter +import com.google.gson.annotations.JsonAdapter +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonWriter +import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap +import org.apache.logging.log4j.LogManager +import ru.dbotthepony.kommons.math.RGBAColor +import ru.dbotthepony.kstarbound.json.getAdapter + +// dungeons are stored as images, and each pixel +// represents a different tile. To make sense +// of pixel's color, this class is used to lookup +// what to do. +@JsonAdapter(ImageTileSet.Adapter::class) +class ImageTileSet(list: List = listOf()) { + private val mapping = Int2ObjectOpenHashMap() + + init { + for ((i, it) in list.withIndex()) { + val replaced = mapping.put(it.index, it) + + // allow duplicates of same entry because vanilla files have them. + if (replaced != null && replaced != it) { + val color = RGBAColor.abgr(it.index) + throw IllegalArgumentException("Two tiles are trying to take same place with index ${it.index} [${color.redInt}, ${color.greenInt}, ${color.blueInt}, ${color.alphaInt}] (list index $i):\ntile 1: $replaced\ntile 2: $it") + } + } + } + + operator fun get(index: Int): DungeonTile? { + return mapping.get(index) + } + + operator fun get(red: Int, green: Int, blue: Int, alpha: Int): DungeonTile? { + return mapping.get(red.and(0xFF).shl(24) or green.and(0xFF).shl(16) or blue.and(0xFF).shl(8) or alpha.and(0xFF)) + } + + operator fun get(index: RGBAColor): DungeonTile? { + return this[index.redInt, index.greenInt, index.blueInt, index.alphaInt] + } + + val isNotEmpty: Boolean + get() = mapping.isNotEmpty() + + class Adapter(gson: Gson) : TypeAdapter() { + private val list = gson.getAdapter>() + + override fun write(out: JsonWriter, value: ImageTileSet) { + TODO("Not yet implemented") + } + + override fun read(`in`: JsonReader): ImageTileSet { + return ImageTileSet(list.read(`in`)) + } + } + + companion object { + private val LOGGER = LogManager.getLogger() + } +} diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/PartReader.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/PartReader.kt new file mode 100644 index 00000000..58da5b98 --- /dev/null +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/PartReader.kt @@ -0,0 +1,9 @@ +package ru.dbotthepony.kstarbound.defs.dungeon + +import ru.dbotthepony.kommons.vector.Vector2i + +abstract class PartReader(val part: DungeonPart) : TileMap() { + abstract val size: Vector2i + + abstract fun bind(def: DungeonDefinition) +} diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/TileMap.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/TileMap.kt new file mode 100644 index 00000000..d00750b1 --- /dev/null +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/TileMap.kt @@ -0,0 +1,24 @@ +package ru.dbotthepony.kstarbound.defs.dungeon + +import ru.dbotthepony.kommons.util.KOptional + +abstract class TileMap { + fun interface TileCallback { + operator fun invoke(x: Int, y: Int, tile: DungeonTile): KOptional + } + + fun interface TileCallback0 { + operator fun invoke(x: Int, y: Int, tile: DungeonTile) + } + + abstract fun walkTiles(callback: TileCallback): KOptional + abstract fun walkTilesAt(x: Int, y: Int, callback: TileCallback): KOptional + + fun iterateTiles(callback: TileCallback0) { + walkTiles { x, y, tile -> callback(x, y, tile); KOptional() } + } + + fun iterateTilesAt(x: Int, y: Int, callback: TileCallback0) { + walkTilesAt(x, y) { x, y, tile -> callback(x, y, tile); KOptional() } + } +} diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/TiledMap.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/TiledMap.kt new file mode 100644 index 00000000..3f556e8f --- /dev/null +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/TiledMap.kt @@ -0,0 +1,449 @@ +package ru.dbotthepony.kstarbound.defs.dungeon + +import com.google.common.collect.ImmutableList +import com.google.gson.JsonElement +import com.google.gson.JsonNull +import com.google.gson.JsonObject +import com.google.gson.JsonPrimitive +import it.unimi.dsi.fastutil.io.FastByteArrayInputStream +import ru.dbotthepony.kommons.gson.contains +import ru.dbotthepony.kommons.gson.get +import ru.dbotthepony.kommons.gson.set +import ru.dbotthepony.kommons.util.AABBi +import ru.dbotthepony.kommons.util.Either +import ru.dbotthepony.kommons.util.KOptional +import ru.dbotthepony.kommons.vector.Vector2i +import ru.dbotthepony.kstarbound.Starbound +import ru.dbotthepony.kstarbound.json.builder.JsonFactory +import ru.dbotthepony.kstarbound.json.jsonArrayOf +import ru.dbotthepony.kstarbound.json.mergeJson +import ru.dbotthepony.kstarbound.world.PIXELS_IN_STARBOUND_UNIT +import ru.dbotthepony.kstarbound.world.PIXELS_IN_STARBOUND_UNITi +import java.io.BufferedInputStream +import java.io.EOFException +import java.util.Base64 +import java.util.zip.InflaterInputStream +import kotlin.math.roundToInt + +class TiledMap(data: JsonData) : TileMap() { + @JsonFactory + data class JsonData( + val tileheight: Int, + val tilewidth: Int, + + val width: Int, + val height: Int, + + val tilesets: ImmutableList, + val layers: ImmutableList, + ) + + init { + require(data.tilewidth == 8) { "Tile width is not equal to 8 (${data.tilewidth})" } + require(data.tileheight == 8) { "Tile height is not equal to 8 (${data.tileheight})" } + + require(data.width > 0) { "Non-positive map width: ${data.width}" } + require(data.height > 0) { "Non-positive map height: ${data.height}" } + } + + val size = Vector2i(data.width, data.height) + + val tileSets = TiledTileSets(data.tilesets) + + var frontLayer: TileLayer? = null + private set + var backLayer: TileLayer? = null + private set + + val objectLayers: ImmutableList + + init { + val objectLayers = ArrayList() + + for (layer in data.layers) { + val type = layer["type"].asString + + if (type == "tilelayer") { + val read = Starbound.gson.fromJson(layer, TileLayerData::class.java) + + when (read.name.lowercase()) { + "front" -> { + if (frontLayer != null) { + throw IllegalArgumentException("Duplicate front layer") + } + + frontLayer = TileLayer(read, false) + } + + "back" -> { + if (backLayer != null) { + throw IllegalArgumentException("Duplicate back layer") + } + + backLayer = TileLayer(read, true) + } + + else -> throw UnsupportedOperationException("Unknown tile layer '${read.name}'! Must be either 'front' or 'back'") + } + } else if (type == "objectgroup") { + objectLayers.add(ObjectLayer(Starbound.gson.fromJson(layer, ObjectLayerData::class.java))) + } else { + throw UnsupportedOperationException("Unknown layer type $type!") + } + } + + this.objectLayers = ImmutableList.copyOf(objectLayers) + } + + override fun walkTiles(callback: TileCallback): KOptional { + var result = frontLayer?.walkTiles(callback) ?: KOptional() + if (result.isPresent) return result + result = backLayer?.walkTiles(callback) ?: KOptional() + if (result.isPresent) return result + + for (layer in objectLayers) { + result = layer.walkTiles(callback) + if (result.isPresent) return result + } + + return KOptional() + } + + override fun walkTilesAt(x: Int, y: Int, callback: TileCallback): KOptional { + var result = frontLayer?.walkTilesAt(x, y, callback) ?: KOptional() + if (result.isPresent) return result + result = backLayer?.walkTilesAt(x, y, callback) ?: KOptional() + if (result.isPresent) return result + + for (layer in objectLayers) { + result = layer.walkTilesAt(x, y, callback) + if (result.isPresent) return result + } + + return KOptional() + } + + @JsonFactory + data class TileLayerData( + val width: Int, + val height: Int, + val x: Int = 0, + val y: Int = 0, + val name: String, + val compression: String? = null, + val data: JsonElement + ) { + init { + require(width > 0) { "Non-positive tile layer width: $width" } + require(height > 0) { "Non-positive tile layer height: $height" } + } + } + + inner class TileLayer(data: TileLayerData, val isBackground: Boolean) : TileMap() { + val width: Int = data.width + val height: Int = data.height + val x: Int = data.x + val y: Int = data.y + + // this eats ram, need to use cache or huffman encoding with bitset + private val tileData: IntArray + + init { + if (data.compression == "zlib") { + val stream = BufferedInputStream(InflaterInputStream(FastByteArrayInputStream(Base64.getDecoder().decode(data.data.asString)))) + + tileData = IntArray(data.width * data.height) { + val a = stream.read() + val b = stream.read() + val c = stream.read() + val d = stream.read() + + if (a or b or c or d < 0) + throw EOFException("Reached end of stream before read all tiles from layer") + + (a or b.shl(8) or c.shl(16) or d.shl(24)) and FLAG_BITS.inv() + } + } else if (data.compression == null) { + require(data.data.asJsonArray.size() == data.width * data.height) { + "'data' does not contain enough tiles to fill ${data.width} x ${data.height} array" + } + + tileData = IntArray(data.width * data.height) + + for ((i, index) in data.data.asJsonArray.withIndex()) { + tileData[i] = index.asInt and FLAG_BITS.inv() + } + } else { + throw IllegalArgumentException("Unsupported compression mode: ${data.compression}") + } + } + + private fun get0(x: Int, y: Int): DungeonTile { + val actualX = x - this.x + var actualY = y - this.y + actualY = this.height - actualY - 1 + + if (isBackground) + return tileSets.getBack(tileData[actualX + actualY * width]) + else + return tileSets.getFront(tileData[actualX + actualY * width]) + } + + operator fun get(x: Int, y: Int): DungeonTile { + if (x !in this.x until this.x + width || y !in this.y until this.y + height) + return DungeonTile.EMPTY + + return get0(x, height - y + 1) + } + + override fun walkTiles(callback: TileCallback): KOptional { + for (x in this.x until this.x + width) { + for (y in this.y until this.y + height) { + val result = callback(x, y, get0(x, y)) + if (result.isPresent) return result + } + } + + return KOptional() + } + + override fun walkTilesAt(x: Int, y: Int, callback: TileCallback): KOptional { + if (x !in this.x until this.x + width || y !in this.y until this.y + height) + return KOptional() + + return callback(x, y, get0(x, y)) + } + } + + @JsonFactory + data class OProperty(val name: String, val value: JsonElement) + + @JsonFactory + data class ObjectLayerData( + val name: String, + val properties: Either> = Either.left(JsonObject()), + val objects: ImmutableList, + ) + + inner class ObjectLayer(data: ObjectLayerData) : TileMap() { + private val objects: ImmutableList + + init { + val properties = data.properties.map({ it }, { p -> JsonObject().also { for ((k, v) in p) it[k] = v } }) + val objects = ArrayList() + + for (o in data.objects) { + objects.add(MapObject(properties, o)) + } + + this.objects = ImmutableList.copyOf(objects) + } + + override fun walkTiles(callback: TileCallback): KOptional { + for (o in objects) { + val result = o.walkTiles(callback) + if (result.isPresent) return result + } + + return KOptional() + } + + override fun walkTilesAt(x: Int, y: Int, callback: TileCallback): KOptional { + for (o in objects) { + val result = o.walkTilesAt(x, y, callback) + if (result.isPresent) return result + } + + return KOptional() + } + } + + @JsonFactory + data class ObjectData( + val id: Int, + val gid: Int? = null, + val properties: Either> = Either.left(JsonObject()), + val x: Int, + val y: Int, + val width: Int = 0, + val height: Int = 0, + val rotation: Double = 0.0, + val polyline: ImmutableList? = null, + val ellipse: JsonElement = JsonNull.INSTANCE, + val polygon: JsonElement = JsonNull.INSTANCE, + ) + + @JsonFactory + data class PolylineEntry(val x: Int, val y: Int) + + enum class ObjType { + STAGEHAND, TILE, WIRING, RECTANGLE; + } + + inner class MapObject(layerProperties: JsonObject, data: ObjectData) : TileMap() { + val id = data.id + val type: ObjType + val polyline: ImmutableList + val pos: Vector2i + val size: Vector2i + val tile: DungeonTile + + init { + val properties = data.properties.map({ it }, { p -> JsonObject().also { for ((k, v) in p) it[k] = v } }) + val merged0 = mergeJson(layerProperties.deepCopy(), properties) + val isBackground = if ("layer" in merged0) merged0["layer"].asString == "back" else false + val merged = layerProperties.deepCopy() + var flipX = false + + if (data.gid != null) { + if (isBackground) { + mergeJson(merged, tileSets.getBackData(data.gid and FLAG_BITS.inv())) + } else { + mergeJson(merged, tileSets.getFrontData(data.gid and FLAG_BITS.inv())) + } + + if (data.gid and (VERTICAL_FLIP or DIAGONAL_FLIP) != 0) + throw UnsupportedOperationException("Object with GID ${data.gid and FLAG_BITS.inv()} (id $id) has either vertical flip or diagonal flip set, which is not supported") + + flipX = data.gid and HORIZONTAL_FLIP != 0 + } + + mergeJson(merged, properties) + + if (isBackground) + merged["layer"] = "back" + else + merged["layer"] = "front" + + if (flipX) + merged["flipX"] = "true" + + if ("stagehand" in merged) + type = ObjType.STAGEHAND + else if (data.gid != null) + type = ObjType.TILE + else if (data.polyline != null) + type = ObjType.WIRING + else if (!data.ellipse.isJsonNull) + throw UnsupportedOperationException("Object has ellipse shape, which is not supported") + else if (!data.polygon.isJsonNull) + throw UnsupportedOperationException("Object has polygon shape, which is not supported") + else + type = ObjType.RECTANGLE + + if (data.rotation != 0.0) + throw UnsupportedOperationException("Object has rotation: ${merged["rotation"].asDouble}, which is not supported") + + if (data.polyline != null) { + merged["wire"] = "_polylineWire$id" + merged["local"] = true + + this.polyline = ImmutableList.copyOf(data.polyline.map { Vector2i(it.x / PIXELS_IN_STARBOUND_UNITi, this@TiledMap.size.y - it.y / PIXELS_IN_STARBOUND_UNITi - 1) }) + } else { + this.polyline = ImmutableList.of() + } + + val calcPos = Vector2i(data.x, data.y) / PIXELS_IN_STARBOUND_UNITi + Vector2i(((merged["imagePositionX"]?.asDouble ?: 0.0) / PIXELS_IN_STARBOUND_UNIT).toInt(), -((merged["imagePositionY"]?.asDouble ?: 0.0) / PIXELS_IN_STARBOUND_UNIT).toInt()) + + pos = calcPos.copy(y = this@TiledMap.size.y - calcPos.y - 1) + size = Vector2i(data.width, data.height) / PIXELS_IN_STARBOUND_UNITi + + if (type == ObjType.STAGEHAND) { + val center = pos + size / 2 + // TODO: what? + val broadcastMins = pos - center + val broadcastMaxs = pos + size - center + merged["broadcastArea"] = jsonArrayOf( + JsonPrimitive(broadcastMins.x), + JsonPrimitive(broadcastMins.y), + JsonPrimitive(broadcastMaxs.x), + JsonPrimitive(broadcastMaxs.y)) + } + + tile = TiledTileSet.makeTile(merged) + } + + private fun stagehandPosition() = Vector2i(pos.x + size.x / 2, pos.y + (size.y.toDouble() / 2.0).roundToInt()) + + override fun walkTiles(callback: TileCallback): KOptional { + return when (type) { + ObjType.STAGEHAND -> { + val (x, y) = stagehandPosition() + callback(x, y, tile) + } + + ObjType.TILE -> { + // Used for placing Starbound-Tiles and Starbound-Objects + callback(pos.x, pos.y + 1, tile) // i don't know why it needs 1 tile offset though + } + + ObjType.WIRING -> { + // Used for wiring. Treat each vertex in the polyline as a tile with the + // wire brush. + for ((x, y) in polyline) { + val result = callback(this.pos.x + x, this.pos.y + y, tile) + if (result.isPresent) return result + } + + KOptional() + } + + ObjType.RECTANGLE -> { + // Used for creating custom brushes and rules + for (x in this.pos.x until this.pos.x + this.size.x) { + for (y in this.pos.y until this.pos.y + this.size.y) { + val result = callback(x, y, tile) + if (result.isPresent) return result + } + } + + KOptional() + } + } + } + + override fun walkTilesAt(x: Int, y: Int, callback: TileCallback): KOptional { + return when(type) { + ObjType.STAGEHAND -> { + val (sx, sy) = stagehandPosition() + if (sx == x && sy == y) return callback(x, y, tile) + KOptional() + } + + ObjType.TILE -> { + if (x == pos.x && y == pos.y + 1) return callback(x, y, tile) + KOptional() + } + + ObjType.WIRING -> { + for ((px, py) in polyline) { + val ox = this.pos.x + px + val oy = this.pos.y + py + + if (ox == x && oy == y) { + val result = callback(x, y, tile) + if (result.isPresent) return result + } + } + + KOptional() + } + + ObjType.RECTANGLE -> { + if (x in this.pos.x until this.pos.x + this.size.x && y in this.pos.y until this.pos.y + this.size.y) { + return callback(x, y, tile) + } + + KOptional() + } + } + } + } + + companion object { + const val HORIZONTAL_FLIP = 1 shl 31 + const val VERTICAL_FLIP = 1 shl 30 + const val DIAGONAL_FLIP = 1 shl 29 + const val FLAG_BITS = HORIZONTAL_FLIP or VERTICAL_FLIP or DIAGONAL_FLIP + } +} diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/TiledPartReader.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/TiledPartReader.kt new file mode 100644 index 00000000..582e1754 --- /dev/null +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/TiledPartReader.kt @@ -0,0 +1,53 @@ +package ru.dbotthepony.kstarbound.defs.dungeon + +import com.google.common.collect.ImmutableList +import com.google.gson.JsonSyntaxException +import ru.dbotthepony.kommons.util.KOptional +import ru.dbotthepony.kommons.vector.Vector2i +import ru.dbotthepony.kstarbound.Starbound +import ru.dbotthepony.kstarbound.util.AssetPathStack +import java.util.stream.Stream + +class TiledPartReader(part: DungeonPart, parts: Stream) : PartReader(part) { + val maps: ImmutableList = parts + .map { Starbound.locate(AssetPathStack.remap(it)) } + .peek { check(it.exists) { "Dungeon references part which does not exist: $it" } } + // well, i don't think anyone gonna patch THESE json files, i guess + // so ignore json patches and just read directly + .map { + try { + TiledMap(Starbound.gson.fromJson(it.jsonReader(), TiledMap.JsonData::class.java)) + } catch (err: Throwable) { + throw JsonSyntaxException("Exception while reading tile map $it", err) + } + } + .collect(ImmutableList.toImmutableList()) + + // also why would you ever want multiple maps specified lmao + // it already has layers and everything else you would ever need + + override val size: Vector2i + get() = maps.firstOrNull()?.size ?: Vector2i.ZERO + + override fun bind(def: DungeonDefinition) { + + } + + override fun walkTiles(callback: TileCallback): KOptional { + for (map in maps) { + val result = map.walkTiles(callback) + if (result.isPresent) return result + } + + return KOptional() + } + + override fun walkTilesAt(x: Int, y: Int, callback: TileCallback): KOptional { + for (map in maps) { + val result = map.walkTilesAt(x, y, callback) + if (result.isPresent) return result + } + + return KOptional() + } +} diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/TiledTileSet.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/TiledTileSet.kt new file mode 100644 index 00000000..7039d725 --- /dev/null +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/TiledTileSet.kt @@ -0,0 +1,101 @@ +package ru.dbotthepony.kstarbound.defs.dungeon + +import com.google.common.collect.ImmutableList +import com.google.common.collect.ImmutableMap +import com.google.gson.JsonObject +import com.google.gson.JsonSyntaxException +import ru.dbotthepony.kommons.gson.contains +import ru.dbotthepony.kommons.gson.get +import ru.dbotthepony.kommons.gson.set +import ru.dbotthepony.kommons.util.Either +import ru.dbotthepony.kstarbound.Starbound +import ru.dbotthepony.kstarbound.json.builder.JsonFactory +import ru.dbotthepony.kstarbound.json.mergeJson +import ru.dbotthepony.kstarbound.set +import java.util.concurrent.ConcurrentHashMap + +class TiledTileSet private constructor( + val front: ImmutableMap>, + val back: ImmutableMap>, +) { + @JsonFactory + data class JsonData( + val properties: JsonObject = JsonObject(), + // val tilecount: Int, // we don't care + val tileproperties: JsonObject = JsonObject(), // empty tileset? + ) + + val size: Int + get() = front.size + + val isEmpty: Boolean + get() = size == 0 + + val isNotEmpty: Boolean + get() = size != 0 + + companion object { + private val cache = ConcurrentHashMap>() + + fun makeTile(data: JsonObject): DungeonTile { + val brushes = DungeonBrushType.entries.mapNotNull { it.readTiled(data) } + val rules = DungeonRule.Type.entries.mapNotNull { it.readTiled(data) } + + var connector: DungeonTile.JigsawConnector? = null + + if ("connector" in data) { + val name = data["connector"].asString + val connectForwardOnly = "connectForwardOnly" in data + val connectDirection = DungeonDirection.entries.first { it.jsonName == data.get("connectDirection", "any") } + connector = DungeonTile.JigsawConnector(name, connectForwardOnly, connectDirection) + } + + return DungeonTile.INTERNER.intern(DungeonTile(ImmutableList.copyOf(brushes), ImmutableList.copyOf(rules), 0, connector)) + } + + private fun load0(location: String): Either { + val locate = Starbound.loadJsonAsset(location) + ?: return Either.right(NoSuchElementException("Tileset at $location does not exist")) + + try { + val data = Starbound.gson.fromJson(locate, JsonData::class.java) + val front = ImmutableMap.Builder>() + val back = ImmutableMap.Builder>() + + for ((key, value) in data.tileproperties.entrySet()) { + if (value !is JsonObject) + throw JsonSyntaxException("Tile at $key is invalid: $value") + + val index = key.toIntOrNull() ?: throw JsonSyntaxException("Invalid tile ID in $location: $key") + val merge = mergeJson(data.properties.deepCopy(), value) + val mergeBack = merge.deepCopy() + + if ("layer" !in merge) { + merge["layer"] = "front" + } + + if ("layer" !in mergeBack) { + mergeBack["layer"] = "back" + } + + if ("clear" !in merge) { + // The magic pink tile/brush has the clear property set to "false". All + // other tiles default to clear="true". + mergeBack["clear"] = "true" + } + + front[index] = makeTile(merge) to merge + back[index] = makeTile(mergeBack) to mergeBack + } + + return Either.left(TiledTileSet(front.build(), back.build())) + } catch (err: Throwable) { + return Either.right(err) + } + } + + fun load(location: String): TiledTileSet { + return cache.computeIfAbsent(location, Companion::load0).map({ it }, { throw it }) + } + } +} diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/TiledTileSets.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/TiledTileSets.kt new file mode 100644 index 00000000..d73dd2a1 --- /dev/null +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/TiledTileSets.kt @@ -0,0 +1,63 @@ +package ru.dbotthepony.kstarbound.defs.dungeon + +import com.google.gson.JsonObject +import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap +import ru.dbotthepony.kstarbound.json.builder.JsonFactory +import ru.dbotthepony.kstarbound.util.AssetPathStack + +class TiledTileSets(entries: List) { + @JsonFactory + data class Entry( + val firstgid: Int, + // not asset path because of funky names which can't be properly + // untangled by AssetPath's adapter code + val source: String, + ) + + private val front = Int2ObjectOpenHashMap>() + private val back = Int2ObjectOpenHashMap>() + + init { + for ((firstgid, source) in entries) { + // Tiled stores tileset paths relative to the map file, which can go below + // the assets root if it's referencing a tileset in another asset package. + // The solution chosen here is to ignore everything in the path up until a + // known path segment, e.g.: + // "source" : "..\/..\/..\/..\/packed\/tilesets\/packed\/materials.json" + // We ignore everything up until the 'tilesets' path segment, and the asset + // we actually load is located at: + // /tilesets/packed/materials.json + val actualSource: String + val split = source.lowercase().lastIndexOf("/tilesets/") + + if (split != -1) { + actualSource = source.substring(split) + } else { + actualSource = AssetPathStack.remap(source) + } + + val set = TiledTileSet.load(actualSource) + + for (i in 0 until set.size) { + front[firstgid + i] = set.front[i] ?: throw NullPointerException("aeiou") + back[firstgid + i] = set.back[i] ?: throw NullPointerException("aeiou") + } + } + } + + fun getFront(gid: Int): DungeonTile { + return front[gid]?.first ?: DungeonTile.EMPTY + } + + fun getBack(gid: Int): DungeonTile { + return back[gid]?.first ?: DungeonTile.CLEAR + } + + fun getFrontData(gid: Int): JsonObject { + return front[gid]?.second ?: JsonObject() + } + + fun getBackData(gid: Int): JsonObject { + return back[gid]?.second ?: JsonObject() + } +} diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/image/Image.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/image/Image.kt index 38090874..e95648ca 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/image/Image.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/image/Image.kt @@ -63,7 +63,7 @@ class Image private constructor( private val spritesInternal = LinkedHashMap() private var dataRef: WeakReference? = null - private val lock = ReentrantLock() + private val lock = Any() //private val _texture = ThreadLocal>() init { @@ -110,9 +110,7 @@ class Image private constructor( if (get != null) return CompletableFuture.completedFuture(get) - lock.lock() - - try { + synchronized(lock) { get = dataRef?.get() if (get != null) @@ -124,8 +122,6 @@ class Image private constructor( dataRef = WeakReference(f.get()) return f.copy() - } finally { - lock.unlock() } } @@ -156,7 +152,7 @@ class Image private constructor( tex.textureMinFilter = GL45.GL_NEAREST tex.textureMagFilter = GL45.GL_NEAREST - }, client.mailbox) + }, client) tex } @@ -172,10 +168,16 @@ class Image private constructor( val whole = Sprite("this", 0, 0, width, height) val nonEmptyRegion get() = whole.nonEmptyRegion + /** + * returns integer in ABGR format + */ operator fun get(x: Int, y: Int): Int { return whole[x, y] } + /** + * returns integer in ABGR format + */ operator fun get(x: Int, y: Int, flip: Boolean): Int { return whole[x, y, flip] } @@ -201,6 +203,10 @@ class Image private constructor( override val u1: Float = (x.toFloat() + this.width.toFloat()) / this@Image.width override val v0: Float = (y.toFloat() + this.height.toFloat()) / this@Image.height + /** + * returns integer in ABGR format if it is RGB or RGBA picture, + * otherwise returns pixels as-is + */ operator fun get(x: Int, y: Int): Int { require(x in 0 until width && y in 0 until height) { "Position out of bounds: $x $y" } @@ -208,17 +214,17 @@ class Image private constructor( val data = data.join() when (amountOfChannels) { - 4 -> return data[offset].toInt() or - data[offset + 1].toInt().shl(8) or - data[offset + 2].toInt().shl(16) or - data[offset + 3].toInt().shl(24) + 4 -> return data[offset].toInt().and(0xFF) or + data[offset + 1].toInt().and(0xFF).shl(8) or + data[offset + 2].toInt().and(0xFF).shl(16) or + data[offset + 3].toInt().and(0xFF).shl(24) - 3 -> return data[offset].toInt() or - data[offset + 1].toInt().shl(8) or - data[offset + 2].toInt().shl(16) + 3 -> return data[offset].toInt().and(0xFF) or + data[offset + 1].toInt().and(0xFF).shl(8) or + data[offset + 2].toInt().and(0xFF).shl(16) or -0x1000000 // leading alpha as 255 - 2 -> return data[offset].toInt() or - data[offset + 1].toInt().shl(8) + 2 -> return data[offset].toInt().and(0xFF) or + data[offset + 1].toInt().and(0xFF).shl(8) 1 -> return data[offset].toInt() @@ -226,6 +232,9 @@ class Image private constructor( } } + /** + * returns integer in ABGR format + */ operator fun get(x: Int, y: Int, flip: Boolean): Int { if (flip) { require(x in 0 until width && y in 0 until height) { "Position out of bounds: $x $y" } @@ -336,7 +345,7 @@ class Image private constructor( .expireAfterAccess(Duration.ofMinutes(1)) .weigher { key, value -> value.capacity() } .maximumWeight((Runtime.getRuntime().maxMemory() / 4L).coerceIn(1_024L * 1_024L * 32L /* 32 МиБ */, 1_024L * 1_024L * 256L /* 256 МиБ */)) - .scheduler(Scheduler.systemScheduler()) + .scheduler(Starbound) .executor(Starbound.EXECUTOR) .buildAsync(CacheLoader { val getWidth = intArrayOf(0) diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/tile/BuiltinMetaMaterials.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/tile/BuiltinMetaMaterials.kt index f648f5be..8a0dc80b 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/tile/BuiltinMetaMaterials.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/tile/BuiltinMetaMaterials.kt @@ -17,6 +17,12 @@ val Registry.Ref.isEmptyTile: Boolean val Registry.Ref.isNullTile: Boolean get() = entry == BuiltinMetaMaterials.NULL || entry == null +val Registry.Ref.orEmptyTile: Registry.Entry + get() = entry ?: BuiltinMetaMaterials.EMPTY + +val Registry.Ref.orNullTile: Registry.Entry + get() = entry ?: BuiltinMetaMaterials.NULL + val Registry.Ref.isObjectSolidTile: Boolean get() = entry == BuiltinMetaMaterials.OBJECT_SOLID @@ -26,9 +32,18 @@ val Registry.Ref.isObjectPlatformTile: Boolean val Registry.Entry.isEmptyTile: Boolean get() = this == BuiltinMetaMaterials.EMPTY || this == BuiltinMetaMaterials.NULL +val Registry.Entry.isRealTile: Boolean + get() = !value.isMeta + +val Registry.Entry.isMetaTile: Boolean + get() = value.isMeta + val Registry.Entry.isNotEmptyTile: Boolean get() = !isEmptyTile +val Registry.Entry.isObjectTile: Boolean + get() = this === BuiltinMetaMaterials.OBJECT_SOLID || this === BuiltinMetaMaterials.OBJECT_PLATFORM + val Registry.Entry.isNullTile: Boolean get() = this == BuiltinMetaMaterials.NULL @@ -52,9 +67,24 @@ fun Registry.Entry.supportsModifier(modifier: Registry.Ref.isEmptyLiquid: Boolean get() = this == BuiltinMetaMaterials.NO_LIQUID +val Registry.Entry.isNotEmptyLiquid: Boolean + get() = !isEmptyLiquid + val Registry.Ref.isEmptyLiquid: Boolean get() = entry == null || entry == BuiltinMetaMaterials.NO_LIQUID +val Registry.Ref.orEmptyLiquid: Registry.Entry + get() = entry ?: BuiltinMetaMaterials.NO_LIQUID + +val Registry.Ref.isNotEmptyLiquid: Boolean + get() = !isEmptyLiquid + +val Registry.Ref.orEmptyModifier: Registry.Entry + get() = entry ?: BuiltinMetaMaterials.EMPTY_MOD + +val Registry.Ref.isRealModifier: Boolean + get() = entry?.value?.isMeta == false + // these are hardcoded way harder than any Hard-Coder:tm: // considering there is no way you gonna mod-in this many (16 bit uint) dungeons const val NO_DUNGEON_ID = 65535 @@ -120,20 +150,7 @@ object BuiltinMetaMaterials { val OBJECT_SOLID = make(65500, "objectsolid", CollisionType.BLOCK) val OBJECT_PLATFORM = make(65501, "objectplatform", CollisionType.PLATFORM) - val MATERIALS: ImmutableList> = ImmutableList.of( - EMPTY, - NULL, - STRUCTURE, - BIOME, - BIOME1, - BIOME2, - BIOME3, - BIOME4, - BIOME5, - BOUNDARY, - OBJECT_SOLID, - OBJECT_PLATFORM, - ) + val BIOME_META_MATERIALS: ImmutableList> = ImmutableList.of(BIOME, BIOME1, BIOME2, BIOME3, BIOME4, BIOME5) val EMPTY_MOD = makeMod(65535, "none") val BIOME_MOD = makeMod(65534, "biome") diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/world/BiomePlaceables.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/world/BiomePlaceables.kt index 6c6b4e4f..01f920e7 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/world/BiomePlaceables.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/world/BiomePlaceables.kt @@ -16,6 +16,7 @@ import ru.dbotthepony.kommons.collect.filterNotNull import ru.dbotthepony.kommons.gson.consumeNull import ru.dbotthepony.kommons.gson.stream import ru.dbotthepony.kommons.gson.value +import ru.dbotthepony.kommons.vector.Vector2i import ru.dbotthepony.kstarbound.Registry import ru.dbotthepony.kstarbound.Starbound import ru.dbotthepony.kstarbound.collect.WeightedList @@ -27,6 +28,8 @@ import ru.dbotthepony.kstarbound.json.builder.JsonFactory import ru.dbotthepony.kstarbound.json.builder.JsonFlat import ru.dbotthepony.kstarbound.json.listAdapter import ru.dbotthepony.kstarbound.util.random.AbstractPerlinNoise +import ru.dbotthepony.kstarbound.util.random.staticRandomDouble +import ru.dbotthepony.kstarbound.util.random.staticRandomInt import java.util.stream.Stream @JsonFactory @@ -55,7 +58,13 @@ data class BiomePlaceables( val mode: BiomePlaceablesDefinition.Placement = BiomePlaceablesDefinition.Placement.FLOOR, @JsonFlat val data: DistributionData, - ) + ) { + fun itemToPlace(x: Int, y: Int): Placement? { + return data.itemToPlace(x, y, priority) + } + } + + data class Placement(val item: Item, val position: Vector2i, val priority: Double) abstract class Item { abstract val type: BiomePlacementItemType @@ -213,5 +222,39 @@ data class BiomePlaceables( return weightedItems.stream().map { it.first } } } + + fun itemToPlace(x: Int, y: Int, priority: Double): Placement? { + if (distribution == BiomePlacementDistributionType.RANDOM) { + if (randomItems.isEmpty()) + return null // whut + + if (staticRandomDouble(x, y, blockSeed) <= blockProbability) { + return Placement(randomItems[staticRandomInt(0, randomItems.size, x, y, blockSeed)], Vector2i(x, y), priority) + } + } else { + if (weightedItems.isEmpty()) + return null // whut + + if (densityFunction[x.toDouble(), y.toDouble()] > 0.0 && (x + modulusOffset + modulusDistortion[x.toDouble(), y.toDouble()]).toInt() % modulus == 0) { + var maxWeight = Double.NEGATIVE_INFINITY + var choosen: Item? = null + + for ((item, distrb) in weightedItems) { + val weight = distrb[x.toDouble(), y.toDouble()] + + if (weight > maxWeight) { + maxWeight = weight + choosen = item + } + } + + if (choosen != null) { + return Placement(choosen, Vector2i(x, y), priority) + } + } + } + + return null + } } } \ No newline at end of file diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/world/WorldLayout.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/world/WorldLayout.kt index 72c1ce58..79047a83 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/world/WorldLayout.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/world/WorldLayout.kt @@ -70,7 +70,18 @@ class WorldLayout { val biomes = ListInterner() val playerStartSearchRegions = ArrayList() - val layers = ArrayList() + var layers: MutableList = ArrayList() + private set + + private fun optimizeLayers() { + // replaces arraylist with immutable list + // which is faster when determining layer weighting + // because it doesn't check for concurrent modifications + // TODO: add binary search of layers + // binary searching layers will save CPU cycles + // when there are a lot of them + layers = ImmutableList.copyOf(layers) + } var loopX = true var loopY = false @@ -342,6 +353,8 @@ class WorldLayout { } } + optimizeLayers() + return this } @@ -512,6 +525,8 @@ class WorldLayout { for (biome in biomes) { biome.parallax?.fadeToSkyColor(skyColoring) } + + optimizeLayers() } data class RegionWeighting(val weight: Double, val xValue: Int, val region: Region) @@ -558,7 +573,7 @@ class WorldLayout { } else if (y < layers.first().yStart) { return emptyList() } else if (y >= layers.last().yStart) { - yi = layers.size + yi = layers.size - 1 } else { yi = layers.indexOfFirst { it.yStart >= y } - 1 } diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/world/WorldStructure.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/world/WorldStructure.kt index ebaa2633..db8fe5ed 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/world/WorldStructure.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/world/WorldStructure.kt @@ -10,7 +10,7 @@ import ru.dbotthepony.kommons.util.Either import ru.dbotthepony.kommons.vector.Vector2d import ru.dbotthepony.kommons.vector.Vector2i import ru.dbotthepony.kstarbound.json.builder.JsonFactory -import ru.dbotthepony.kstarbound.world.Direction1D +import ru.dbotthepony.kstarbound.world.Direction @JsonFactory data class WorldStructure( @@ -41,7 +41,7 @@ data class WorldStructure( data class Obj( val position: Vector2i, val name: String, - val direction: Direction1D, + val direction: Direction, val parameters: JsonElement, val residual: Boolean = false, ) diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/world/WorldTemplate.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/world/WorldTemplate.kt index a1978697..603c0851 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/defs/world/WorldTemplate.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/defs/world/WorldTemplate.kt @@ -14,9 +14,11 @@ import ru.dbotthepony.kstarbound.defs.tile.BuiltinMetaMaterials import ru.dbotthepony.kstarbound.defs.tile.LiquidDefinition import ru.dbotthepony.kstarbound.defs.tile.TileDefinition import ru.dbotthepony.kstarbound.defs.tile.TileModifierDefinition +import ru.dbotthepony.kstarbound.defs.tile.isNotEmptyLiquid import ru.dbotthepony.kstarbound.json.builder.JsonFactory import ru.dbotthepony.kstarbound.math.quintic2 import ru.dbotthepony.kstarbound.util.random.random +import ru.dbotthepony.kstarbound.util.random.staticRandom64 import ru.dbotthepony.kstarbound.world.Universe import ru.dbotthepony.kstarbound.world.UniversePos import ru.dbotthepony.kstarbound.world.WorldGeometry @@ -38,7 +40,10 @@ class WorldTemplate(val geometry: WorldGeometry) { var celestialParameters: CelestialParameters? = null private set - val customTerrainRegions = ArrayList() + val threatLevel: Double + get() = worldParameters?.threatLevel ?: 0.0 + + private val customTerrainRegions = ArrayList() constructor(worldParameters: VisitableWorldParameters, skyParameters: SkyParameters, seed: Long) : this(WorldGeometry(worldParameters.worldSize, true, false)) { this.seed = seed @@ -70,6 +75,14 @@ class WorldTemplate(val geometry: WorldGeometry) { val aabb = region.aabb.enlarge(Globals.worldTemplate.customTerrainBlendSize, Globals.worldTemplate.customTerrainBlendSize) } + fun addCustomTerrainRegion(region: Poly) { + customTerrainRegions.add(CustomTerrainRegion(region, true)) + } + + fun addCustomSpaceRegion(region: Poly) { + customTerrainRegions.add(CustomTerrainRegion(region, false)) + } + @JsonFactory data class SerializedForm( val celestialParameters: CelestialParameters? = null, @@ -151,21 +164,23 @@ class WorldTemplate(val geometry: WorldGeometry) { return geometry.size.y / 2 } - data class PotentialBiomeItems( + fun seedFor(x: Int, y: Int) = staticRandom64(geometry.x.cell(x), geometry.y.cell(y), seed, "Block") + + class PotentialBiomeItems( // Potential items that would spawn at the given block assuming it is at - val surfaceBiomeItems: List, + val surfaceBiomeItems: List, // ... Or on a cave surface. - val caveSurfaceBiomeItems: List, + val caveSurfaceBiomeItems: List, // ... Or on a cave ceiling. - val caveCeilingBiomeItems: List, + val caveCeilingBiomeItems: List, // ... Or on a cave background wall. - val caveBackgroundBiomeItems: List, + val caveBackgroundBiomeItems: List, // ... Or in the ocean - val oceanItems: List, + val oceanItems: List, ) fun potentialBiomeItemsAt(x: Int, y: Int): PotentialBiomeItems { @@ -174,15 +189,44 @@ class WorldTemplate(val geometry: WorldGeometry) { val upperBlockBiome = cellInfo(geometry.x.cell(x), geometry.y.cell(y + 1)).blockBiome return PotentialBiomeItems( - surfaceBiomeItems = lowerBlockBiome?.surfacePlaceables?.itemDistributions?.filter { it.mode == BiomePlaceablesDefinition.Placement.FLOOR } ?: listOf(), - oceanItems = thisBlockBiome?.surfacePlaceables?.itemDistributions?.filter { it.mode == BiomePlaceablesDefinition.Placement.OCEAN } ?: listOf(), + surfaceBiomeItems = lowerBlockBiome?.surfacePlaceables?.itemDistributions?.filter { it.mode == BiomePlaceablesDefinition.Placement.FLOOR }?.map { it.itemToPlace(x, y) }?.filterNotNull() ?: listOf(), + oceanItems = thisBlockBiome?.surfacePlaceables?.itemDistributions?.filter { it.mode == BiomePlaceablesDefinition.Placement.OCEAN }?.map { it.itemToPlace(x, y) }?.filterNotNull() ?: listOf(), - caveSurfaceBiomeItems = lowerBlockBiome?.undergroundPlaceables?.itemDistributions?.filter { it.mode == BiomePlaceablesDefinition.Placement.FLOOR } ?: listOf(), - caveCeilingBiomeItems = upperBlockBiome?.undergroundPlaceables?.itemDistributions?.filter { it.mode == BiomePlaceablesDefinition.Placement.CEILING } ?: listOf(), - caveBackgroundBiomeItems = thisBlockBiome?.undergroundPlaceables?.itemDistributions?.filter { it.mode == BiomePlaceablesDefinition.Placement.BACKGROUND } ?: listOf(), + caveSurfaceBiomeItems = lowerBlockBiome?.undergroundPlaceables?.itemDistributions?.filter { it.mode == BiomePlaceablesDefinition.Placement.FLOOR }?.map { it.itemToPlace(x, y) }?.filterNotNull() ?: listOf(), + caveCeilingBiomeItems = upperBlockBiome?.undergroundPlaceables?.itemDistributions?.filter { it.mode == BiomePlaceablesDefinition.Placement.CEILING }?.map { it.itemToPlace(x, y) }?.filterNotNull() ?: listOf(), + caveBackgroundBiomeItems = thisBlockBiome?.undergroundPlaceables?.itemDistributions?.filter { it.mode == BiomePlaceablesDefinition.Placement.BACKGROUND }?.map { it.itemToPlace(x, y) }?.filterNotNull() ?: listOf(), ) } + fun validBiomeItemsAt(x: Int, y: Int): List { + val thisBlock = cellInfo(geometry.x.cell(x), geometry.y.cell(y)) + + if (thisBlock.biomeTransition) + return emptyList() + + val result = ArrayList() + val lowerBlock = cellInfo(geometry.x.cell(x), geometry.y.cell(y - 1)) + val upperBlock = cellInfo(geometry.x.cell(x), geometry.y.cell(y + 1)) + val potential = potentialBiomeItemsAt(x, y) + + if (!lowerBlock.biomeTransition && lowerBlock.terrain && !thisBlock.terrain && !lowerBlock.foregroundCave) + result.addAll(potential.surfaceBiomeItems) + + if (!lowerBlock.biomeTransition && lowerBlock.terrain && thisBlock.terrain && !lowerBlock.foregroundCave && thisBlock.foregroundCave) + result.addAll(potential.caveSurfaceBiomeItems) + + if (!upperBlock.biomeTransition && upperBlock.terrain && thisBlock.terrain && !upperBlock.foregroundCave && thisBlock.foregroundCave) + result.addAll(potential.caveCeilingBiomeItems) + + if (thisBlock.terrain && thisBlock.foregroundCave && !thisBlock.backgroundCave) + result.addAll(potential.caveBackgroundBiomeItems) + + if (thisBlock.oceanLiquid.isNotEmptyLiquid && y == thisBlock.oceanLiquidLevel) + result.addAll(potential.oceanItems) + + return result + } + class CellInfo(val x: Int, val y: Int) { var foreground: Registry.Ref = BuiltinMetaMaterials.EMPTY.ref var foregroundMod: Registry.Ref = BuiltinMetaMaterials.EMPTY_MOD.ref @@ -204,19 +248,11 @@ class WorldTemplate(val geometry: WorldGeometry) { var backgroundCave = false } - // making cache big enough to make - // serial generation stages fast enough, - // since sampling noise is costly - - // TODO: Don't specify scheduler and executor since - // G1GC doesn't like this and will refuse to clean up - // memory retained by this cache until G1GC feels like it - // (needs more profiling) private val cellCache = Caffeine.newBuilder() - .maximumSize(1_000_000L) - .expireAfterAccess(Duration.ofMinutes(2)) - .executor(Starbound.EXECUTOR) - .scheduler(Scheduler.systemScheduler()) + .maximumSize(50_000L) + //.expireAfterAccess(Duration.ofMinutes(1)) + //.executor(Starbound.EXECUTOR) + //.scheduler(Starbound) // don't specify scheduler since this cache is accessed very frequently .build { (x, y) -> cellInfo0(x, y) } fun cellInfo(x: Int, y: Int): CellInfo { diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/network/LegacyNetworkCellState.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/network/LegacyNetworkCellState.kt index 08c92626..202ee7a3 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/network/LegacyNetworkCellState.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/network/LegacyNetworkCellState.kt @@ -107,10 +107,11 @@ data class LegacyNetworkLiquidState( val level: Int, // ubyte ) { fun write(stream: DataOutputStream) { - stream.write(liquid) - if (liquid in 1 .. 255) { // empty or can't be represented by legacy protocol + stream.write(liquid) stream.write(level) + } else { + stream.write(0) } } diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/network/PacketRegistry.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/network/PacketRegistry.kt index 6050fa86..ab79a977 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/network/PacketRegistry.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/network/PacketRegistry.kt @@ -195,15 +195,26 @@ class PacketRegistry(val isLegacy: Boolean) { stream = FastByteArrayInputStream(packetReadBuffer.elements(), 0, packetReadBuffer.size) } + var i = -1 + // legacy protocol allows to stitch multiple packets of same type together without // separate headers for each // Due to nature of netty pipeline, we can't do the same on native protocol; // so don't do that when on native protocol do { + i++ + try { ctx.fireChannelRead(readingType!!.factory.read(DataInputStream(stream), isLegacy, side)) } catch (err: Throwable) { - LOGGER.error("Error while reading incoming packet from network (type ${readingType!!.id}; ${readingType!!.type})", err) + LOGGER.error("Error while reading incoming packet from network (type ${readingType!!.id}; ${readingType!!.type}; packet No. $i in stream)", err) + } + + // are u foking serious, InflaterInputStream? + if (stream is BufferedInputStream && stream.available() > 0 && isLegacy) { + stream.mark(1) + stream.read() + stream.reset() } } while (stream.available() > 0 && isLegacy) diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/server/IntegratedStarboundServer.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/server/IntegratedStarboundServer.kt index 78d75997..6ebd3623 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/server/IntegratedStarboundServer.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/server/IntegratedStarboundServer.kt @@ -4,14 +4,14 @@ import ru.dbotthepony.kstarbound.client.StarboundClient import java.io.Closeable import java.io.File -class IntegratedStarboundServer(val client: StarboundClient, root: File) : StarboundServer(root), Closeable { +class IntegratedStarboundServer(val client: StarboundClient, root: File) : StarboundServer(root) { init { channels.createLocalChannel() } override fun tick0() { - if (client.shouldTerminate) { - close() + if (client.isShutdown) { + shutdown() } } diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/server/ServerConnection.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/server/ServerConnection.kt index 835f13ef..e3d07081 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/server/ServerConnection.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/server/ServerConnection.kt @@ -136,7 +136,7 @@ class ServerConnection(val server: StarboundServer, type: ConnectionType) : Conn announceDisconnect("Connection to remote host is lost.") if (::shipWorld.isInitialized) { - shipWorld.close() + shipWorld.eventLoop.shutdown() } if (countedTowardsPlayerCount) { @@ -288,7 +288,7 @@ class ServerConnection(val server: StarboundServer, type: ConnectionType) : Conn currentFlightJob?.cancel() val flight = world.flyShip(this, location) - shipWorld.mailbox.execute { + shipWorld.eventLoop.execute { shipWorld.sky.startFlying(false) } @@ -303,7 +303,7 @@ class ServerConnection(val server: StarboundServer, type: ConnectionType) : Conn val sky = coords.skyParameters(world) - shipWorld.mailbox.execute { + shipWorld.eventLoop.execute { shipWorld.sky.stopFlyingAt(sky) } } @@ -323,7 +323,7 @@ class ServerConnection(val server: StarboundServer, type: ConnectionType) : Conn currentFlightJob?.cancel() world.removeClient(this) - shipWorld.mailbox.execute { + shipWorld.eventLoop.execute { shipWorld.sky.startFlying(true) } @@ -350,7 +350,7 @@ class ServerConnection(val server: StarboundServer, type: ConnectionType) : Conn val newParams = ship.location.skyParameters(world) - shipWorld.mailbox.execute { + shipWorld.eventLoop.execute { shipWorld.sky.stopFlyingAt(newParams) } @@ -450,7 +450,7 @@ class ServerConnection(val server: StarboundServer, type: ConnectionType) : Conn tracker = null if (::shipWorld.isInitialized) { - shipWorld.close() + shipWorld.eventLoop.shutdown() } if (channel.isOpen) { @@ -492,11 +492,11 @@ class ServerConnection(val server: StarboundServer, type: ConnectionType) : Conn server.loadShipWorld(this, shipChunkSource).thenAccept { if (!isConnected || !channel.isOpen) { LOGGER.warn("$this disconnected before loaded their ShipWorld") - it.close() + it.eventLoop.shutdown() } else { shipWorld = it // shipWorld.sky.startFlying(true, true) - shipWorld.thread.start() + shipWorld.eventLoop.start() enqueueWarp(WarpAlias.OwnShip) shipUpgrades = shipUpgrades.addCapability("planetTravel") shipUpgrades = shipUpgrades.addCapability("teleport") diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/server/StarboundServer.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/server/StarboundServer.kt index c49d7273..983dcbe2 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/server/StarboundServer.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/server/StarboundServer.kt @@ -4,6 +4,7 @@ import com.google.gson.JsonPrimitive import it.unimi.dsi.fastutil.objects.ObjectArraySet import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.async import kotlinx.coroutines.cancel import kotlinx.coroutines.future.asCompletableFuture @@ -24,6 +25,7 @@ import ru.dbotthepony.kstarbound.server.world.ServerUniverse import ru.dbotthepony.kstarbound.server.world.ServerWorld import ru.dbotthepony.kstarbound.server.world.ServerSystemWorld import ru.dbotthepony.kstarbound.server.world.WorldStorage +import ru.dbotthepony.kstarbound.util.BlockableEventLoop import ru.dbotthepony.kstarbound.util.Clock import ru.dbotthepony.kstarbound.util.ExceptionLogger import ru.dbotthepony.kstarbound.util.ExecutionSpinner @@ -38,7 +40,7 @@ import java.util.concurrent.TimeUnit import java.util.concurrent.locks.ReentrantLock import java.util.function.Supplier -sealed class StarboundServer(val root: File) : Closeable { +sealed class StarboundServer(val root: File) : BlockableEventLoop("Server thread") { init { if (!root.exists()) { check(root.mkdirs()) { "Unable to create ${root.absolutePath}" } @@ -48,12 +50,10 @@ sealed class StarboundServer(val root: File) : Closeable { } private val worlds = HashMap>() - val mailbox = MailboxExecutorService().also { it.exceptionHandler = ExceptionLogger(LOGGER) } - val spinner = ExecutionSpinner(mailbox::executeQueuedTasks, ::tick, Starbound.TIMESTEP_NANOS) - val thread = Thread(spinner, "Server Thread") val universe = ServerUniverse() val chat = ChatHandler(this) val scope = CoroutineScope(Starbound.COROUTINE_EXECUTOR + SupervisorJob()) + val eventLoopScope = CoroutineScope(asCoroutineDispatcher() + SupervisorJob()) private val systemWorlds = HashMap>() @@ -62,11 +62,11 @@ sealed class StarboundServer(val root: File) : Closeable { } fun loadSystemWorld(location: Vector3i): CompletableFuture { - return CompletableFuture.supplyAsync(Supplier { + return supplyAsync { systemWorlds.computeIfAbsent(location) { scope.async { loadSystemWorld0(location) }.asCompletableFuture() } - }, mailbox).thenCompose { it } + }.thenCompose { it } } private suspend fun loadCelestialWorld(location: WorldID.Celestial): ServerWorld { @@ -76,11 +76,11 @@ sealed class StarboundServer(val root: File) : Closeable { val world = ServerWorld.create(this, template, WorldStorage.Nothing, location) try { - world.thread.start() + world.eventLoop.start() world.prepare().await() } catch (err: Throwable) { LOGGER.fatal("Exception while creating celestial world at $location!", err) - world.close() + world.eventLoop.shutdown() throw err } @@ -113,7 +113,7 @@ sealed class StarboundServer(val root: File) : Closeable { world.setProperty("ephemeral", JsonPrimitive(!config.persistent)) - world.thread.start() + world.eventLoop.start() return world } @@ -127,7 +127,7 @@ sealed class StarboundServer(val root: File) : Closeable { } fun loadWorld(location: WorldID): CompletableFuture { - return CompletableFuture.supplyAsync(Supplier { + return supplyAsync { var world = worlds[location] if (world != null && world.isCompletedExceptionally) { @@ -142,11 +142,11 @@ sealed class StarboundServer(val root: File) : Closeable { worlds[location] = future future } - }, mailbox).thenCompose { it } + }.thenCompose { it } } fun loadShipWorld(connection: ServerConnection, storage: WorldStorage): CompletableFuture { - return CompletableFuture.supplyAsync(Supplier { + return supplyAsync { val id = WorldID.ShipWorld(connection.uuid ?: throw NullPointerException("Connection UUID is null")) val existing = worlds[id] @@ -156,11 +156,11 @@ sealed class StarboundServer(val root: File) : Closeable { val world = ServerWorld.load(this, storage, id) worlds[id] = world world - }, mailbox).thenCompose { it } + }.thenCompose { it } } fun notifyWorldUnloaded(worldID: WorldID) { - mailbox.execute { + execute { worlds.remove(worldID) } } @@ -181,17 +181,20 @@ sealed class StarboundServer(val root: File) : Closeable { val universeClock = Clock() init { - mailbox.scheduleAtFixedRate(Runnable { + scheduleAtFixedRate(Runnable { channels.broadcast(UniverseTimeUpdatePacket(universeClock.seconds)) }, Globals.universeServer.clockUpdatePacketInterval, Globals.universeServer.clockUpdatePacketInterval, TimeUnit.MILLISECONDS) - thread.uncaughtExceptionHandler = Thread.UncaughtExceptionHandler { t, e -> - LOGGER.fatal("Unexpected exception in server execution loop, shutting down", e) - actuallyClose() - } + scheduleAtFixedRate(Runnable { + tickNormal() + }, Starbound.TIMESTEP_NANOS, Starbound.TIMESTEP_NANOS, TimeUnit.NANOSECONDS) - // thread.isDaemon = this is IntegratedStarboundServer - thread.start() + scheduleAtFixedRate(Runnable { + tickSystemWorlds() + }, Starbound.SYSTEM_WORLD_TIMESTEP_NANOS, Starbound.SYSTEM_WORLD_TIMESTEP_NANOS, TimeUnit.NANOSECONDS) + + isDaemon = false + start() } private val occupiedNicknames = ObjectArraySet() @@ -226,57 +229,67 @@ sealed class StarboundServer(val root: File) : Closeable { protected abstract fun close0() protected abstract fun tick0() - private fun tick(): Boolean { - if (isClosed) return false - - channels.connections.forEach { - try { - it.tick() - } catch (err: Throwable) { - LOGGER.error("Exception while ticking client connection", err) - it.disconnect("Exception while ticking client connection: $err") + private fun tickSystemWorlds() { + systemWorlds.values.removeIf { + if (it.isCompletedExceptionally) { + return@removeIf true } - } - // TODO: schedule to thread pool? - // right now, system worlds are rather lightweight, and having separate threads for them is overkill - if (systemWorlds.isNotEmpty()) { - runBlocking { - systemWorlds.values.removeIf { - if (it.isCompletedExceptionally) { - return@removeIf true - } + if (!it.isDone) { + return@removeIf false + } - if (!it.isDone) { - return@removeIf false - } - - launch { it.get().tick() } - - if (it.get().shouldClose()) { - LOGGER.info("Stopping idling ${it.get()}") - return@removeIf true - } - - return@removeIf false + eventLoopScope.launch { + try { + it.get().tick() + } catch (err: Throwable) { + LOGGER.fatal("Exception in system world $it event loop", err) } } - } - tick0() - return !isClosed + if (it.get().shouldClose()) { + LOGGER.info("Stopping idling ${it.get()}") + return@removeIf true + } + + return@removeIf false + } } - private fun actuallyClose() { - if (isClosed) return - isClosed = true + private fun tickNormal() { + try { + channels.connections.forEach { + try { + it.tick() + } catch (err: Throwable) { + LOGGER.error("Exception while ticking client connection", err) + it.disconnect("Exception while ticking client connection: $err") + } + } + + tick0() + } catch (err: Throwable) { + LOGGER.fatal("Exception in main server event loop", err) + shutdown() + } + } + + override fun performShutdown() { + super.performShutdown() scope.cancel("Server shutting down") channels.close() worlds.values.forEach { - if (it.isDone && !it.isCompletedExceptionally) - it.get().close() + if (it.isDone && !it.isCompletedExceptionally) { + it.get().eventLoop.shutdown() + } + } + + worlds.values.forEach { + if (it.isDone && !it.isCompletedExceptionally) { + it.get().eventLoop.awaitTermination(10L, TimeUnit.SECONDS) + } it.cancel(true) } @@ -285,14 +298,6 @@ sealed class StarboundServer(val root: File) : Closeable { close0() } - final override fun close() { - if (Thread.currentThread() == thread) { - actuallyClose() - } else { - mailbox.execute { actuallyClose() } - } - } - companion object { private val LOGGER = LogManager.getLogger() } diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/ServerChunk.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/ServerChunk.kt index a1033f06..60cad1c8 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/ServerChunk.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/ServerChunk.kt @@ -4,16 +4,21 @@ import it.unimi.dsi.fastutil.objects.ObjectAVLTreeSet import it.unimi.dsi.fastutil.objects.ObjectArrayList import it.unimi.dsi.fastutil.objects.ObjectArraySet import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.delay import kotlinx.coroutines.future.await import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine import org.apache.logging.log4j.LogManager import ru.dbotthepony.kommons.arrays.Object2DArray +import ru.dbotthepony.kommons.guava.immutableList import ru.dbotthepony.kommons.util.AABBi +import ru.dbotthepony.kommons.util.KOptional import ru.dbotthepony.kommons.vector.Vector2d import ru.dbotthepony.kommons.vector.Vector2i +import ru.dbotthepony.kstarbound.Registries import ru.dbotthepony.kstarbound.Starbound +import ru.dbotthepony.kstarbound.defs.dungeon.DungeonRule import ru.dbotthepony.kstarbound.defs.tile.BuiltinMetaMaterials +import ru.dbotthepony.kstarbound.defs.tile.FIRST_RESERVED_DUNGEON_ID import ru.dbotthepony.kstarbound.defs.tile.MICRO_DUNGEON_ID import ru.dbotthepony.kstarbound.defs.tile.NO_DUNGEON_ID import ru.dbotthepony.kstarbound.defs.tile.TileDamage @@ -21,12 +26,19 @@ import ru.dbotthepony.kstarbound.defs.tile.TileDamageResult import ru.dbotthepony.kstarbound.defs.tile.TileDamageType import ru.dbotthepony.kstarbound.defs.tile.isEmptyLiquid import ru.dbotthepony.kstarbound.defs.tile.isEmptyTile +import ru.dbotthepony.kstarbound.defs.tile.isMetaTile +import ru.dbotthepony.kstarbound.defs.tile.isNotEmptyLiquid import ru.dbotthepony.kstarbound.defs.tile.isNotEmptyTile import ru.dbotthepony.kstarbound.defs.tile.isNullTile import ru.dbotthepony.kstarbound.defs.tile.supportsModifier +import ru.dbotthepony.kstarbound.defs.world.Biome +import ru.dbotthepony.kstarbound.defs.world.BiomePlaceables +import ru.dbotthepony.kstarbound.defs.world.WorldTemplate import ru.dbotthepony.kstarbound.network.LegacyNetworkCellState import ru.dbotthepony.kstarbound.network.packets.clientbound.TileDamageUpdatePacket +import ru.dbotthepony.kstarbound.util.random.random import ru.dbotthepony.kstarbound.util.random.staticRandomDouble +import ru.dbotthepony.kstarbound.util.random.staticRandomInt import ru.dbotthepony.kstarbound.world.CHUNK_SIZE import ru.dbotthepony.kstarbound.world.CHUNK_SIZE_FF import ru.dbotthepony.kstarbound.world.Chunk @@ -35,13 +47,19 @@ import ru.dbotthepony.kstarbound.world.IChunkListener import ru.dbotthepony.kstarbound.world.TileHealth import ru.dbotthepony.kstarbound.world.api.AbstractCell import ru.dbotthepony.kstarbound.world.api.ImmutableCell +import ru.dbotthepony.kstarbound.world.api.MutableCell +import ru.dbotthepony.kstarbound.world.api.MutableTileState import ru.dbotthepony.kstarbound.world.api.TileColor import ru.dbotthepony.kstarbound.world.entities.AbstractEntity import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit import java.util.concurrent.locks.ReentrantLock import java.util.function.Predicate +import java.util.function.Supplier import kotlin.concurrent.withLock import kotlin.coroutines.cancellation.CancellationException +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException class ServerChunk(world: ServerWorld, pos: ChunkPos) : Chunk(world, pos) { /** @@ -52,10 +70,10 @@ class ServerChunk(world: ServerWorld, pos: ChunkPos) : Chunk { block -> + val future = world.eventLoop.schedule(Runnable { + if (!neighbour.chunk.isDone) { + LOGGER.error("Giving up waiting on ${neighbour.pos} while advancing generation stage of $pos to $nextState (neighbour chunk was in state ${world.chunkMap[neighbour.pos]?.state}, expected $state)") + block.resume(Unit) + } + }, 30L, TimeUnit.SECONDS) - if (!neighbour.chunk.isDone) { - LOGGER.error("Giving up waiting on ${neighbour.pos} while advancing generation stage of $this to $nextState (neighbour chunk was in state ${world.chunkMap[neighbour.pos]?.state}, expected $state)") + neighbour.chunk.thenAccept { + future.cancel(false) + block.resume(Unit) + }.exceptionally { + future.cancel(false) + block.resumeWithException(it); null + } + + block.invokeOnCancellation { + future.cancel(false) + neighbour.cancel() + } } } } catch (err: Throwable) { @@ -126,23 +159,37 @@ class ServerChunk(world: ServerWorld, pos: ChunkPos) : Chunk { - // tiles can be generated concurrently without any consequences - CompletableFuture.runAsync(Runnable { prepareCells() }, Starbound.EXECUTOR).await() + State.TERRAIN -> { + if (world.template.worldLayout == null) { + // skip since no cells will be generated anyway + cells.value.fill(AbstractCell.EMPTY) + } else { + // tiles can be generated concurrently without any consequences + CompletableFuture.runAsync(Runnable { prepareCells() }, Starbound.EXECUTOR).await() + } } State.MICRO_DUNGEONS -> { - //LOGGER.error("NYI: Generating microdungeons for $chunk") + // skip if we have no layout + if (world.template.worldLayout != null) { + placeMicroDungeons() + } } State.CAVE_LIQUID -> { - // not thread safe, but takes very little time to execute - generateLiquid() + // skip if we have no layout + if (world.template.worldLayout != null) { + generateLiquid() + } } State.FULL -> { - // CompletableFuture.runAsync(Runnable { placeGrass() }, Starbound.EXECUTOR).await() - placeGrass() + CompletableFuture.runAsync(Runnable { finalizeCells() }, Starbound.EXECUTOR).await() + + // skip if we have no layout + if (world.template.worldLayout != null) { + placeGrass() + } } State.FRESH -> throw RuntimeException() @@ -168,7 +215,10 @@ class ServerChunk(world: ServerWorld, pos: ChunkPos) : Chunk 0) { "Invalid ticket time: $time" } + require(time >= 0) { "Invalid ticket time: $time" } ticketsLock.withLock { return TimedTicket(time, target) @@ -263,8 +315,6 @@ class ServerChunk(world: ServerWorld, pos: ChunkPos) : Chunk() init { - isBusy = true - if (this@ServerChunk.state >= targetState) { chunk.complete(this@ServerChunk) } @@ -520,7 +570,7 @@ class ServerChunk(world: ServerWorld, pos: ChunkPos) : Chunk tile.material = biome?.mainBlock?.native?.entry ?: BuiltinMetaMaterials.EMPTY + BuiltinMetaMaterials.BIOME1 -> tile.material = biome?.subBlocks?.getOrNull(0)?.native?.entry ?: biome?.mainBlock?.native?.entry ?: BuiltinMetaMaterials.EMPTY + BuiltinMetaMaterials.BIOME2 -> tile.material = biome?.subBlocks?.getOrNull(1)?.native?.entry ?: biome?.mainBlock?.native?.entry ?: BuiltinMetaMaterials.EMPTY + BuiltinMetaMaterials.BIOME3 -> tile.material = biome?.subBlocks?.getOrNull(2)?.native?.entry ?: biome?.mainBlock?.native?.entry ?: BuiltinMetaMaterials.EMPTY + BuiltinMetaMaterials.BIOME4 -> tile.material = biome?.subBlocks?.getOrNull(3)?.native?.entry ?: biome?.mainBlock?.native?.entry ?: BuiltinMetaMaterials.EMPTY + BuiltinMetaMaterials.BIOME5 -> tile.material = biome?.subBlocks?.getOrNull(4)?.native?.entry ?: biome?.mainBlock?.native?.entry ?: BuiltinMetaMaterials.EMPTY + else -> {} + } - val tile = cell.tile(isBackground) - val tileInv = cell.tile(!isBackground) + tile.hueShift = biome?.hueShift(tile.material) ?: 0f - // don't place mods in dungeons unless explicitly specified, also don't - // touch non-grass mods - if ( - tile.modifier == BuiltinMetaMaterials.BIOME_MOD || - tile.modifier == BuiltinMetaMaterials.UNDERGROUND_BIOME_MOD || - (cell.dungeonId == NO_DUNGEON_ID && tile.modifier == BuiltinMetaMaterials.EMPTY_MOD) - ) { - // check whether we're floor or ceiling + if (biome == null && tile.modifier == BuiltinMetaMaterials.BIOME_MOD) { + tile.modifier = BuiltinMetaMaterials.EMPTY_MOD + tile.modifierHueShift = 0f + } + } - // NOTE: we are querying other chunks while generating, - // and we might read stale data if we are reading neighbouring chunks - // since they might be in process of generation, too - // (only if they are in process of generating someing significant, which modify terrain) - // shouldn't be an issue though - val cellAbove = world.chunkMap.getCell(pos.tileX + x, pos.tileY + y + 1) - val cellBelow = world.chunkMap.getCell(pos.tileX + x, pos.tileY + y - 1) + fun replaceBiomeBlocks(cell: MutableCell, info: WorldTemplate.CellInfo) { + doReplaceBiomeTile(cell.foreground, info.blockBiome) + doReplaceBiomeTile(cell.background, info.blockBiome) + } - val isFloor = (!cell.foreground.material.isEmptyTile && cellAbove.foreground.material.isEmptyTile) || (!cell.background.material.isEmptyTile && cellAbove.background.material.isEmptyTile) - val isCeiling = !isFloor && ((!cell.foreground.material.isEmptyTile && cellBelow.foreground.material.isEmptyTile) || (!cell.background.material.isEmptyTile && cellBelow.background.material.isEmptyTile)) + private fun replaceBiomeBlocks() { + val cells = cells.value - // I might be stupid, but in original code the check above is completely wrong - // because it will result in buried glass under tiles - //val isFloor = !tile.material.isEmptyTile && tileAbove.material.isEmptyTile - //val isCeiling = !isFloor && !tile.material.isEmptyTile && tileBelow.material.isEmptyTile - - // get the appropriate placeables for above/below ground - val placeables = if (isFloor && !cellAbove.background.material.isEmptyTile || isCeiling && !cellBelow.background.material.isEmptyTile) { - biome.undergroundPlaceables - } else { - biome.surfacePlaceables + for (x in 0 until width) { + for (y in 0 until height) { + val cell = cells[x, y].mutable() + replaceBiomeBlocks(cell, world.template.cellInfo(pos.tileX + x, pos.tileY + y)) + cells[x, y] = cell.immutable() } + } + } - // determine the proper grass mod or lack thereof - var grassMod = BuiltinMetaMaterials.EMPTY_MOD + private suspend fun placeMicroDungeons() { + val placements = CompletableFuture.supplyAsync(Supplier { + val placements = ArrayList() - if (isFloor) { - if (staticRandomDouble(world.template.seed, pos.tileX + x, pos.tileY + y, "grass") <= placeables.grassModDensity) { - grassMod = placeables.grassMod.native.entry ?: BuiltinMetaMaterials.EMPTY_MOD - } - } else if (isCeiling) { - if (staticRandomDouble(world.template.seed, pos.tileX + x, pos.tileY + y, "grass") <= placeables.ceilingGrassModDensity) { - grassMod = placeables.ceilingGrassMod.native.entry ?: BuiltinMetaMaterials.EMPTY_MOD + for (x in 0 until width) { + for (y in 0 until height) { + placements.addAll(world.template.validBiomeItemsAt(pos.tileX + x, pos.tileY + y)) } } - val modify = cell.mutable() + placements.sortByDescending { it.priority } + placements + }, Starbound.EXECUTOR).await() - if (isBackground) { - modify.background.modifier = grassMod - modify.foreground.modifier = BuiltinMetaMaterials.EMPTY_MOD - } else { - modify.foreground.modifier = grassMod - modify.background.modifier = BuiltinMetaMaterials.EMPTY_MOD + val bounds = AABBi( + pos.tile - Vector2i(CHUNK_SIZE_FF, CHUNK_SIZE_FF), + pos.tile + Vector2i(width + CHUNK_SIZE_FF, height + CHUNK_SIZE_FF) + ) + + for (placement in placements) { + if (placement.item is BiomePlaceables.MicroDungeon) { + if (placement.item.microdungeons.isEmpty()) + continue // ??? + + val seed = world.template.seedFor(placement.position.x, placement.position.y) + val random = random(seed) + val dungeon = placement.item.microdungeons.elementAt(random.nextInt(placement.item.microdungeons.size)) + + val def = Registries.dungeons[dungeon] + + if (def == null) { + LOGGER.error("Unknown dungeon type $dungeon!") + } else { + val anchors = def.value.validAnchors(world) + + if (anchors.isEmpty()) + continue + + val anchor = anchors.random(random) + + for (dy in MICRODUNGEON_PLACEMENT_SHIFTS) { + val pos = placement.position - anchor.anchor + Vector2i(y = dy) + + if (!bounds.isInside(pos) || !bounds.isInside(pos + anchor.reader.size - Vector2i.POSITIVE_XY)) + continue + + val collision = anchor.reader.walkTiles { x, y, tile -> + if (tile.usesPlaces && world.getCell(pos.x + x, pos.y + y).dungeonId != NO_DUNGEON_ID) { + return@walkTiles KOptional(true) + } + + return@walkTiles KOptional() + }.orElse(false) + + if (!collision && anchor.canPlace(pos.x, pos.y, world)) { + def.value.build(anchor, world, random, pos.x, pos.y, dungeonID = MICRO_DUNGEON_ID).await() + LOGGER.info("Placed dungeon $dungeon at $pos") + break + } + } + } } + } + } - modify.background.modifierHueShift = biome.hueShift(modify.background.modifier) - modify.foreground.modifierHueShift = biome.hueShift(modify.foreground.modifier) + private fun placeGrass() { + val cells = cells.value - cells.value[x, y] = modify.immutable() + for (x in 0 until width) { + for (y in 0 until height) { + val biome = world.template.cellInfo(pos.tileX + x, pos.tileY + y).blockBiome ?: continue + val cell = cells[x, y] + + // determine layer for grass mod calculation + val isBackground = cell.foreground.material.isEmptyTile + + val tile = cell.tile(isBackground) + val tileInv = cell.tile(!isBackground) + + // don't place mods in dungeons unless explicitly specified, also don't + // touch non-grass mods + if ( + tile.modifier == BuiltinMetaMaterials.BIOME_MOD || + tile.modifier == BuiltinMetaMaterials.UNDERGROUND_BIOME_MOD || + (cell.dungeonId == NO_DUNGEON_ID && tile.modifier == BuiltinMetaMaterials.EMPTY_MOD) + ) { + // check whether we're floor or ceiling + + // NOTE: we are querying other chunks while generating, + // and we might read stale data if we are reading neighbouring chunks + // since they might be in process of generation, too + // (only if they are in process of generating someing significant, which modify terrain) + // shouldn't be an issue though + val cellAbove = world.chunkMap.getCell(pos.tileX + x, pos.tileY + y + 1) + val cellBelow = world.chunkMap.getCell(pos.tileX + x, pos.tileY + y - 1) + + val isFloor = (!cell.foreground.material.isEmptyTile && cellAbove.foreground.material.isEmptyTile) || (!cell.background.material.isEmptyTile && cellAbove.background.material.isEmptyTile) + val isCeiling = !isFloor && ((!cell.foreground.material.isEmptyTile && cellBelow.foreground.material.isEmptyTile) || (!cell.background.material.isEmptyTile && cellBelow.background.material.isEmptyTile)) + + // get the appropriate placeables for above/below ground + val placeables = if (isFloor && !cellAbove.background.material.isEmptyTile || isCeiling && !cellBelow.background.material.isEmptyTile) { + biome.undergroundPlaceables + } else { + biome.surfacePlaceables + } + + // determine the proper grass mod or lack thereof + var grassMod = BuiltinMetaMaterials.EMPTY_MOD + + if (isFloor) { + if (staticRandomDouble(world.template.seed, pos.tileX + x, pos.tileY + y, "grass") <= placeables.grassModDensity) { + grassMod = placeables.grassMod.native.entry ?: BuiltinMetaMaterials.EMPTY_MOD + } + } else if (isCeiling) { + if (staticRandomDouble(world.template.seed, pos.tileX + x, pos.tileY + y, "grass") <= placeables.ceilingGrassModDensity) { + grassMod = placeables.ceilingGrassMod.native.entry ?: BuiltinMetaMaterials.EMPTY_MOD + } + } + + val modify = cell.mutable() + + if (isBackground) { + modify.background.modifier = grassMod + modify.foreground.modifier = BuiltinMetaMaterials.EMPTY_MOD + } else { + modify.foreground.modifier = grassMod + modify.background.modifier = BuiltinMetaMaterials.EMPTY_MOD + } + + modify.background.modifierHueShift = biome.hueShift(modify.background.modifier) + modify.foreground.modifierHueShift = biome.hueShift(modify.foreground.modifier) + + cells[x, y] = modify.immutable() + } + } } } @@ -927,5 +1109,12 @@ class ServerChunk(world: ServerWorld, pos: ChunkPos) : Chunk() - suspend fun tick(delta: Double = Starbound.TIMESTEP) { + suspend fun tick(delta: Double = Starbound.SYSTEM_WORLD_TIMESTEP) { val orbit = destination as? SystemWorldLocation.Orbit // if destination is an orbit we haven't started orbiting yet, update the time @@ -446,7 +446,7 @@ class ServerSystemWorld : SystemWorld { var hasExpired = false private set - suspend fun tick(delta: Double = Starbound.TIMESTEP) { + suspend fun tick(delta: Double = Starbound.SYSTEM_WORLD_TIMESTEP) { if (!data.permanent && spawnTime > 0.0 && clock.seconds > spawnTime + data.lifeTime) hasExpired = true diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/ServerUniverse.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/ServerUniverse.kt index 78cad64b..f1586207 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/ServerUniverse.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/ServerUniverse.kt @@ -13,6 +13,7 @@ import ru.dbotthepony.kommons.util.AABBi import ru.dbotthepony.kommons.util.KOptional import ru.dbotthepony.kommons.vector.Vector2i import ru.dbotthepony.kstarbound.Globals +import ru.dbotthepony.kstarbound.Starbound import ru.dbotthepony.kstarbound.defs.world.CelestialBaseInformation import ru.dbotthepony.kstarbound.defs.world.CelestialConfig import ru.dbotthepony.kstarbound.defs.world.CelestialParameters @@ -219,7 +220,8 @@ class ServerUniverse private constructor(marker: Nothing?) : Universe(), Closeab .expireAfterAccess(Duration.ofMinutes(10L)) .maximumSize(1024L) .softValues() - .scheduler(Scheduler.systemScheduler()) + .scheduler(Starbound) + .executor(Starbound.EXECUTOR) .build() fun getChunkFuture(pos: Vector2i): CompletableFuture> { diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/ServerWorld.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/ServerWorld.kt index be8aaf55..15263dd7 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/ServerWorld.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/ServerWorld.kt @@ -15,7 +15,9 @@ import ru.dbotthepony.kommons.util.AABB import ru.dbotthepony.kommons.util.AABBi import ru.dbotthepony.kommons.util.IStruct2i import ru.dbotthepony.kommons.vector.Vector2d +import ru.dbotthepony.kommons.vector.Vector2i import ru.dbotthepony.kstarbound.Globals +import ru.dbotthepony.kstarbound.Registries import ru.dbotthepony.kstarbound.Starbound import ru.dbotthepony.kstarbound.defs.WarpAction import ru.dbotthepony.kstarbound.defs.WarpAlias @@ -35,7 +37,9 @@ import ru.dbotthepony.kstarbound.network.packets.clientbound.UpdateWorldProperti import ru.dbotthepony.kstarbound.server.StarboundServer import ru.dbotthepony.kstarbound.server.ServerConnection import ru.dbotthepony.kstarbound.util.AssetPathStack +import ru.dbotthepony.kstarbound.util.BlockableEventLoop import ru.dbotthepony.kstarbound.util.ExecutionSpinner +import ru.dbotthepony.kstarbound.util.random.random import ru.dbotthepony.kstarbound.world.ChunkPos import ru.dbotthepony.kstarbound.world.World import ru.dbotthepony.kstarbound.world.WorldGeometry @@ -46,6 +50,7 @@ import ru.dbotthepony.kstarbound.world.physics.CollisionType import java.util.concurrent.CompletableFuture import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.locks.LockSupport import java.util.function.Supplier @@ -64,7 +69,6 @@ class ServerWorld private constructor( val clients = CopyOnWriteArrayList() val shouldStopOnIdle = worldID !is WorldID.ShipWorld - val scope = CoroutineScope(mailbox.asCoroutineDispatcher() + SupervisorJob()) private fun doAcceptClient(client: ServerConnection, action: WarpAction?) { try { @@ -93,17 +97,19 @@ class ServerWorld private constructor( client.tracker?.remove("Transiting to new world", false) clients.add(ServerWorldTracker(this, client, start)) + + //if (worldID is WorldID.Celestial) + //Registries.dungeons["gardenmicrodungeons"]?.value?.generate(this@ServerWorld, random(), start.x.toInt(), start.y.toInt(), markSurfaceAndTerrain = false, forcePlacement = true) } finally { isBusy-- } } fun acceptClient(player: ServerConnection, action: WarpAction? = null): CompletableFuture { - check(!isClosed.get()) { "$this is invalid" } - unpause() + check(!eventLoop.isShutdown) { "$this is invalid" } try { - val future = CompletableFuture.supplyAsync(Supplier { doAcceptClient(player, action) }, mailbox) + val future = eventLoop.supplyAsync { doAcceptClient(player, action) } future.exceptionally { LOGGER.error("Error while accepting new player into world", it) @@ -115,47 +121,19 @@ class ServerWorld private constructor( } } - val spinner = ExecutionSpinner(mailbox::executeQueuedTasks, ::spin, Starbound.TIMESTEP_NANOS) - private val str = "Server World ${worldID.toString()}" - val thread = Thread(spinner, str) + override val eventLoop = object : BlockableEventLoop("Server World $worldID") { + init { + isDaemon = true + } - init { - mailbox.thread = thread - } + override fun performShutdown() { + LOGGER.info("Shutting down ${this@ServerWorld}") - private val isClosed = AtomicBoolean() - - fun isClosed(): Boolean { - return isClosed.get() - } - - init { - thread.isDaemon = true - } - - fun pause() { - if (!isClosed.get()) spinner.pause() - } - - fun unpause() { - if (!isClosed.get()) spinner.unpause() - } - - override fun toString(): String { - if (isClosed.get()) - return "NULL $str" - else - return str - } - - override fun close() { - if (!isClosed.get()) - LOGGER.info("Shutting down $this") - - if (isClosed.compareAndSet(false, true)) { - server.notifyWorldUnloaded(worldID) - super.close() - spinner.unpause() + try { + server.notifyWorldUnloaded(worldID) + } catch (err: RejectedExecutionException) { + // do nothing + } chunkMap.chunks().forEach { it.cancelLoadJob() @@ -165,50 +143,25 @@ class ServerWorld private constructor( it.remove() it.client.enqueueWarp(WarpAlias.Return) } - - LockSupport.unpark(thread) } } + init { + eventLoop.scheduleAtFixedRate(::tick, 0L, Starbound.TIMESTEP_NANOS, TimeUnit.NANOSECONDS) + } + + val scope = CoroutineScope(eventLoop.asCoroutineDispatcher() + SupervisorJob()) + + override fun toString(): String { + return "Server World $worldID" + } + private var idleTicks = 0 private var isBusy = 0 - private fun spin(): Boolean { - if (isClosed.get()) return false - - try { - if (clients.isEmpty() && isBusy <= 0) { - idleTicks++ - } else { - idleTicks = 0 - } - - tick() - - if (idleTicks >= 600) { - if (shouldStopOnIdle) { - close() - return false - } else { - pause() - } - } - - return true - } catch (err: Throwable) { - LOGGER.fatal("Exception in world tick loop", err) - close() - return false - } - } - override val isRemote: Boolean get() = false - override fun isSameThread(): Boolean { - return Thread.currentThread() === thread - } - fun damageTiles(positions: Collection, isBackground: Boolean, sourcePosition: Vector2d, damage: TileDamage, source: AbstractEntity? = null): TileDamageResult { if (damage.amount <= 0.0) return TileDamageResult.NONE @@ -235,8 +188,7 @@ class ServerWorld private constructor( if (!damagedEntities.add(entity)) continue val occupySpaces = entity.occupySpaces.stream() - .map { geometry.wrap(it + entity.tilePosition) } - .filter { it in positions } + .filter { p -> actualPositions.any { it.first == p } } .toList() val broken = entity.damage(occupySpaces, sourcePosition, damage) @@ -276,12 +228,26 @@ class ServerWorld private constructor( } override fun tick() { - super.tick() + try { + if (clients.isEmpty() && isBusy <= 0) { + idleTicks++ + } else { + idleTicks = 0 + } - val packet = StepUpdatePacket(ticks) + if (idleTicks >= 600) { + if (shouldStopOnIdle) { + eventLoop.shutdown() + } - clients.forEach { - if (!isClosed.get()) { + return + } + + super.tick() + + val packet = StepUpdatePacket(ticks) + + clients.forEach { it.send(packet) try { @@ -291,6 +257,9 @@ class ServerWorld private constructor( //it.disconnect("Exception while ticking player: $err") } } + } catch (err: Throwable) { + LOGGER.fatal("Exception in world tick loop", err) + eventLoop.shutdown() } } @@ -316,9 +285,7 @@ class ServerWorld private constructor( // everything inside our own thread, not anywhere else // This way, external callers can properly wait for preparations to complete fun prepare(): CompletableFuture<*> { - return CompletableFuture.supplyAsync(Supplier { - scope.launch { prepare0() }.asCompletableFuture() - }, mailbox).thenCompose { it } + return scope.launch { prepare0() }.asCompletableFuture() } private suspend fun findPlayerStart(hint: Vector2d? = null): Vector2d { @@ -430,13 +397,13 @@ class ServerWorld private constructor( } fun temporaryChunkTicket(region: AABBi, time: Int, target: ServerChunk.State = ServerChunk.State.FULL): List { - require(time > 0) { "Invalid ticket time: $time" } + require(time >= 0) { "Invalid ticket time: $time" } return geometry.region2Chunks(region).map { temporaryChunkTicket(it, time, target) }.filterNotNull() } fun temporaryChunkTicket(region: AABB, time: Int, target: ServerChunk.State = ServerChunk.State.FULL): List { - require(time > 0) { "Invalid ticket time: $time" } + require(time >= 0) { "Invalid ticket time: $time" } return geometry.region2Chunks(region).map { temporaryChunkTicket(it, time, target) }.filterNotNull() } 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 c9b5e323..cbbc523f 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/ServerWorldTracker.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/server/world/ServerWorldTracker.kt @@ -289,7 +289,7 @@ class ServerWorldTracker(val world: ServerWorld, val client: ServerConnection, p // this handles case where player is removed from world and // instantly added back because new world rejected us - world.mailbox.execute { remove0() } + world.eventLoop.execute { remove0() } } } diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/util/BlockableEventLoop.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/util/BlockableEventLoop.kt new file mode 100644 index 00000000..d5e067b7 --- /dev/null +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/util/BlockableEventLoop.kt @@ -0,0 +1,450 @@ +package ru.dbotthepony.kstarbound.util + +import org.apache.logging.log4j.LogManager +import java.util.PriorityQueue +import java.util.concurrent.Callable +import java.util.concurrent.CompletableFuture +import java.util.concurrent.Delayed +import java.util.concurrent.Future +import java.util.concurrent.FutureTask +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit +import java.util.concurrent.locks.Condition +import java.util.concurrent.locks.LockSupport +import java.util.function.Supplier + +// I tried to make use of Netty's event loops, but they seem to be a bit overcomplicated +// if you try to use them by yourself :( +// so I made my own +open class BlockableEventLoop(name: String) : Thread(name), ScheduledExecutorService { + private class ScheduledTask(callable: Callable, var executeAt: Long, val repeat: Boolean, val timeDelay: Long, val isFixedDelay: Boolean) : FutureTask(callable), ScheduledFuture { + override fun compareTo(other: Delayed): Int { + return getDelay(TimeUnit.NANOSECONDS).compareTo(other.getDelay(TimeUnit.NANOSECONDS)) + } + + override fun getDelay(unit: TimeUnit): Long { + return executeAt - System.nanoTime() + } + + fun shouldEnqueue(): Boolean { + if (executeAt <= System.nanoTime()) + return perform() + + return true + } + + fun perform(): Boolean { + if (repeat) { + if (isFixedDelay) { + // fixed delay + + val deadlineMargin = executeAt - System.nanoTime() + + if (deadlineMargin <= -5_000_000_000L) { + if (!IS_IN_IDE) // since this will get spammed if debugging using breakpoints + LOGGER.warn("Event loop missed scheduled deadline by ${-deadlineMargin / 1_000_000L} milliseconds") + } + + runAndReset() + executeAt = System.nanoTime() + timeDelay + } else { + // fixed rate + val timeBefore = System.nanoTime() + var deadlineMargin = executeAt - System.nanoTime() + + if (deadlineMargin <= -5_000_000_000L) { + if (!IS_IN_IDE) // since this will get spammed if debugging using breakpoints + LOGGER.warn("Event loop missed scheduled deadline by ${-deadlineMargin / 1_000_000L} milliseconds, clamping to 5 seconds") + + deadlineMargin = -5_000_000_000L + } + + runAndReset() + val now = System.nanoTime() + executeAt = now + timeDelay + deadlineMargin - (now - timeBefore) + } + + return true + } else { + run() + return false + } + } + } + + private class TaskPair(val future: CompletableFuture, var supplier: Callable?) + + private val eventQueue = LinkedBlockingQueue>() + private val scheduledQueue = PriorityQueue>() + + private fun nextDeadline(): Long { + if (isShutdown) + return 0L + + val poll = scheduledQueue.peek() + + if (poll == null) { + return Long.MAX_VALUE + } else { + return poll.executeAt - System.nanoTime() + } + } + + @Volatile + private var isShutdown = false + + private var isRunning = true + + private fun eventLoopIteration(): Boolean { + var executedAnything = false + val next = eventQueue.poll(nextDeadline(), TimeUnit.NANOSECONDS) + + if (next != null) { + executedAnything = true + + try { + val callable = next.supplier + + if (callable != null) { + (next.future as CompletableFuture).complete(callable.call()) + } + } catch (err: Throwable) { + LOGGER.error("Error executing scheduled task", err) + + try { + next.future.completeExceptionally(err) + } catch (err: Throwable) { + LOGGER.error("Caught an exception while propagating CompletableFuture to completeExceptionally stage", err) + } + } + } + + if (scheduledQueue.isNotEmpty() && !isShutdown) { + val executed = ArrayList>() + var lastSize: Int + + do { + lastSize = executed.size + + while (scheduledQueue.isNotEmpty() && scheduledQueue.peek()!!.executeAt <= System.nanoTime() && !isShutdown) { + executedAnything = true + val poll = scheduledQueue.poll()!! + + if (poll.perform()) { + executed.add(poll) + } + } + } while (lastSize != executed.size && !isShutdown) + + scheduledQueue.addAll(executed) + } + + return executedAnything + } + + final override fun run() { + while (isRunning) { + eventLoopIteration() + + if (isShutdown && isRunning) { + while (eventLoopIteration()) {} + isRunning = false + performShutdown() + } + } + + LOGGER.info("Thread ${this.name} stopped gracefully") + } + + final override fun execute(command: Runnable) { + if (isShutdown) + throw RejectedExecutionException("EventLoop is shutting down") + + if (currentThread() === this) { + command.run() + } else { + val future = CompletableFuture() + val pair = TaskPair(future) { command.run() } + + future.exceptionally { + pair.supplier = null + } + + eventQueue.add(pair) + } + } + + final override fun submit(task: Callable): CompletableFuture { + if (isShutdown) + throw RejectedExecutionException("EventLoop is shutting down") + + if (currentThread() === this) { + try { + return CompletableFuture.completedFuture(task.call()) + } catch (err: Throwable) { + return CompletableFuture.failedFuture(err) + } + } else { + val future = CompletableFuture() + val pair = TaskPair(future, task) + + future.exceptionally { + pair.supplier = null + null + } + + eventQueue.add(pair) + return future + } + } + + fun supplyAsync(task: Supplier): CompletableFuture { + return submit(task::get) + } + + fun supplyAsync(task: () -> T): CompletableFuture { + return submit(task::invoke) + } + + fun ensureSameThread() { + check(this === currentThread()) { "Performing non-threadsafe operation outside of event loop thread" } + } + + fun isSameThread() = this === currentThread() + + final override fun submit(task: Runnable): CompletableFuture<*> { + if (isShutdown) + throw RejectedExecutionException("EventLoop is shutting down") + + if (currentThread() === this) { + try { + return CompletableFuture.completedFuture(task.run()) + } catch (err: Throwable) { + return CompletableFuture.failedFuture(err) + } + } else { + val future = CompletableFuture() + val pair = TaskPair(future) { task.run() } + + future.exceptionally { + pair.supplier = null + } + + eventQueue.add(pair) + return future + } + } + + final override fun submit(task: Runnable, result: T): CompletableFuture { + if (isShutdown) + throw RejectedExecutionException("EventLoop is shutting down") + + if (currentThread() === this) { + try { + task.run() + return CompletableFuture.completedFuture(result) + } catch (err: Throwable) { + return CompletableFuture.failedFuture(err) + } + } else { + val future = CompletableFuture() + val pair = TaskPair(future) { task.run(); result } + + future.exceptionally { + pair.supplier = null + null + } + + eventQueue.add(pair) + return future + } + } + + final override fun invokeAll(tasks: Collection>): List> { + if (isShutdown) + throw RejectedExecutionException("EventLoop is shutting down") + + return tasks.map { submit(it) } + } + + final override fun invokeAll(tasks: Collection>, timeout: Long, unit: TimeUnit): List> { + if (isShutdown) + throw RejectedExecutionException("EventLoop is shutting down") + + val futures = tasks.map { submit(it) } + CompletableFuture.allOf(*futures.toTypedArray()).get(timeout, unit) + return futures + } + + final override fun invokeAny(tasks: Collection>): T { + if (isShutdown) + throw RejectedExecutionException("EventLoop is shutting down") + + return submit(tasks.first()).get() + } + + final override fun invokeAny(tasks: Collection>, timeout: Long, unit: TimeUnit): T { + if (isShutdown) + throw RejectedExecutionException("EventLoop is shutting down") + + return submit(tasks.first()).get(timeout, unit) + } + + final override fun shutdown() { + if (!isShutdown) { + isShutdown = true + + if (currentThread() === this || state == State.NEW) { + while (eventLoopIteration()) {} + + while (scheduledQueue.isNotEmpty()) { + val remove = scheduledQueue.remove() + + try { + remove.cancel(false) + } catch (err: Throwable) { + LOGGER.warn("Caught exception while cancelling future during event loop shutdown", err) + } + } + + isRunning = false + performShutdown() + } else { + // wake up thread + eventQueue.add(TaskPair(CompletableFuture()) { }) + } + } + } + + protected open fun performShutdown() { + + } + + private fun shutdownNow0() { + while (eventQueue.isNotEmpty()) { + val remove = eventQueue.remove() + + try { + remove.future.cancel(false) + } catch (err: Throwable) { + LOGGER.warn("Caught exception while cancelling future during event loop shutdown", err) + } + } + + while (scheduledQueue.isNotEmpty()) { + val remove = scheduledQueue.remove() + + try { + remove.cancel(false) + } catch (err: Throwable) { + LOGGER.warn("Caught exception while cancelling future during event loop shutdown", err) + } + } + + isRunning = false + performShutdown() + } + + final override fun shutdownNow(): List { + if (!isShutdown) { + isShutdown = true + + if (currentThread() === this) { + shutdownNow0() + } else { + eventQueue.add(TaskPair(CompletableFuture()) { }) + } + } + + return emptyList() + } + + final override fun awaitTermination(timeout: Long, unit: TimeUnit): Boolean { + // lazy wait loop + var budget = TimeUnit.NANOSECONDS.convert(timeout, unit) + var origin = System.nanoTime() + + while (budget > 0L && isRunning) { + val new = System.nanoTime() + budget -= new - origin + origin = new + LockSupport.parkNanos(budget.coerceAtMost(500_000L)) + + if (interrupted()) { + return !isRunning + } + } + + return !isRunning + } + + final override fun isShutdown(): Boolean { + return isShutdown + } + + final override fun isTerminated(): Boolean { + return !isRunning + } + + final override fun schedule(command: Runnable, delay: Long, unit: TimeUnit): ScheduledFuture<*> { + val task = ScheduledTask({ command.run() }, System.nanoTime() + TimeUnit.NANOSECONDS.convert(delay, unit), false, 0L, false) + + execute { + if (task.shouldEnqueue()) + scheduledQueue.add(task) + } + + return task + } + + final override fun schedule(callable: Callable, delay: Long, unit: TimeUnit): ScheduledFuture { + val task = ScheduledTask(callable, System.nanoTime() + TimeUnit.NANOSECONDS.convert(delay, unit), false, 0L, false) + + execute { + if (task.shouldEnqueue()) + scheduledQueue.add(task) + } + + return task + } + + final override fun scheduleAtFixedRate( + command: Runnable, + initialDelay: Long, + period: Long, + unit: TimeUnit + ): ScheduledFuture<*> { + val task = ScheduledTask({ command.run() }, System.nanoTime() + TimeUnit.NANOSECONDS.convert(initialDelay, unit), true, TimeUnit.NANOSECONDS.convert(period, unit), false) + + execute { + if (task.shouldEnqueue()) + scheduledQueue.add(task) + } + + return task + } + + final override fun scheduleWithFixedDelay( + command: Runnable, + initialDelay: Long, + delay: Long, + unit: TimeUnit + ): ScheduledFuture<*> { + val task = ScheduledTask({ command.run() }, System.nanoTime() + TimeUnit.NANOSECONDS.convert(initialDelay, unit), true, TimeUnit.NANOSECONDS.convert(delay, unit), true) + + execute { + if (task.shouldEnqueue()) + scheduledQueue.add(task) + } + + return task + } + + companion object { + private val LOGGER = LogManager.getLogger() + private val IS_IN_IDE = java.lang.management.ManagementFactory.getRuntimeMXBean().inputArguments.toString().contains("-agentlib:jdwp") + } +} diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/util/HashTableInterner.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/util/HashTableInterner.kt index a6514d61..204860c0 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/util/HashTableInterner.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/util/HashTableInterner.kt @@ -3,10 +3,11 @@ package ru.dbotthepony.kstarbound.util import com.github.benmanes.caffeine.cache.Interner import it.unimi.dsi.fastutil.HashCommon import it.unimi.dsi.fastutil.objects.ObjectArrayList +import ru.dbotthepony.kstarbound.Starbound import ru.dbotthepony.kstarbound.stream import java.lang.ref.ReferenceQueue import java.lang.ref.WeakReference -import java.util.concurrent.locks.LockSupport +import java.util.concurrent.TimeUnit import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock import kotlin.math.log @@ -19,46 +20,40 @@ class HashTableInterner(private val segmentBits: Int = log(Runtime.getR companion object { private val interners = ArrayList>>() - private fun run() { - var wait = 1_000_000L - val minWait = 1_000_000L - val maxWait = 1_000_000_000L + private var wait = 1_000_000L + private const val minWait = 1_000_000L + private const val maxWait = 1_000_000_000L - while (true) { - var any = 0 + private fun cleanupCycle() { + var any = 0 - synchronized(interners) { - val i = interners.iterator() + synchronized(interners) { + val i = interners.iterator() - for (v in i) { - val get = v.get() + for (v in i) { + val get = v.get() - if (get == null) { - i.remove() - } else { - for (segment in get.segments) { - any += segment.cleanup() - } + if (get == null) { + i.remove() + } else { + for (segment in get.segments) { + any += segment.cleanup() } } } - - if (any != 0) { - wait = (wait - 1_000_000L * any).coerceAtLeast(minWait) - } else { - wait = (wait + 1_000_000L).coerceAtMost(maxWait) - } - - LockSupport.parkNanos(wait) } + + if (any != 0) { + wait = (wait - 1_000_000L * any).coerceAtLeast(minWait) + } else { + wait = (wait + 1_000_000L).coerceAtMost(maxWait) + } + + Starbound.schedule(::cleanupCycle, wait, TimeUnit.NANOSECONDS) } - private val thread = Thread(::run, "Interner Cleanup") - init { - thread.priority = 2 - thread.isDaemon = true - thread.start() + cleanupCycle() } } diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/util/random/RandomUtils.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/util/random/RandomUtils.kt index 82b221e0..0b22952f 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/util/random/RandomUtils.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/util/random/RandomUtils.kt @@ -83,6 +83,11 @@ fun staticRandomDouble(vararg values: Any): Double { return staticRandom64(*values).ushr(11) * 1.1102230246251565E-16 } +fun staticRandomInt(origin: Int, bound: Int, vararg values: Any): Int { + val rand = staticRandomDouble(*values) + return origin + ((bound - origin) * rand).toInt() +} + fun staticRandom64(vararg values: Any): Long { val digest = XXHash64(1997293021376312589L) diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/Chunk.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/Chunk.kt index 1360df53..ae60f69f 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/Chunk.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/Chunk.kt @@ -2,7 +2,9 @@ package ru.dbotthepony.kstarbound.world import ru.dbotthepony.kommons.arrays.Object2DArray 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.network.LegacyNetworkCellState import ru.dbotthepony.kstarbound.world.api.AbstractCell import ru.dbotthepony.kstarbound.world.api.ICellAccess @@ -52,7 +54,8 @@ abstract class Chunk, This : Chunk, This : Chunk RayFilterResult.CONTINUE } @@ -67,9 +67,9 @@ fun ICellAccess.castRay( val direction = (end - start).unitVector - var result = filter.test(cell, 0.0, cellPosX, cellPosY, Direction.NONE, start.x, start.y) - if (result.write) hitTiles.add(RayCastResult.HitCell(Vector2i(cellPosX, cellPosY), Direction.NONE, start, cell)) - if (result.hit) return RayCastResult(hitTiles, RayCastResult.HitCell(Vector2i(cellPosX, cellPosY), Direction.NONE, start, cell), 0.0, start, start, direction) + var result = filter.test(cell, 0.0, cellPosX, cellPosY, RayDirection.NONE, start.x, start.y) + if (result.write) hitTiles.add(RayCastResult.HitCell(Vector2i(cellPosX, cellPosY), RayDirection.NONE, start, cell)) + if (result.hit) return RayCastResult(hitTiles, RayCastResult.HitCell(Vector2i(cellPosX, cellPosY), RayDirection.NONE, start, cell), 0.0, start, start, direction) val distance = start.distance(end) var travelled = 0.0 @@ -79,8 +79,8 @@ fun ICellAccess.castRay( val stepX: Int val stepY: Int - val xNormal: Direction - val yNormal: Direction + val xNormal: RayDirection + val yNormal: RayDirection var rayLengthX: Double var rayLengthY: Double @@ -88,25 +88,25 @@ fun ICellAccess.castRay( if (direction.x < 0.0) { stepX = -1 rayLengthX = (start.x - cellPosX) * unitStepSizeX - xNormal = Direction.RIGHT + xNormal = RayDirection.RIGHT } else { stepX = 1 rayLengthX = (cellPosX - start.x + 1) * unitStepSizeX - xNormal = Direction.LEFT + xNormal = RayDirection.LEFT } if (direction.y < 0.0) { stepY = -1 rayLengthY = (start.y - cellPosY) * unitStepSizeY - yNormal = Direction.UP + yNormal = RayDirection.UP } else { stepY = 1 rayLengthY = (cellPosY - start.y + 1) * unitStepSizeY - yNormal = Direction.DOWN + yNormal = RayDirection.DOWN } while (travelled < distance) { - val normal: Direction + val normal: RayDirection if (rayLengthX < rayLengthY) { cellPosX += stepX diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/World.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/World.kt index 21764ce0..67cb078d 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/World.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/World.kt @@ -25,6 +25,7 @@ import ru.dbotthepony.kstarbound.json.mergeJson import ru.dbotthepony.kstarbound.math.* import ru.dbotthepony.kstarbound.network.IPacket import ru.dbotthepony.kstarbound.network.packets.clientbound.SetPlayerStartPacket +import ru.dbotthepony.kstarbound.util.BlockableEventLoop import ru.dbotthepony.kstarbound.util.ExceptionLogger import ru.dbotthepony.kstarbound.util.MailboxExecutorService import ru.dbotthepony.kstarbound.util.ParallelPerform @@ -49,10 +50,9 @@ import java.util.stream.Stream import kotlin.concurrent.withLock import kotlin.math.roundToInt -abstract class World, ChunkType : Chunk>(val template: WorldTemplate) : ICellAccess, Closeable { +abstract class World, ChunkType : Chunk>(val template: WorldTemplate) : ICellAccess { val background = TileView.Background(this) val foreground = TileView.Foreground(this) - val mailbox = MailboxExecutorService().also { it.exceptionHandler = ExceptionLogger(LOGGER) } val sky = Sky(template.skyParameters) val geometry: WorldGeometry = template.geometry @@ -270,15 +270,8 @@ abstract class World, ChunkType : Chunk, ChunkType : Chunk = Predicate { true }, distinct: Boolean = true): List { return entityIndex.query( AABBi(pos, pos + Vector2i.POSITIVE_XY), distinct = distinct, - filter = { it is TileEntity && (pos - it.tilePosition) in it.occupySpaces && filter.test(it) } + filter = { it is TileEntity && pos in it.occupySpaces && filter.test(it) } ) as List } diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/api/ICellAccess.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/api/ICellAccess.kt index 27183822..f3311063 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/api/ICellAccess.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/api/ICellAccess.kt @@ -20,5 +20,6 @@ interface ICellAccess { * whenever cell was set */ fun setCell(x: Int, y: Int, cell: AbstractCell): Boolean + fun setCell(pos: IStruct2i, cell: AbstractCell): Boolean = setCell(pos.component1(), pos.component2(), cell) } diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/api/MutableLiquidState.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/api/MutableLiquidState.kt index 1ff0be98..15631768 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/api/MutableLiquidState.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/api/MutableLiquidState.kt @@ -12,6 +12,13 @@ data class MutableLiquidState( override var pressure: Float = 0f, override var isInfinite: Boolean = false, ) : AbstractLiquidState() { + fun from(other: AbstractLiquidState) { + state = other.state + level = other.level + pressure = other.pressure + isInfinite = other.isInfinite + } + fun read(stream: DataInputStream): MutableLiquidState { state = Registries.liquid[stream.readUnsignedByte()] ?: BuiltinMetaMaterials.NO_LIQUID level = stream.readFloat() 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 d0b67172..6f0788cc 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/AbstractEntity.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/AbstractEntity.kt @@ -112,7 +112,7 @@ abstract class AbstractEntity(path: String) : JsonDriven(path), Comparable= (actorMovementParameters.minimumLiquidPercentage ?: 0.0) val liquidImpedance = liquidPercentage * (actorMovementParameters.liquidImpedance ?: 0.0) - var updatedMovingDirection: Direction1D? = null + var updatedMovingDirection: Direction? = null val isRunning = controlRun && !movementModifiers.runningSuppressed if (controlFly != null) { @@ -297,9 +296,9 @@ class ActorMovementController() : MovementController() { approachVelocity(flyVelocity * movementModifiers.speedModifier, movementParameters.airForce ?: 0.0) if (flyVelocity.x > 0.0) - updatedMovingDirection = Direction1D.RIGHT + updatedMovingDirection = Direction.RIGHT else if (flyVelocity.x < 0.0) - updatedMovingDirection = Direction1D.LEFT + updatedMovingDirection = Direction.LEFT groundMovementSustainTimer = GameTimer(0.0) } else { @@ -379,10 +378,10 @@ class ActorMovementController() : MovementController() { } if (controlMove == Direction.LEFT) { - updatedMovingDirection = Direction1D.LEFT + updatedMovingDirection = Direction.LEFT targetHorizontalAmbulatingVelocity = -1.0 * (if (isRunning) movementParameters.runSpeed ?: 0.0 else movementParameters.walkSpeed ?: 0.0) * movementModifiers.speedModifier } else if (controlMove == Direction.RIGHT) { - updatedMovingDirection = Direction1D.RIGHT + updatedMovingDirection = Direction.RIGHT targetHorizontalAmbulatingVelocity = 1.0 * (if (isRunning) movementParameters.runSpeed ?: 0.0 else movementParameters.walkSpeed ?: 0.0) * movementModifiers.speedModifier } diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/PathController.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/PathController.kt index 068d9164..8a0f5b00 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/PathController.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/PathController.kt @@ -1,7 +1,7 @@ package ru.dbotthepony.kstarbound.world.entities import ru.dbotthepony.kommons.vector.Vector2d -import ru.dbotthepony.kstarbound.world.Direction1D +import ru.dbotthepony.kstarbound.world.Direction import ru.dbotthepony.kstarbound.world.World class PathController(val world: World<*, *>, var edgeTimer: Double = 0.0) { @@ -9,7 +9,7 @@ class PathController(val world: World<*, *>, var edgeTimer: Double = 0.0) { private set var endPosition: Vector2d? = null private set - var controlFace: Direction1D? = null + var controlFace: Direction? = null private set fun reset() { diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/tile/TileEntity.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/tile/TileEntity.kt index 7d57be68..86e6c425 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/tile/TileEntity.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/tile/TileEntity.kt @@ -22,7 +22,7 @@ abstract class TileEntity(path: String) : AbstractEntity(path) { abstract val metaBoundingBox: AABB - private fun updateSpatialIndex() { + protected open fun updateSpatialIndex() { val spatialEntry = spatialEntry ?: return spatialEntry.fixture.move(metaBoundingBox + position) } @@ -57,7 +57,16 @@ abstract class TileEntity(path: String) : AbstractEntity(path) { override val position: Vector2d get() = Vector2d(xTilePosition.toDouble(), yTilePosition.toDouble()) + /** + * Tile positions this entity occupies in world (in world coordinates, not relative) + */ abstract val occupySpaces: Set + + /** + * Tile positions this entity is rooted in world (in world coordinates, not relative) + */ + abstract val roots: Set + abstract fun damage(damageSpaces: List, source: Vector2d, damage: TileDamage): Boolean override fun onJoinWorld(world: World<*, *>) { diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/tile/WorldObject.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/tile/WorldObject.kt index 02e943a9..1eefc045 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/tile/WorldObject.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/entities/tile/WorldObject.kt @@ -2,6 +2,7 @@ package ru.dbotthepony.kstarbound.world.entities.tile import com.google.common.collect.ImmutableList import com.google.common.collect.ImmutableMap +import com.google.common.collect.ImmutableSet import com.google.gson.JsonArray import com.google.gson.JsonElement import com.google.gson.JsonNull @@ -22,6 +23,7 @@ import ru.dbotthepony.kstarbound.defs.`object`.ObjectDefinition import ru.dbotthepony.kstarbound.defs.`object`.ObjectOrientation import ru.dbotthepony.kommons.gson.get import ru.dbotthepony.kommons.gson.set +import ru.dbotthepony.kommons.guava.immutableSet import ru.dbotthepony.kommons.io.RGBACodec import ru.dbotthepony.kommons.io.StreamCodec import ru.dbotthepony.kommons.io.Vector2iCodec @@ -32,7 +34,6 @@ import ru.dbotthepony.kommons.util.KOptional import ru.dbotthepony.kommons.util.getValue import ru.dbotthepony.kommons.util.setValue import ru.dbotthepony.kommons.vector.Vector2d -import ru.dbotthepony.kstarbound.client.world.ClientWorld import ru.dbotthepony.kstarbound.defs.DamageSource import ru.dbotthepony.kstarbound.defs.EntityType import ru.dbotthepony.kstarbound.defs.InteractAction @@ -250,7 +251,28 @@ open class WorldObject(val config: Registry.Entry) : TileEntit val drawables: List by drawablesCache - override val occupySpaces get() = orientation?.occupySpaces ?: setOf() + private val occupySpaces0 = LazyData { + (orientation?.occupySpaces ?: setOf()).stream().map { world.geometry.wrap(it + tilePosition) }.collect(ImmutableSet.toImmutableSet()) + } + + override val occupySpaces: ImmutableSet by occupySpaces0 + + override val roots: Set + get() = setOf() + + private val anchorPositions0 = LazyData { + immutableSet { + orientation?.anchors?.forEach { accept(it.pos + tilePosition) } + } + } + + val anchorPositions: ImmutableSet by anchorPositions0 + + override fun updateSpatialIndex() { + super.updateSpatialIndex() + occupySpaces0.invalidate() + anchorPositions0.invalidate() + } fun getRenderParam(key: String): String? { return localRenderKeys[key] ?: networkedRenderKeys[key] ?: "default" diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/physics/Poly.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/physics/Poly.kt index 973fa3c5..6db490d9 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/physics/Poly.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/physics/Poly.kt @@ -18,7 +18,6 @@ import ru.dbotthepony.kommons.vector.Vector2d import ru.dbotthepony.kstarbound.client.StarboundClient import ru.dbotthepony.kstarbound.client.gl.vertex.GeometryType import ru.dbotthepony.kommons.gson.consumeNull -import ru.dbotthepony.kommons.io.StreamCodec import ru.dbotthepony.kommons.io.readCollection import ru.dbotthepony.kommons.io.readVector2d import ru.dbotthepony.kommons.io.readVector2f @@ -31,6 +30,7 @@ import ru.dbotthepony.kstarbound.network.syncher.legacyCodec import ru.dbotthepony.kstarbound.network.syncher.nativeCodec import java.io.DataInputStream import java.io.DataOutputStream +import java.util.LinkedList import kotlin.math.absoluteValue import kotlin.math.cos import kotlin.math.sin @@ -245,7 +245,7 @@ class Poly private constructor(val edges: ImmutableList, val vertices: I return wn } - fun contains(point: IStruct2d): Boolean { + operator fun contains(point: IStruct2d): Boolean { return windingNumber(point) != 0 } @@ -433,5 +433,42 @@ class Poly private constructor(val edges: ImmutableList, val vertices: I return null } + + fun quickhull(points: Collection): Poly { + val sorted = ArrayList(points) + + sorted.sortWith { o1, o2 -> + val cmp = o1.x.compareTo(o2.x) + if (cmp == 0) o1.y.compareTo(o2.y) else cmp + } + + // calculates on which side b is lying on o->a line + fun cross(o: Vector2d, a: Vector2d, b: Vector2d): Double { + return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x) + } + + val lowerPoints = LinkedList() + val upperPoints = LinkedList() + + for (point in sorted) { + while (lowerPoints.size > 1 && cross(lowerPoints[lowerPoints.size - 2], lowerPoints[lowerPoints.size - 1], point) <= 0) + lowerPoints.removeLast() + + lowerPoints.add(point) + } + + for (point in sorted.asReversed()) { + while (upperPoints.size > 1 && cross(upperPoints[upperPoints.size - 2], upperPoints[upperPoints.size - 1], point) <= 0) + upperPoints.removeLast() + + upperPoints.add(point) + } + + lowerPoints.removeLast() + upperPoints.removeLast() + + lowerPoints.addAll(upperPoints) + return Poly(lowerPoints) + } } } diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/terrain/IslandSurfaceTerrainSelector.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/terrain/IslandSurfaceTerrainSelector.kt index 4b5583e9..c8967e54 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/terrain/IslandSurfaceTerrainSelector.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/terrain/IslandSurfaceTerrainSelector.kt @@ -90,9 +90,9 @@ class IslandSurfaceTerrainSelector(data: Data, parameters: TerrainSelectorParame } private val cache = Caffeine.newBuilder() - .maximumSize(2048L) - .executor(Starbound.EXECUTOR) - .scheduler(Scheduler.systemScheduler()) + .maximumSize(512L) + //.executor(Starbound.EXECUTOR) + //.scheduler(Starbound) .build(::compute) override fun get(x: Int, y: Int): Double { diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/terrain/KarstCaveTerrainSelector.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/terrain/KarstCaveTerrainSelector.kt index 8931aadf..41fd35c6 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/terrain/KarstCaveTerrainSelector.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/terrain/KarstCaveTerrainSelector.kt @@ -58,11 +58,11 @@ class KarstCaveTerrainSelector(data: Data, parameters: TerrainSelectorParameters } private val layers = Caffeine.newBuilder() - .maximumSize(2048L) + .maximumSize(256L) .softValues() - .expireAfterAccess(Duration.ofMinutes(5)) - .scheduler(Scheduler.systemScheduler()) - .executor(Starbound.EXECUTOR) + .expireAfterAccess(Duration.ofMinutes(2)) + //.scheduler(Starbound) + //.executor(Starbound.EXECUTOR) .build(::Layer) private inner class Sector(val sector: Vector2i) { @@ -127,11 +127,11 @@ class KarstCaveTerrainSelector(data: Data, parameters: TerrainSelectorParameters } private val sectors = Caffeine.newBuilder() - .maximumSize(2048L) + .maximumSize(64L) .softValues() - .expireAfterAccess(Duration.ofMinutes(5)) - .scheduler(Scheduler.systemScheduler()) - .executor(Starbound.EXECUTOR) + .expireAfterAccess(Duration.ofMinutes(2)) + //.scheduler(Starbound) + //.executor(Starbound.EXECUTOR) .build(::Sector) override fun get(x: Int, y: Int): Double { diff --git a/src/main/kotlin/ru/dbotthepony/kstarbound/world/terrain/WormCaveTerrainSelector.kt b/src/main/kotlin/ru/dbotthepony/kstarbound/world/terrain/WormCaveTerrainSelector.kt index 2f98ec28..2ea49ba2 100644 --- a/src/main/kotlin/ru/dbotthepony/kstarbound/world/terrain/WormCaveTerrainSelector.kt +++ b/src/main/kotlin/ru/dbotthepony/kstarbound/world/terrain/WormCaveTerrainSelector.kt @@ -178,11 +178,11 @@ class WormCaveTerrainSelector(data: Data, parameters: TerrainSelectorParameters) } private val sectors = Caffeine.newBuilder() - .maximumSize(2048L) + .maximumSize(64L) .softValues() - .expireAfterAccess(Duration.ofMinutes(5)) - .scheduler(Scheduler.systemScheduler()) - .executor(Starbound.EXECUTOR) + .expireAfterAccess(Duration.ofMinutes(2)) + //.scheduler(Starbound) + //.executor(Starbound.EXECUTOR) .build(::Sector) override fun get(x: Int, y: Int): Double { diff --git a/src/test/kotlin/ru/dbotthepony/kstarbound/test/MathTests.kt b/src/test/kotlin/ru/dbotthepony/kstarbound/test/MathTests.kt index a38d2ab7..c4a3bcf2 100644 --- a/src/test/kotlin/ru/dbotthepony/kstarbound/test/MathTests.kt +++ b/src/test/kotlin/ru/dbotthepony/kstarbound/test/MathTests.kt @@ -1,11 +1,18 @@ package ru.dbotthepony.kstarbound.test +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +import ru.dbotthepony.kommons.vector.Vector2d import ru.dbotthepony.kstarbound.math.* +import ru.dbotthepony.kstarbound.util.random.random import ru.dbotthepony.kstarbound.world.CHUNK_SIZE import ru.dbotthepony.kstarbound.world.CHUNK_SIZE_FF import ru.dbotthepony.kstarbound.world.ChunkPos +import ru.dbotthepony.kstarbound.world.physics.Poly +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin object MathTests { @Test @@ -47,4 +54,30 @@ object MathTests { check(roundTowardsPositiveInfinity(-1.1) == -1) check(roundTowardsPositiveInfinity(-1.6) == -1) } + + @Test + @DisplayName("Quickhull test") + fun quickhullTest() { + // this test is fragile since it is at discretion of "random" how it goes + val random = random(28383384L) + val vertices = ArrayList() + + for (i in 0 until 1000) { + val angle = random.nextDouble(PI * 2.0) + vertices.add(Vector2d(sin(angle), cos(angle))) + } + + val hull = Poly.quickhull(vertices) + + val testPoints = ArrayList() + + for (i in 0 until 1000) { + val angle = random.nextDouble(PI * 2.0) + testPoints.add(Vector2d(sin(angle) * 0.8, cos(angle) * 0.8)) + } + + for (point in testPoints) { + assertTrue(point in hull) + } + } }