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.JsonObject 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.kstarbound.math.vector.Vector2d import ru.dbotthepony.kstarbound.math.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.AssetPathStack import ru.dbotthepony.kstarbound.util.random.random import java.util.concurrent.CompletableFuture import java.util.random.RandomGenerator import kotlin.math.roundToInt import kotlin.math.sqrt // 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(), private val parts: ImmutableList, ) { @JsonFactory data class Metadata( val name: String, val displayName: String = "", val species: String = "", // why is it required to be present in original code? val protected: Boolean = false, val maxRadius: Double = 100.0, val maxParts: Int = 100, val extendSurfaceFreeSpace: Int = 0, val rules: ImmutableList = ImmutableList.of(), val anchor: ImmutableSet = ImmutableSet.of(), val gravity: Either? = null, val breathable: Boolean? = null, ) { init { require(maxRadius > 0.0) { "Non positive maxRadius. What are you trying to achieve?" } require(anchor.isNotEmpty()) { "No anchors are specified, dungeon won't be able to spawn in world" } } } val name: String get() = metadata.name init { tiles.spewWarnings(name) } private val file = AssetPathStack.lastFile() val actualParts: ImmutableList by lazy { AssetPathStack(file) { val build = parts.stream().map { Starbound.gson.fromJson(it, DungeonPart::class.java) }.collect(ImmutableList.toImmutableList()) build.forEach { it.bind(this) } for (anchor in metadata.anchor) { if (!build.any { it.name == anchor }) { throw JsonSyntaxException("Dungeon contains $anchor as anchor, but there is no such part") } } build } } val partMap: ImmutableMap by lazy { actualParts.stream().map { it.name to it }.collect(ImmutableMap.toImmutableMap({ it.first }, { it.second })) } val anchorParts: ImmutableList by lazy { metadata.anchor.stream().map { anchor -> actualParts.first { it.name == anchor } }.collect(ImmutableList.toImmutableList()) } private val expectedSize by lazy { (partMap.values.sumOf { it.reader.size.x.toDouble() * it.reader.size.y.toDouble() } / sqrt(partMap.size.toDouble())).roundToInt() } private fun connectableParts(connector: DungeonPart.JigsawConnector): List { val result = ArrayList() for (part in actualParts) { if (!part.doesNotConnectTo(connector.part)) { for (pconnector in part.connectors) { if (pconnector.connectsTo(connector)) { result.add(pconnector) } } } } return result } private fun choosePart(parts: MutableList, random: RandomGenerator): DungeonPart.JigsawConnector { val sum = parts.sumOf { it.part.chance } val sample = random.nextDouble(sum) var weighting = 0.0 val itr = parts.iterator() for (part in itr) { weighting += part.part.chance if (weighting >= sample) { itr.remove() return part } } return parts.removeLast() } fun validAnchors(world: ServerWorld): List { return anchorParts.filter { world.template.threatLevel in it.minimumThreatLevel .. it.maximumThreatLevel } } private suspend fun generate0(anchor: DungeonPart, world: DungeonWorld, x: Int, y: Int, forcePlacement: Boolean, dungeonID: Int) { val placementCounter = Object2IntArrayMap() val basePos = Vector2i(x, y) val openSet = ArrayDeque>() anchor.place(basePos, world, dungeonID) var piecesPlaced = 1 placementCounter[anchor.name] = 1 openSet.add(basePos to anchor) val origin = basePos + anchor.reader.size / 2 val closedConnectors = HashSet() while (openSet.isNotEmpty()) { val (parentPos, parent) = openSet.removeFirst() for (connector in parent.connectors) { val connectorPos = parentPos + connector.offset if (!closedConnectors.add(connectorPos)) continue val candidates = connectableParts(connector) .filter { world.parent.template.threatLevel in it.part.minimumThreatLevel .. it.part.maximumThreatLevel } .toMutableList() while (candidates.isNotEmpty()) { val candidate = choosePart(candidates, world.random) val partPos = connectorPos - candidate.offset + candidate.direction.positionAdjustment val optionPos = connectorPos + candidate.direction.positionAdjustment if (!candidate.part.ignoresPartMaximum) { if (piecesPlaced >= metadata.maxParts) { continue } if ((partPos - origin).length > metadata.maxRadius) { continue } } if (!candidate.part.allowsPlacement(placementCounter.getInt(candidate.part.name))) { continue } else if (!candidate.part.checkPartCombinationsAllowed(placementCounter)) { continue } else if (candidate.part.collidesWithPlaces(partPos.x, partPos.y, world)) { continue } if (forcePlacement || candidate.part.canPlace(partPos.x, partPos.y, world)) { candidate.part.place(partPos, world, dungeonID) piecesPlaced++ placementCounter[candidate.part.name] = placementCounter.getInt(candidate.part.name) + 1 closedConnectors.add(partPos) closedConnectors.add(optionPos) openSet.add(partPos to candidate.part) } } } } world.applyFinalTouches() } fun generate( world: ServerWorld, random: RandomGenerator, x: Int, y: Int, markSurfaceAndTerrain: Boolean, forcePlacement: Boolean, dungeonID: Int = 0, terrainSurfaceSpaceExtends: Int = 0, commit: Boolean = true, scope: CoroutineScope = Starbound.GLOBAL_SCOPE, ): CompletableFuture { require(dungeonID in 0 .. NO_DUNGEON_ID) { "Dungeon ID out of range: $dungeonID" } val dungeonWorld = DungeonWorld(world, random, if (markSurfaceAndTerrain) y else null, terrainSurfaceSpaceExtends, expectedSize = expectedSize) 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 scope.async { if (forcePlacement || anchor.canPlace(x, y - anchor.placementLevelConstraint, dungeonWorld)) { 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, scope: CoroutineScope = Starbound.GLOBAL_SCOPE, ): CompletableFuture { require(anchor in anchorParts) { "$anchor does not belong to $name" } val dungeonWorld = DungeonWorld(world, random, if (markSurfaceAndTerrain) y else null, terrainSurfaceSpaceExtends, expectedSize = expectedSize) return scope.async { generate0(anchor, dungeonWorld, x, y, forcePlacement, dungeonID) if (commit) { dungeonWorld.commit() } dungeonWorld }.asCompletableFuture() } companion object { private val LOGGER = LogManager.getLogger() } }