KStarbound/src/main/kotlin/ru/dbotthepony/kstarbound/defs/dungeon/DungeonDefinition.kt

280 lines
9.2 KiB
Kotlin

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<JsonObject>,
) {
@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 {
tiles.spewWarnings(name)
}
private val file = AssetPathStack.lastFile()
val actualParts: ImmutableList<DungeonPart> 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<String, DungeonPart> by lazy {
actualParts.stream().map { it.name to it }.collect(ImmutableMap.toImmutableMap({ it.first }, { it.second }))
}
val anchorParts: ImmutableList<DungeonPart> 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<DungeonPart.JigsawConnector> {
val result = ArrayList<DungeonPart.JigsawConnector>()
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<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.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<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, 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<DungeonWorld> {
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()
}
}