280 lines
9.2 KiB
Kotlin
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()
|
|
}
|
|
}
|