Dungeons generation (need optimizing), BlockableEventLoop (finally normal event loop)

This commit is contained in:
DBotThePony 2024-04-08 12:14:42 +07:00
parent 68f3d6aa29
commit 53bb3bd843
Signed by: DBot
GPG Key ID: DCC23B5715498507
61 changed files with 4842 additions and 768 deletions

View File

@ -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<TileDefinition> = BuiltinMetaMaterials.EMPTY.ref
val modifier: Registry.Ref<TileModifierDefinition> = 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

View File

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

View File

@ -44,7 +44,7 @@ operator fun <T> ThreadLocal<T>.setValue(thisRef: Any, property: KProperty<*>, v
set(value)
}
operator fun <K, V> ImmutableMap.Builder<K, V>.set(key: K, value: V): ImmutableMap.Builder<K, V> = put(key, value)
operator fun <K : Any, V : Any> ImmutableMap.Builder<K, V>.set(key: K, value: V): ImmutableMap.Builder<K, V> = put(key, value)
fun String.sintern(): String = Starbound.STRINGS.intern(this)

View File

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

View File

@ -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<TreeVariant.StemData>("tree stem variant").also(registriesInternal::add).also { adapters.add(it.adapter()) }
val treeFoliageVariants = Registry<TreeVariant.FoliageData>("tree foliage variant").also(registriesInternal::add).also { adapters.add(it.adapter()) }
val bushVariants = Registry<BushVariant.Data>("bush variant").also(registriesInternal::add).also { adapters.add(it.adapter()) }
val dungeons = Registry<DungeonDefinition>("dungeon").also(registriesInternal::add).also { adapters.add(it.adapter()) }
private fun <T> key(mapper: (T) -> String): (T) -> Pair<String, Int?> {
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()))

View File

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

View File

@ -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<ScissorRect>()
private val onDrawGUI = ArrayList<() -> Unit>()
private val onViewportChanged = ArrayList<(width: Int, height: Int) -> Unit>()
private val terminateCallbacks = ArrayList<() -> Unit>()
private val openglCleanQueue = ReferenceQueue<Any>()
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<Image, GLTexture2D> = 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<WeakReference<FontProgram>>()
private val uberShaderPrograms = ArrayList<WeakReference<UberShader>>()
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<GLFrameBuffer>(::glBindFramebuffer, GL_FRAMEBUFFER)
var program by GLObjectTracker<GLShaderProgram>(::glUseProgram)
val textures2D = GLTexturesTracker<GLTexture2D>(maxTextureBlocks)
var textures2D: GLTexturesTracker<GLTexture2D> by Delegates.notNull()
private set
var clearColor by GLGenericTracker<IStruct4f>(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<StarboundClient> {
val future = CompletableFuture<StarboundClient>()
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<StarboundClient>()

View File

@ -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<GLTexture2D, Config> = Caffeine.newBuilder()
.expireAfterAccess(Duration.ofMinutes(5))
.scheduler(Scheduler.systemScheduler())
.scheduler(Starbound)
.build()
private val background: Cache<GLTexture2D, Config> = Caffeine.newBuilder()
.expireAfterAccess(Duration.ofMinutes(5))
.scheduler(Scheduler.systemScheduler())
.scheduler(Starbound)
.build()
private val matCache: Cache<String, TileRenderer> = Caffeine.newBuilder()
.expireAfterAccess(Duration.ofMinutes(5))
.scheduler(Scheduler.systemScheduler())
.scheduler(Starbound)
.build()
private val modCache: Cache<String, TileRenderer> = Caffeine.newBuilder()
.expireAfterAccess(Duration.ofMinutes(5))
.scheduler(Scheduler.systemScheduler())
.scheduler(Starbound)
.build()
fun getMaterialRenderer(defName: String): TileRenderer {

View File

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

View File

@ -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<DungeonBrush>() {
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>) : 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<TileDefinition> = BuiltinMetaMaterials.EMPTY.ref,
val modifier: Registry.Ref<TileModifierDefinition> = 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<TileDefinition>, 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<TileDefinition> = BuiltinMetaMaterials.EMPTY.ref,
val modifier: Registry.Ref<TileModifierDefinition> = 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<TileModifierDefinition> = 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<ObjectDefinition>,
val direction: Direction = Direction.LEFT,
val parameters: JsonObject = JsonObject(),
) : DungeonBrush() {
constructor(obj: Registry.Ref<ObjectDefinition>, 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<TileModifierDefinition> = 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<LiquidDefinition>, 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()
}
}

View File

@ -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<ImmutableList<DungeonBrush>>() {})
}
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<Registry.Ref<TileDefinition>>() {})
}
private val adapter1 by lazy {
Starbound.gson.getAdapter(object : TypeToken<Registry.Ref<TileModifierDefinition>>() {})
}
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<Registry.Ref<TileDefinition>>() {})
}
private val adapter1 by lazy {
Starbound.gson.getAdapter(object : TypeToken<Registry.Ref<TileModifierDefinition>>() {})
}
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<Registry.Ref<ObjectDefinition>>() {})
}
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<Registry.Ref<LiquidDefinition>>() {})
}
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?
}

View File

@ -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<DungeonPart>,
) {
@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<DungeonRule> = ImmutableList.of(),
val anchor: ImmutableSet<String> = ImmutableSet.of(),
val gravity: Either<Vector2d, Double>? = 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<String, DungeonPart> = parts.stream().map { it.name to it }.collect(ImmutableMap.toImmutableMap({ it.first }, { it.second }))
val anchorParts: ImmutableList<DungeonPart> = metadata.anchor.stream().map { anchor -> parts.first { it.name == anchor } }.collect(ImmutableList.toImmutableList())
private fun connectableParts(connector: DungeonPart.JigsawConnector): List<DungeonPart.JigsawConnector> {
val result = ArrayList<DungeonPart.JigsawConnector>()
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<DungeonPart.JigsawConnector>, 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<DungeonPart> {
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<String>()
val basePos = Vector2i(x, y)
val openSet = ArrayDeque<Pair<Vector2i, DungeonPart>>()
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<Vector2i>()
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<DungeonWorld> {
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<DungeonWorld> {
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()
}
}

View File

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

View File

@ -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<DungeonRule>,
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<JigsawConnector> 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<DungeonDefinition>()
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<JigsawConnector>()
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<String, Int>): 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<Boolean> { 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<Boolean> { 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<Vector2i>()
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<DungeonPart>() {
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()
}
}

View File

@ -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<String, Int>): 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<String>) : 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<String>) : DungeonRule() {
constructor(json: JsonArray) : this(json[1].asJsonArray.stream().map { it.asString }.collect(ImmutableSet.toImmutableSet()))
override fun checkPartCombinationsAllowed(placements: Map<String, Int>): Boolean {
return placements.keys.none { it in parts }
}
override fun toString(): String {
return "Do not combine with $parts"
}
}
class Adapter(gson: Gson) : TypeAdapter<DungeonRule>() {
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()
}
}

View File

@ -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<DungeonBrush>,
val rules: ImmutableList<DungeonRule>,
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<DungeonBrush> = ImmutableList.of(),
val rules: ImmutableList<DungeonRule> = 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<Vector4i, String>,
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<DungeonTile>() {
private val objects = gson.getAdapter(JsonObject::class.java)
private val data = gson.getAdapter(BasicData::class.java)
private val values = gson.getAdapter<Either<Vector4i, String>>()
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<DungeonTile> = 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))
}
}

View File

@ -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<TileDefinition> = 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<TileModifierDefinition> = BuiltinMetaMaterials.EMPTY_MOD,
val hueShift: Float = 0f
) {
fun apply(to: MutableTileState) {
to.modifier = modifier
to.modifierHueShift = hueShift
}
}
data class PlacedObject(
val prototype: Registry.Entry<ObjectDefinition>,
val direction: Direction = Direction.LEFT,
val parameters: JsonObject = JsonObject()
)
private val liquid = HashMap<Vector2i, AbstractLiquidState>()
private val foregroundMaterial = HashMap<Vector2i, Material>()
private val foregroundModifier = HashMap<Vector2i, Modifier>()
private val backgroundMaterial = HashMap<Vector2i, Material>()
private val backgroundModifier = HashMap<Vector2i, Modifier>()
// for entity spaces which should be considered empty if they
// are occupied by tile entity
private val clearTileEntitiesAt = HashSet<Vector2i>()
// entities themselves to be removed
private val tileEntitiesToRemove = HashSet<TileEntity>()
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<Vector2i>()
private val protectTile = HashSet<Vector2i>()
private val boundingBoxes = ArrayList<AABBi>()
private var currentBoundingBox: AABBi? = null
fun touched(): Set<Vector2i> = 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<Vector2i>()
private val biomeTrees = HashSet<Vector2i>()
private val itemDrops = HashMap<Vector2i, ArrayList<ItemDescriptor>>()
private val randomizedItemDrops = HashMap<Vector2i, ArrayList<ItemDescriptor>>()
private val dungeonIDs = HashMap<Vector2i, Int>()
private var dungeonID = -1
private val pendingLiquids = HashMap<Vector2i, AbstractLiquidState>()
private val openLocalWires = HashMap<String, HashSet<Vector2i>>()
private val globalWires = HashMap<String, HashSet<Vector2i>>()
private val localWires = ArrayList<HashSet<Vector2i>>()
private val placedObjects = HashMap<Vector2i, PlacedObject>()
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<ObjectDefinition>, 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<TileDefinition>, hueShift: Float = 0f, color: TileColor = TileColor.DEFAULT) {
setForeground(x, y, Material(material, hueShift, color))
}
fun setForeground(x: Int, y: Int, modifier: Registry.Entry<TileModifierDefinition>, 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<TileDefinition>, hueShift: Float = 0f, color: TileColor = TileColor.DEFAULT) {
setBackground(x, y, Material(material, hueShift, color))
}
fun setBackground(x: Int, y: Int, modifier: Registry.Entry<TileModifierDefinition>, 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 <T> waitForRegion(region: AABBi, block: () -> T): T {
val tickets = ArrayList<ServerChunk.ITicket>()
return try {
tickets.addAll(parent.permanentChunkTicket(region, ServerChunk.State.TERRAIN))
tickets.forEach { it.chunk.await() }
block()
} finally {
tickets.forEach { it.cancel() }
}
}
suspend inline fun <T> waitForRegionAndJoin(region: AABBi, crossinline block: () -> T): T {
return waitForRegion(region) {
parent.eventLoop.supplyAsync { block() }.await()
}
}
suspend inline fun <T> waitForRegion(position: Vector2i, size: Vector2i, block: () -> T): T {
return waitForRegion(AABBi(position, position + size), block)
}
suspend inline fun <T> 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<Registry.Entry<LiquidDefinition>, HashSet<Vector2i>>()
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<Vector2i>()
val contiguousRegion = HashSet<Vector2i>()
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<ServerChunk.ITicket>()
try {
val terrainBlendingVertexes = ArrayList<Vector2d>()
val spaceBlendingVertexes = ArrayList<Vector2d>()
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<Vector2i>()
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<ArrayList<Vector2i>>()
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<ChunkPos>()
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)
}
}

View File

@ -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<Image>) : 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<DungeonTile>(images[it].width, images[it].height)
} as Array<Object2DArray<DungeonTile>>
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 <T> walkTiles(callback: TileCallback<T>): KOptional<T> {
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 <T> walkTilesAt(x: Int, y: Int, callback: TileCallback<T>): KOptional<T> {
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()
}
}

View File

@ -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<DungeonTile> = listOf()) {
private val mapping = Int2ObjectOpenHashMap<DungeonTile>()
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<ImageTileSet>() {
private val list = gson.getAdapter<ImmutableList<DungeonTile>>()
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()
}
}

View File

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

View File

@ -0,0 +1,24 @@
package ru.dbotthepony.kstarbound.defs.dungeon
import ru.dbotthepony.kommons.util.KOptional
abstract class TileMap {
fun interface TileCallback<T> {
operator fun invoke(x: Int, y: Int, tile: DungeonTile): KOptional<T>
}
fun interface TileCallback0 {
operator fun invoke(x: Int, y: Int, tile: DungeonTile)
}
abstract fun <T> walkTiles(callback: TileCallback<T>): KOptional<T>
abstract fun <T> walkTilesAt(x: Int, y: Int, callback: TileCallback<T>): KOptional<T>
fun iterateTiles(callback: TileCallback0) {
walkTiles<Unit> { x, y, tile -> callback(x, y, tile); KOptional() }
}
fun iterateTilesAt(x: Int, y: Int, callback: TileCallback0) {
walkTilesAt<Unit>(x, y) { x, y, tile -> callback(x, y, tile); KOptional() }
}
}

View File

@ -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<TiledTileSets.Entry>,
val layers: ImmutableList<JsonObject>,
)
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<ObjectLayer>
init {
val objectLayers = ArrayList<ObjectLayer>()
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 <T> walkTiles(callback: TileCallback<T>): KOptional<T> {
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 <T> walkTilesAt(x: Int, y: Int, callback: TileCallback<T>): KOptional<T> {
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 <T> walkTiles(callback: TileCallback<T>): KOptional<T> {
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 <T> walkTilesAt(x: Int, y: Int, callback: TileCallback<T>): KOptional<T> {
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<JsonObject, ImmutableList<OProperty>> = Either.left(JsonObject()),
val objects: ImmutableList<ObjectData>,
)
inner class ObjectLayer(data: ObjectLayerData) : TileMap() {
private val objects: ImmutableList<MapObject>
init {
val properties = data.properties.map({ it }, { p -> JsonObject().also { for ((k, v) in p) it[k] = v } })
val objects = ArrayList<MapObject>()
for (o in data.objects) {
objects.add(MapObject(properties, o))
}
this.objects = ImmutableList.copyOf(objects)
}
override fun <T> walkTiles(callback: TileCallback<T>): KOptional<T> {
for (o in objects) {
val result = o.walkTiles(callback)
if (result.isPresent) return result
}
return KOptional()
}
override fun <T> walkTilesAt(x: Int, y: Int, callback: TileCallback<T>): KOptional<T> {
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<JsonObject, ImmutableList<OProperty>> = 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<PolylineEntry>? = 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<Vector2i>
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 <T> walkTiles(callback: TileCallback<T>): KOptional<T> {
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 <T> walkTilesAt(x: Int, y: Int, callback: TileCallback<T>): KOptional<T> {
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
}
}

View File

@ -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<String>) : PartReader(part) {
val maps: ImmutableList<TiledMap> = 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 <T> walkTiles(callback: TileCallback<T>): KOptional<T> {
for (map in maps) {
val result = map.walkTiles(callback)
if (result.isPresent) return result
}
return KOptional()
}
override fun <T> walkTilesAt(x: Int, y: Int, callback: TileCallback<T>): KOptional<T> {
for (map in maps) {
val result = map.walkTilesAt(x, y, callback)
if (result.isPresent) return result
}
return KOptional()
}
}

View File

@ -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<Int, Pair<DungeonTile, JsonObject>>,
val back: ImmutableMap<Int, Pair<DungeonTile, JsonObject>>,
) {
@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<String, Either<TiledTileSet, Throwable>>()
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<TiledTileSet, Throwable> {
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<Int, Pair<DungeonTile, JsonObject>>()
val back = ImmutableMap.Builder<Int, Pair<DungeonTile, JsonObject>>()
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 })
}
}
}

View File

@ -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<Entry>) {
@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<Pair<DungeonTile, JsonObject>>()
private val back = Int2ObjectOpenHashMap<Pair<DungeonTile, JsonObject>>()
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()
}
}

View File

@ -63,7 +63,7 @@ class Image private constructor(
private val spritesInternal = LinkedHashMap<String, Sprite>()
private var dataRef: WeakReference<ByteBuffer>? = null
private val lock = ReentrantLock()
private val lock = Any()
//private val _texture = ThreadLocal<WeakReference<GLTexture2D>>()
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<IStarboundFile, ByteBuffer> { 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)

View File

@ -17,6 +17,12 @@ val Registry.Ref<TileDefinition>.isEmptyTile: Boolean
val Registry.Ref<TileDefinition>.isNullTile: Boolean
get() = entry == BuiltinMetaMaterials.NULL || entry == null
val Registry.Ref<TileDefinition>.orEmptyTile: Registry.Entry<TileDefinition>
get() = entry ?: BuiltinMetaMaterials.EMPTY
val Registry.Ref<TileDefinition>.orNullTile: Registry.Entry<TileDefinition>
get() = entry ?: BuiltinMetaMaterials.NULL
val Registry.Ref<TileDefinition>.isObjectSolidTile: Boolean
get() = entry == BuiltinMetaMaterials.OBJECT_SOLID
@ -26,9 +32,18 @@ val Registry.Ref<TileDefinition>.isObjectPlatformTile: Boolean
val Registry.Entry<TileDefinition>.isEmptyTile: Boolean
get() = this == BuiltinMetaMaterials.EMPTY || this == BuiltinMetaMaterials.NULL
val Registry.Entry<TileDefinition>.isRealTile: Boolean
get() = !value.isMeta
val Registry.Entry<TileDefinition>.isMetaTile: Boolean
get() = value.isMeta
val Registry.Entry<TileDefinition>.isNotEmptyTile: Boolean
get() = !isEmptyTile
val Registry.Entry<TileDefinition>.isObjectTile: Boolean
get() = this === BuiltinMetaMaterials.OBJECT_SOLID || this === BuiltinMetaMaterials.OBJECT_PLATFORM
val Registry.Entry<TileDefinition>.isNullTile: Boolean
get() = this == BuiltinMetaMaterials.NULL
@ -52,9 +67,24 @@ fun Registry.Entry<TileDefinition>.supportsModifier(modifier: Registry.Ref<TileM
val Registry.Entry<LiquidDefinition>.isEmptyLiquid: Boolean
get() = this == BuiltinMetaMaterials.NO_LIQUID
val Registry.Entry<LiquidDefinition>.isNotEmptyLiquid: Boolean
get() = !isEmptyLiquid
val Registry.Ref<LiquidDefinition>.isEmptyLiquid: Boolean
get() = entry == null || entry == BuiltinMetaMaterials.NO_LIQUID
val Registry.Ref<LiquidDefinition>.orEmptyLiquid: Registry.Entry<LiquidDefinition>
get() = entry ?: BuiltinMetaMaterials.NO_LIQUID
val Registry.Ref<LiquidDefinition>.isNotEmptyLiquid: Boolean
get() = !isEmptyLiquid
val Registry.Ref<TileModifierDefinition>.orEmptyModifier: Registry.Entry<TileModifierDefinition>
get() = entry ?: BuiltinMetaMaterials.EMPTY_MOD
val Registry.Ref<TileModifierDefinition>.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<Registry.Entry<TileDefinition>> = ImmutableList.of(
EMPTY,
NULL,
STRUCTURE,
BIOME,
BIOME1,
BIOME2,
BIOME3,
BIOME4,
BIOME5,
BOUNDARY,
OBJECT_SOLID,
OBJECT_PLATFORM,
)
val BIOME_META_MATERIALS: ImmutableList<Registry.Entry<TileDefinition>> = ImmutableList.of(BIOME, BIOME1, BIOME2, BIOME3, BIOME4, BIOME5)
val EMPTY_MOD = makeMod(65535, "none")
val BIOME_MOD = makeMod(65534, "biome")

View File

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

View File

@ -70,7 +70,18 @@ class WorldLayout {
val biomes = ListInterner<Biome>()
val playerStartSearchRegions = ArrayList<AABBi>()
val layers = ArrayList<Layer>()
var layers: MutableList<Layer> = 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
}

View File

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

View File

@ -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<CustomTerrainRegion>()
val threatLevel: Double
get() = worldParameters?.threatLevel ?: 0.0
private val customTerrainRegions = ArrayList<CustomTerrainRegion>()
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<BiomePlaceables.DistributionItem>,
val surfaceBiomeItems: List<BiomePlaceables.Placement>,
// ... Or on a cave surface.
val caveSurfaceBiomeItems: List<BiomePlaceables.DistributionItem>,
val caveSurfaceBiomeItems: List<BiomePlaceables.Placement>,
// ... Or on a cave ceiling.
val caveCeilingBiomeItems: List<BiomePlaceables.DistributionItem>,
val caveCeilingBiomeItems: List<BiomePlaceables.Placement>,
// ... Or on a cave background wall.
val caveBackgroundBiomeItems: List<BiomePlaceables.DistributionItem>,
val caveBackgroundBiomeItems: List<BiomePlaceables.Placement>,
// ... Or in the ocean
val oceanItems: List<BiomePlaceables.DistributionItem>,
val oceanItems: List<BiomePlaceables.Placement>,
)
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<BiomePlaceables.Placement> {
val thisBlock = cellInfo(geometry.x.cell(x), geometry.y.cell(y))
if (thisBlock.biomeTransition)
return emptyList()
val result = ArrayList<BiomePlaceables.Placement>()
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<TileDefinition> = BuiltinMetaMaterials.EMPTY.ref
var foregroundMod: Registry.Ref<TileModifierDefinition> = 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<Vector2i, CellInfo> { (x, y) -> cellInfo0(x, y) }
fun cellInfo(x: Int, y: Int): CellInfo {

View File

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

View File

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

View File

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

View File

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

View File

@ -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<WorldID, CompletableFuture<ServerWorld>>()
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<Vector3i, CompletableFuture<ServerSystemWorld>>()
@ -62,11 +62,11 @@ sealed class StarboundServer(val root: File) : Closeable {
}
fun loadSystemWorld(location: Vector3i): CompletableFuture<ServerSystemWorld> {
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<ServerWorld> {
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<ServerWorld> {
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<String>()
@ -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()
}

View File

@ -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<ServerWorld, ServerChunk>(world, pos) {
/**
@ -52,10 +70,10 @@ class ServerChunk(world: ServerWorld, pos: ChunkPos) : Chunk<ServerWorld, Server
enum class State {
FRESH, // Nothing is loaded
TILES,
TERRAIN,
MICRO_DUNGEONS,
CAVE_LIQUID,
FULL; // indicates everything has been loaded
FULL;
}
var state: State = State.FRESH
@ -107,14 +125,29 @@ class ServerChunk(world: ServerWorld, pos: ChunkPos) : Chunk<ServerWorld, Server
}
for (neighbour in neighbours) {
var i = 0
if (neighbour.chunk.isDone)
continue
while (!neighbour.chunk.isDone && ++i < 20) {
delay(500L)
}
suspendCancellableCoroutine<Unit> { 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<ServerWorld, Server
}
when (nextState) {
State.TILES -> {
// 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<ServerWorld, Server
// very good.
if (cells.isPresent) {
loadCells(cells.value)
bumpState(State.CAVE_LIQUID)
// bumping state while loading chunk might have
// undesired consequences, such as if chunk requester
// is pessimistic and want "fully loaded chunk or chunk generated least X stage"
// bumpState(State.CAVE_LIQUID)
world.storage.loadEntities(pos).await().ifPresent {
for (obj in it) {
@ -189,6 +239,8 @@ class ServerChunk(world: ServerWorld, pos: ChunkPos) : Chunk<ServerWorld, Server
} else {
LOGGER.error("Exception while loading chunk $this", err)
}
} finally {
isBusy = false
}
}
@ -199,7 +251,7 @@ class ServerChunk(world: ServerWorld, pos: ChunkPos) : Chunk<ServerWorld, Server
}
fun temporaryTicket(time: Int, target: State = State.FULL): ITimedTicket {
require(time > 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<ServerWorld, Server
final override val chunk = CompletableFuture<ServerChunk>()
init {
isBusy = true
if (this@ServerChunk.state >= targetState) {
chunk.complete(this@ServerChunk)
}
@ -520,7 +570,7 @@ class ServerChunk(world: ServerWorld, pos: ChunkPos) : Chunk<ServerWorld, Server
val unloadable = world.entityIndex
.query(
aabb,
filter = Predicate { it.isApplicableForUnloading && aabb.isInside(it.position) },
filter = Predicate { it.isApplicableForUnloading && aabbd.isInside(it.position) },
distinct = true, withEdges = false)
world.storage.saveCells(pos, copyCells())
@ -603,83 +653,215 @@ class ServerChunk(world: ServerWorld, pos: ChunkPos) : Chunk<ServerWorld, Server
}
}
private fun placeGrass() {
private fun finalizeCells() {
val cells = cells.value
for (x in 0 until width) {
for (y in 0 until height) {
placeGrass(x, y)
val cell = cells[x, y].mutable()
val info by lazy { world.template.cellInfo(pos.tileX + x, pos.tileY + y) }
if (cell.liquid.isInfinite) {
// make sure that ocean liquid never exists on tiles without empty
// background (except in real dungeons)
if (cell.dungeonId < FIRST_RESERVED_DUNGEON_ID && cell.background.material.isNotEmptyTile) {
cell.liquid.isInfinite = false
}
// pressurize liquid under the ocean
if (info.oceanLiquid.isNotEmptyLiquid && pos.tileY + y < info.oceanLiquidLevel) {
cell.liquid.pressure = cell.liquid.pressure.coerceAtLeast((info.oceanLiquidLevel - (pos.tileY + y)).toFloat())
}
}
if (cell.foreground.material.isMetaTile) {
cell.foreground.color = TileColor.DEFAULT
}
if (cell.background.material.isMetaTile) {
cell.background.color = TileColor.DEFAULT
}
replaceBiomeBlocks(cell, info)
cells[x, y] = cell.immutable()
}
}
}
private fun placeGrass(x: Int, y: Int) {
val biome = world.template.cellInfo(pos.tileX + x, pos.tileY + y).blockBiome ?: return
val cell = cells.value[x, y]
private fun doReplaceBiomeTile(tile: MutableTileState, biome: Biome?) {
// TODO: Maybe somehow expand this biome meta material list?
// that's only 6 meta blocks in total!
// determine layer for grass mod calculation
val isBackground = cell.foreground.material.isEmptyTile
when (tile.material) {
BuiltinMetaMaterials.BIOME -> 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<BiomePlaceables.Placement>()
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<Boolean> { 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<ServerWorld, Server
companion object {
private val LOGGER = LogManager.getLogger()
private val MICRODUNGEON_PLACEMENT_SHIFTS = immutableList {
accept(0)
for (i in 1 .. 4) accept(i)
for (i in 1 .. 4) accept(-i)
}
}
}

View File

@ -256,7 +256,7 @@ class ServerSystemWorld : SystemWorld {
// in original engine, ticking happens at 20 updates per second
// Since there is no Lua driven code, we can tick as fast as we want
suspend fun tick(delta: Double = Starbound.TIMESTEP) {
suspend fun tick(delta: Double = Starbound.SYSTEM_WORLD_TIMESTEP) {
var next = tasks.poll()
while (next != null) {
@ -299,7 +299,7 @@ class ServerSystemWorld : SystemWorld {
private val netVersions = Object2LongOpenHashMap<UUID>()
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

View File

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

View File

@ -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<ServerWorldTracker>()
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<Unit> {
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<IStruct2i>, 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<ServerChunk.ITimedTicket> {
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<ServerChunk.ITimedTicket> {
require(time > 0) { "Invalid ticket time: $time" }
require(time >= 0) { "Invalid ticket time: $time" }
return geometry.region2Chunks(region).map { temporaryChunkTicket(it, time, target) }.filterNotNull()
}

View File

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

View File

@ -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<T>(callable: Callable<T>, var executeAt: Long, val repeat: Boolean, val timeDelay: Long, val isFixedDelay: Boolean) : FutureTask<T>(callable), ScheduledFuture<T> {
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<T>(val future: CompletableFuture<T>, var supplier: Callable<T>?)
private val eventQueue = LinkedBlockingQueue<TaskPair<*>>()
private val scheduledQueue = PriorityQueue<ScheduledTask<*>>()
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<Any?>).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<ScheduledTask<*>>()
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<Unit>()
val pair = TaskPair(future) { command.run() }
future.exceptionally {
pair.supplier = null
}
eventQueue.add(pair)
}
}
final override fun <T> submit(task: Callable<T>): CompletableFuture<T> {
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<T>()
val pair = TaskPair(future, task)
future.exceptionally {
pair.supplier = null
null
}
eventQueue.add(pair)
return future
}
}
fun <T> supplyAsync(task: Supplier<T>): CompletableFuture<T> {
return submit(task::get)
}
fun <T> supplyAsync(task: () -> T): CompletableFuture<T> {
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<Unit>(err)
}
} else {
val future = CompletableFuture<Unit>()
val pair = TaskPair(future) { task.run() }
future.exceptionally {
pair.supplier = null
}
eventQueue.add(pair)
return future
}
}
final override fun <T> submit(task: Runnable, result: T): CompletableFuture<T> {
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<T>()
val pair = TaskPair(future) { task.run(); result }
future.exceptionally {
pair.supplier = null
null
}
eventQueue.add(pair)
return future
}
}
final override fun <T> invokeAll(tasks: Collection<Callable<T>>): List<Future<T>> {
if (isShutdown)
throw RejectedExecutionException("EventLoop is shutting down")
return tasks.map { submit(it) }
}
final override fun <T> invokeAll(tasks: Collection<Callable<T>>, timeout: Long, unit: TimeUnit): List<Future<T>> {
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 <T> invokeAny(tasks: Collection<Callable<T>>): T {
if (isShutdown)
throw RejectedExecutionException("EventLoop is shutting down")
return submit(tasks.first()).get()
}
final override fun <T> invokeAny(tasks: Collection<Callable<T>>, 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<Runnable> {
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 <V> schedule(callable: Callable<V>, delay: Long, unit: TimeUnit): ScheduledFuture<V> {
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")
}
}

View File

@ -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<T : Any>(private val segmentBits: Int = log(Runtime.getR
companion object {
private val interners = ArrayList<WeakReference<HashTableInterner<*>>>()
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()
}
}

View File

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

View File

@ -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<WorldType : World<WorldType, This>, This : Chunk<WorldType,
val worldBackgroundView = TileView.Background(worldView)
val worldForegroundView = TileView.Foreground(worldView)
val aabb = aabbBase + Vector2d(pos.x * CHUNK_SIZE.toDouble(), pos.y * CHUNK_SIZE.toDouble())
val aabb = AABBi(pos.tile, pos.tile + Vector2i(width, height))
val aabbd = aabb.toDoubleAABB()
// TODO: maybe fit them into "width" and "height" variables added recently?
protected val cells = lazy {
@ -158,11 +161,4 @@ abstract class Chunk<WorldType : World<WorldType, This>, This : Chunk<WorldType,
open fun tick() {
}
companion object {
private val aabbBase = AABB(
Vector2d.ZERO,
Vector2d(CHUNK_SIZE.toDouble(), CHUNK_SIZE.toDouble()),
)
}
}

View File

@ -1,17 +1,22 @@
package ru.dbotthepony.kstarbound.world
import com.google.gson.stream.JsonWriter
import ru.dbotthepony.kommons.io.StreamCodec
import ru.dbotthepony.kommons.vector.Vector2d
import ru.dbotthepony.kstarbound.json.builder.IStringSerializable
enum class Direction(val normal: Vector2d, override val jsonName: String) : IStringSerializable {
LEFT(Vector2d.NEGATIVE_X, "left"),
RIGHT(Vector2d.POSITIVE_X, "right"),
LEFT(Vector2d.NEGATIVE_X, "left") {
override val opposite: Direction
get() = RIGHT
},
RIGHT(Vector2d.POSITIVE_X, "right") {
override val opposite: Direction
get() = LEFT
};
UP(Vector2d.POSITIVE_Y, "up"),
DOWN(Vector2d.NEGATIVE_Y, "down"),
NONE(Vector2d.ZERO, "any");
abstract val opposite: Direction
operator fun unaryPlus() = opposite
override fun match(name: String): Boolean {
return name.lowercase() == jsonName
@ -21,16 +26,3 @@ enum class Direction(val normal: Vector2d, override val jsonName: String) : IStr
val CODEC = StreamCodec.Enum(Direction::class.java)
}
}
enum class Direction1D(val normal: Vector2d, override val jsonName: String) : IStringSerializable {
LEFT(Vector2d.NEGATIVE_X, "left"),
RIGHT(Vector2d.POSITIVE_X, "right");
override fun match(name: String): Boolean {
return name.lowercase() == jsonName
}
companion object {
val CODEC = StreamCodec.Enum(Direction1D::class.java)
}
}

View File

@ -0,0 +1,22 @@
package ru.dbotthepony.kstarbound.world
import ru.dbotthepony.kommons.io.StreamCodec
import ru.dbotthepony.kommons.vector.Vector2d
import ru.dbotthepony.kstarbound.json.builder.IStringSerializable
enum class RayDirection(val normal: Vector2d, override val jsonName: String) : IStringSerializable {
LEFT(Vector2d.NEGATIVE_X, "left"),
RIGHT(Vector2d.POSITIVE_X, "right"),
UP(Vector2d.POSITIVE_Y, "up"),
DOWN(Vector2d.NEGATIVE_Y, "down"),
NONE(Vector2d.ZERO, "any");
override fun match(name: String): Boolean {
return name.lowercase() == jsonName
}
companion object {
val CODEC = StreamCodec.Enum(RayDirection::class.java)
}
}

View File

@ -19,7 +19,7 @@ data class RayCastResult(
) {
constructor(startPos: Vector2d, direction: Vector2d) : this(listOf(), null, 0.0, startPos, startPos, direction)
data class HitCell(val pos: Vector2i, val normal: Direction, val borderCross: Vector2d, val cell: AbstractCell)
data class HitCell(val pos: Vector2i, val normal: RayDirection, val borderCross: Vector2d, val cell: AbstractCell)
}
enum class RayFilterResult(val hit: Boolean, val write: Boolean) {
@ -43,7 +43,7 @@ fun interface TileRayFilter {
/**
* [x] and [y] are wrapped around positions
*/
fun test(cell: AbstractCell, fraction: Double, x: Int, y: Int, normal: Direction, borderX: Double, borderY: Double): RayFilterResult
fun test(cell: AbstractCell, fraction: Double, x: Int, y: Int, normal: RayDirection, borderX: Double, borderY: Double): RayFilterResult
}
val NeverFilter = TileRayFilter { state, fraction, x, y, normal, borderX, borderY -> 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

View File

@ -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<This : World<This, ChunkType>, ChunkType : Chunk<This, ChunkType>>(val template: WorldTemplate) : ICellAccess, Closeable {
abstract class World<This : World<This, ChunkType>, ChunkType : Chunk<This, ChunkType>>(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<This : World<This, ChunkType>, ChunkType : Chunk<This, Chun
broadcast(SetPlayerStartPacket(position, respawnInWorld))
}
abstract fun isSameThread(): Boolean
fun ensureSameThread() {
check(isSameThread()) { "Trying to access $this from ${Thread.currentThread()}" }
}
open fun tick() {
ticks++
mailbox.executeQueuedTasks()
Starbound.EXECUTOR.submit(ParallelPerform(dynamicEntities.spliterator(), {
if (!it.isRemote) {
@ -286,29 +279,23 @@ abstract class World<This : World<This, ChunkType>, ChunkType : Chunk<This, Chun
}
})).join()
mailbox.executeQueuedTasks()
entities.values.forEach { it.tick() }
mailbox.executeQueuedTasks()
for (chunk in chunkMap.chunks())
chunk.tick()
mailbox.executeQueuedTasks()
sky.tick()
}
protected abstract fun chunkFactory(pos: ChunkPos): ChunkType
override fun close() {
mailbox.shutdownNow()
}
abstract val eventLoop: BlockableEventLoop
fun entitiesAtTile(pos: Vector2i, filter: Predicate<TileEntity> = Predicate { true }, distinct: Boolean = true): List<TileEntity> {
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<TileEntity>
}

View File

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

View File

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

View File

@ -112,7 +112,7 @@ abstract class AbstractEntity(path: String) : JsonDriven(path), Comparable<Abstr
if (entityID == 0)
entityID = world.nextEntityID.incrementAndGet()
world.ensureSameThread()
world.eventLoop.ensureSameThread()
check(!world.entities.containsKey(entityID)) { "Duplicate entity ID: $entityID" }
@ -127,7 +127,7 @@ abstract class AbstractEntity(path: String) : JsonDriven(path), Comparable<Abstr
fun remove(isDeath: Boolean = false) {
val world = innerWorld ?: throw IllegalStateException("Not in world")
world.ensureSameThread()
world.eventLoop.ensureSameThread()
mailbox.shutdownNow()
check(world.entities.remove(entityID) == this) { "Tried to remove $this from $world, but removed something else!" }

View File

@ -15,7 +15,6 @@ import ru.dbotthepony.kstarbound.network.syncher.networkedData
import ru.dbotthepony.kstarbound.network.syncher.networkedEnum
import ru.dbotthepony.kstarbound.util.GameTimer
import ru.dbotthepony.kstarbound.world.Direction
import ru.dbotthepony.kstarbound.world.Direction1D
import ru.dbotthepony.kstarbound.world.physics.CollisionType
import kotlin.math.PI
import kotlin.math.absoluteValue
@ -27,16 +26,16 @@ class ActorMovementController() : MovementController() {
var controlDown: Boolean = false
var lastControlDown: Boolean = false
var controlFly: Vector2d? = null
var controlFace: Direction1D? = null
var controlFace: Direction? = null
var isWalking: Boolean by networkGroup.add(networkedBoolean())
private set
var isRunning: Boolean by networkGroup.add(networkedBoolean())
private set
var movingDirection: Direction1D by networkGroup.add(networkedEnum(Direction1D.RIGHT))
var movingDirection: Direction by networkGroup.add(networkedEnum(Direction.RIGHT))
private set
var facingDirection: Direction1D by networkGroup.add(networkedEnum(Direction1D.RIGHT))
var facingDirection: Direction by networkGroup.add(networkedEnum(Direction.RIGHT))
private set
var isCrouching: Boolean by networkGroup.add(networkedBoolean())
@ -282,7 +281,7 @@ class ActorMovementController() : MovementController() {
isLiquidMovement = liquidPercentage >= (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
}

View File

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

View File

@ -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<Vector2i>
/**
* Tile positions this entity is rooted in world (in world coordinates, not relative)
*/
abstract val roots: Set<Vector2i>
abstract fun damage(damageSpaces: List<Vector2i>, source: Vector2d, damage: TileDamage): Boolean
override fun onJoinWorld(world: World<*, *>) {

View File

@ -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<ObjectDefinition>) : TileEntit
val drawables: List<Drawable> 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<Vector2i> by occupySpaces0
override val roots: Set<Vector2i>
get() = setOf()
private val anchorPositions0 = LazyData {
immutableSet {
orientation?.anchors?.forEach { accept(it.pos + tilePosition) }
}
}
val anchorPositions: ImmutableSet<Vector2i> by anchorPositions0
override fun updateSpatialIndex() {
super.updateSpatialIndex()
occupySpaces0.invalidate()
anchorPositions0.invalidate()
}
fun getRenderParam(key: String): String? {
return localRenderKeys[key] ?: networkedRenderKeys[key] ?: "default"

View File

@ -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<Line2d>, 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<Line2d>, val vertices: I
return null
}
fun quickhull(points: Collection<Vector2d>): 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<Vector2d>()
val upperPoints = LinkedList<Vector2d>()
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)
}
}
}

View File

@ -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<Int, Column>(::compute)
override fun get(x: Int, y: Int): Double {

View File

@ -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<Int, Layer>(::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<Vector2i, Sector>(::Sector)
override fun get(x: Int, y: Int): Double {

View File

@ -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<Vector2i, Sector>(::Sector)
override fun get(x: Int, y: Int): Double {

View File

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