diff --git a/build.gradle.kts b/build.gradle.kts index 8f67426..594f636 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -22,7 +22,7 @@ val archives_version = "$mod_version+mc$minecraft_version-fabric" repositories { mavenCentral() - maven("https://jitpack.io") // MixinExtras, Fabric ASM + maven("https://jitpack.io") // MixinExtras, Fabric ASM, BlueMap API maven("https://maven.jamieswhiteshirt.com/libs-release") // Reach Entity Attributes maven("https://mvn.devos.one/snapshots/") // Create Fabric maven("https://api.modrinth.com/maven") // LazyDFU @@ -56,8 +56,10 @@ dependencies { shadowDep("io.ktor:ktor-server-core-jvm:$ktor_version") shadowDep("io.ktor:ktor-server-cio-jvm:$ktor_version") shadowDep("io.ktor:ktor-server-cors-jvm:$ktor_version") - shadowDep("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlin_json_version") shadowDep("org.jetbrains.kotlin-wrappers:kotlin-css:$kotlin_css_version") + + compileOnly("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlin_json_version") + compileOnly("com.github.BlueMap-Minecraft:BlueMapAPI:v2.5.1") } val targetJavaVersion = 17 diff --git a/src/main/kotlin/littlechasiu/ctm/BlueMapIntegration.kt b/src/main/kotlin/littlechasiu/ctm/BlueMapIntegration.kt new file mode 100644 index 0000000..d6e5c49 --- /dev/null +++ b/src/main/kotlin/littlechasiu/ctm/BlueMapIntegration.kt @@ -0,0 +1,539 @@ +package littlechasiu.ctm + +import com.flowpowered.math.vector.Vector3d +import com.flowpowered.math.vector.Vector3l +import com.simibubi.create.content.trains.signal.SignalBlockEntity +import de.bluecolored.bluemap.api.BlueMapAPI +import de.bluecolored.bluemap.api.BlueMapMap +import de.bluecolored.bluemap.api.markers.LineMarker +import de.bluecolored.bluemap.api.markers.MarkerSet +import de.bluecolored.bluemap.api.markers.POIMarker +import de.bluecolored.bluemap.api.markers.ShapeMarker +import de.bluecolored.bluemap.api.math.Color +import de.bluecolored.bluemap.api.math.Line +import de.bluecolored.bluemap.api.math.Shape +import littlechasiu.ctm.model.* +import kotlin.math.ceil +import kotlin.math.pow +import kotlin.math.roundToInt + +object BlueMapIntegration { + var mapStyle = MapStyle() + + private const val BLUEMAP_TRACK_CURVE_POINTS = 10 + + private const val BLUEMAP_TRACK_LABEL = "Create Track" + private const val BLUEMAP_TRACK_ID = "create-track" + private const val BLUEMAP_STATION_LABEL = "Create Station" + private const val BLUEMAP_STATION_ID = "create-station" + private const val BLUEMAP_TRAIN_LABEL = "Create Train" + private const val BLUEMAP_TRAIN_ID = "create-train" + private const val BLUEMAP_SIGNAL_LABEL = "Create Signal" + private const val BLUEMAP_SIGNAL_ID = "create-signal" + + private val CSS_NAMED_COLORS = mapOf( + "black" to "#000000", + "silver" to "#c0c0c0", + "gray" to "#808080", + "white" to "#ffffff", + "maroon" to "#800000", + "red" to "#ff0000", + "purple" to "#800080", + "fuchsia" to "#ff00ff", + "green" to "#008000", + "lime" to "#00ff00", + "olive" to "#808000", + "yellow" to "#ffff00", + "navy" to "#000080", + "blue" to "#0000ff", + "teal" to "#008080", + "aqua" to "#00ffff", + "aliceblue" to "#f0f8ff", + "antiquewhite" to "#faebd7", + "aquamarine" to "#7fffd4", + "azure" to "#f0ffff", + "beige" to "#f5f5dc", + "bisque" to "#ffe4c4", + "blanchedalmond" to "#ffebcd", + "blueviolet" to "#8a2be2", + "brown" to "#a52a2a", + "burlywood" to "#deb887", + "cadetblue" to "#5f9ea0", + "chartreuse" to "#7fff00", + "chocolate" to "#d2691e", + "coral" to "#ff7f50", + "cornflowerblue" to "#6495ed", + "cornsilk" to "#fff8dc", + "crimson" to "#dc143c", + "cyan" to "#00ffff", + "darkblue" to "#00008b", + "darkcyan" to "#008b8b", + "darkgoldenrod" to "#b8860b", + "darkgray" to "#a9a9a9", + "darkgreen" to "#006400", + "darkgrey" to "#a9a9a9", + "darkkhaki" to "#bdb76b", + "darkmagenta" to "#8b008b", + "darkolivegreen" to "#556b2f", + "darkorange" to "#ff8c00", + "darkorchid" to "#9932cc", + "darkred" to "#8b0000", + "darksalmon" to "#e9967a", + "darkseagreen" to "#8fbc8f", + "darkslateblue" to "#483d8b", + "darkslategray" to "#2f4f4f", + "darkslategrey" to "#2f4f4f", + "darkturquoise" to "#00ced1", + "darkviolet" to "#9400d3", + "deeppink" to "#ff1493", + "deepskyblue" to "#00bfff", + "dimgray" to "#696969", + "dimgrey" to "#696969", + "dodgerblue" to "#1e90ff", + "firebrick" to "#b22222", + "floralwhite" to "#fffaf0", + "forestgreen" to "#228b22", + "gainsboro" to "#dcdcdc", + "ghostwhite" to "#f8f8ff", + "gold" to "#ffd700", + "goldenrod" to "#daa520", + "greenyellow" to "#adff2f", + "grey" to "#808080", + "honeydew" to "#f0fff0", + "hotpink" to "#ff69b4", + "indianred" to "#cd5c5c", + "indigo" to "#4b0082", + "ivory" to "#fffff0", + "khaki" to "#f0e68c", + "lavender" to "#e6e6fa", + "lavenderblush" to "#fff0f5", + "lawngreen" to "#7cfc00", + "lemonchiffon" to "#fffacd", + "lightblue" to "#add8e6", + "lightcoral" to "#f08080", + "lightcyan" to "#e0ffff", + "lightgoldenrodyellow" to "#fafad2", + "lightgray" to "#d3d3d3", + "lightgreen" to "#90ee90", + "lightgrey" to "#d3d3d3", + "lightpink" to "#ffb6c1", + "lightsalmon" to "#ffa07a", + "lightseagreen" to "#20b2aa", + "lightskyblue" to "#87cefa", + "lightslategray" to "#778899", + "lightslategrey" to "#778899", + "lightsteelblue" to "#b0c4de", + "lightyellow" to "#ffffe0", + "limegreen" to "#32cd32", + "linen" to "#faf0e6", + "magenta" to "#ff00ff", + "mediumaquamarine" to "#66cdaa", + "mediumblue" to "#0000cd", + "mediumorchid" to "#ba55d3", + "mediumpurple" to "#9370db", + "mediumseagreen" to "#3cb371", + "mediumslateblue" to "#7b68ee", + "mediumspringgreen" to "#00fa9a", + "mediumturquoise" to "#48d1cc", + "mediumvioletred" to "#c71585", + "midnightblue" to "#191970", + "mintcream" to "#f5fffa", + "mistyrose" to "#ffe4e1", + "moccasin" to "#ffe4b5", + "navajowhite" to "#ffdead", + "oldlace" to "#fdf5e6", + "olivedrab" to "#6b8e23", + "orange" to "#ffa500", + "orangered" to "#ff4500", + "orchid" to "#da70d6", + "palegoldenrod" to "#eee8aa", + "palegreen" to "#98fb98", + "paleturquoise" to "#afeeee", + "palevioletred" to "#db7093", + "papayawhip" to "#ffefd5", + "peachpuff" to "#ffdab9", + "peru" to "#cd853f", + "pink" to "#ffc0cb", + "plum" to "#dda0dd", + "powderblue" to "#b0e0e6", + "rebeccapurple" to "#663399", + "rosybrown" to "#bc8f8f", + "royalblue" to "#4169e1", + "saddlebrown" to "#8b4513", + "salmon" to "#fa8072", + "sandybrown" to "#f4a460", + "seagreen" to "#2e8b57", + "seashell" to "#fff5ee", + "sienna" to "#a0522d", + "skyblue" to "#87ceeb", + "slateblue" to "#6a5acd", + "slategray" to "#708090", + "slategrey" to "#708090", + "snow" to "#fffafa", + "springgreen" to "#00ff7f", + "steelblue" to "#4682b4", + "tan" to "#d2b48c", + "thistle" to "#d8bfd8", + "tomato" to "#ff6347", + "transparent" to "#0000", + "turquoise" to "#40e0d0", + "violet" to "#ee82ee", + "wheat" to "#f5deb3", + "whitesmoke" to "#f5f5f5", + "yellowgreen" to "#9acd32", + ) + + class BlueMapLine(points: List, yOffset: Double = 1.0) { + private val mutablePath: MutableList = mutableListOf() + private lateinit var longPath: List + + val path: List = mutablePath + var tomb = false + + init { + mutablePath.addAll(when (points.size) { + 4 -> bezier(points, yOffset) + 2 -> points.asSequence().map { Vector3d(it.x, it.y + yOffset, it.z) } + else -> throw IllegalArgumentException("unsupported path type") + }) + updateLongPath() + } + + fun merge(line: BlueMapLine): Boolean { + if (tomb || line.tomb || path == line.path || path.last() != line.path.first()) return false + // if both lines are heading to the same direction, we can omit the midpoint when joining + if (unitVector(path[path.size - 2], path[path.size - 1]) == unitVector(line.path[0], line.path[1])) { + mutablePath.removeLast() + } + mutablePath.addAll(line.path) + line.tomb = true + updateLongPath() + return true + } + + private fun updateLongPath() { + longPath = path.map { it.mul(10000.0).toLong() } + } + + private fun unitVector(a: Vector3d, b: Vector3d): Vector3d { + return a.sub(b).normalize() + } + + private fun bezier(points: List, yOffset: Double): Sequence { + // https://denisrizov.com/2016/06/02/bezier-curves-unity-package-included/ + val vecPoints = points.map { Vector3d(it.x, it.y + yOffset, it.z) } + return (0..BLUEMAP_TRACK_CURVE_POINTS) + .asSequence() + .map { it.toFloat() / BLUEMAP_TRACK_CURVE_POINTS } + .map { t -> + val u = 1.0 - t + val t2 = t * t + val u2 = u * u + val t3 = t2 * t + val u3 = u2 * u + (vecPoints[0].mul(u3)) + .add(vecPoints[1].mul(3.0 * u2 * t)) + .add(vecPoints[2].mul(3.0 * u * t2)) + .add(vecPoints[3].mul(t3)) + .mul(1000.0).round().div(1000.0) // truncate to 3 decimal places + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as BlueMapLine + + return longPath == other.longPath + } + + override fun hashCode(): Int { + return longPath.hashCode() + } + } + + private fun getMarkerSet(map: BlueMapMap, id: String, label: String): MarkerSet { + val mapMarkerSet = map.markerSets[id] + val markerSet: MarkerSet + + if (mapMarkerSet != null) { + markerSet = mapMarkerSet + } else { + markerSet = MarkerSet + .builder() + .label(label) + .build() + map.markerSets[id] = markerSet + } + + return markerSet + } + + private fun htmlEscape(str: String): String { + return str + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'") + } + + private fun getDefault(value: T?, default: T): T { + if (value == null) return default + return value + } + + private fun getCssColor(cssColor: String): String { + if (cssColor[0] == '#') { + return cssColor + } + + return CSS_NAMED_COLORS.getOrDefault(cssColor, "#000") + } + + private fun mergeLines(lines: MutableList, threshold: Double) { + val points = lines.groupByTo(mutableMapOf()) { it.path[0] } + var merged = false + for (line in lines) { + val adj = points[line.path.last()] ?: continue + for (i in 0 .. (adj.size * threshold).roundToInt()) { + val key = line.path.last() + val adjacentLines = points[key] ?: break + val mergedLine = adjacentLines.indexOfFirst { if (line.merge(it)) { merged = true; true } else false } + if (mergedLine != -1) { + adjacentLines.removeAt(mergedLine) + if (adjacentLines.isEmpty()) points.remove(key) + } else { + break + } + } + } + if (merged) { + lines.removeIf { it.tomb } + } + } + + private fun updateTracks(blueMap: BlueMapAPI, tracks: List) { + val dimensions = tracks.groupBy { it.dimension } + dimensions.forEach { (dimension, edges) -> + blueMap.getWorld(dimension).ifPresent { world -> + // CTM tracks has tiny, overlapping segments, sometimes in both directions, which slows down BlueMap + // We normalize all the lines in one direction, filter out points due to precision issues, render + // Bézier curves into straight lines, sort them based on starting / ending points and merge them. + // FIXME: severe Z-fighting due to large amount of overlapping segments + val lines = edges + .asSequence() + .map { edge -> + // normalize paths to point in +ve + val first = edge.path.first() + val last = edge.path.last() + if (first.x > last.x || first.y > last.y || first.z > last.z) { + BlueMapLine(edge.path.asReversed()) + } else { + BlueMapLine(edge.path) + } + } + .distinct() + .toMutableList() + + // your mileage may vary + // We try to merge less lines as the input size increases, in order to prevent completely deadlocking the server + val threshold = 0.999.pow((lines.size - 1).toDouble()) + if (threshold > 0.2) { + // if the threshold is too low, it's unlikely for the algorithm is merge anything at all, so don't bother + // for context, a threshold of 0.2 is going to attempt to merge 1 out of 3 adjacent nodes + lines.sortWith(compareBy({ it.path[0].x }, { it.path[0].y }, { it.path[0].z })) + mergeLines(lines, threshold) + } + + + for (line in lines) { + val markerBuilder = LineMarker + .builder() + .label(BLUEMAP_TRACK_LABEL) + .lineWidth(2) + .line(Line(line.path)) + + for (map in world.maps) { + val markerSet = getMarkerSet(map, BLUEMAP_TRACK_ID, BLUEMAP_TRACK_LABEL) + markerSet.markers[BLUEMAP_TRACK_ID + "-" + markerSet.markers.size] = markerBuilder.build() + } + } + } + } + } + + private fun updateStation(blueMap: BlueMapAPI, station: Station) { + val scheduleHtml = "
    " + + station.summary.joinToString(separator = "") { + var time: String + + if (it.ticks == -1 || it.ticks >= 12000 - 15 * 20) { + time = "later" + } else if (it.ticks < 200) { + time = "now" + } else { + time = "in " + + var min = it.ticks.floorDiv(1200) + var sec = it.ticks.floorDiv(20) % 60 + sec = ceil(sec / 15f).toInt() * 15 + + if (sec == 60) { + min += 1 + sec = 0 + } + + time += if (min > 0) min else sec + time += if (min > 0) "mins" else "secs" + } + + "
  1. Due: $time, Train: ${htmlEscape(it.trainName)}, Destination: ${htmlEscape(it.scheduleTitle)}
  2. " + } + + "
" + + val marker = POIMarker + .builder() + .label(station.name) + .detail( + "
" + + "
" + htmlEscape(station.name) + "
" + + "
Facing: " + + "" + + "
" + + scheduleHtml + + "
" + ) + .position(station.location.x, station.location.y, station.location.z) + .build() + + blueMap.getWorld(station.dimension).ifPresent { world -> + for (map in world.maps) { + val markerSet = getMarkerSet(map, BLUEMAP_STATION_ID, BLUEMAP_STATION_LABEL) + markerSet.markers[BLUEMAP_STATION_ID + "-" + markerSet.markers.size] = marker + } + } + } + + private fun updateTrain(blueMap: BlueMapAPI, train: CreateTrain) { + for ((i, car) in train.cars.withIndex()) { + val pt0 = Vector3d( + getDefault(car.leading?.location?.x, 0.0), + getDefault(car.leading?.location?.y, 0.0), + getDefault(car.leading?.location?.z, 0.0) + ) + val pt1 = Vector3d( + getDefault(car.trailing?.location?.x, 0.0), + getDefault(car.trailing?.location?.y, 0.0), + getDefault(car.trailing?.location?.z, 0.0) + ) + + val scheduleHtml = + if (train.schedule != null) + "
    " + + train.schedule.entries.mapIndexed { entryIndex, entry -> + var entryHtml = "" + val style = if (train.schedule.currentEntry == entryIndex) "" else "list-style-type: none" + + if (entry.instruction.destination != null) entryHtml += htmlEscape(entry.instruction.destination) + + if (entryHtml == "") return@mapIndexed "" + "
  • $entryHtml
  • " + }.joinToString(separator = "") + + "
" + else + "" + + val marker = LineMarker + .builder() + .label( + if (train.cars.size == 1) + train.name + else + "${train.name} (${i + 1})" + ) + .detail("
${htmlEscape(train.name)} (${i + 1})
$scheduleHtml") + .lineColor(Color(getCssColor(mapStyle.colors.train))) + .lineWidth(12) + .line(Line(pt0, pt1)) + .build() + + blueMap.getWorld(getDefault(car.leading?.dimension, "minecraft:overworld")).ifPresent { world -> + for (map in world.maps) { + val markerSet = getMarkerSet(map, BLUEMAP_TRAIN_ID, BLUEMAP_TRAIN_LABEL) + markerSet.markers[BLUEMAP_TRAIN_ID + "-" + markerSet.markers.size] = marker + } + } + } + } + + private fun updateSignal(blueMap: BlueMapAPI, signal: Signal) { + val shape = Shape.createCircle(signal.location.x, signal.location.z, 1.0, 50) + val markerBuilder = ShapeMarker + .builder() + .label(BLUEMAP_SIGNAL_LABEL) + .shape(shape, signal.location.y.toFloat() + 1) + .lineColor(Color(getCssColor(mapStyle.colors.signal.outline))) + + if (signal.forward != null) { + when (signal.forward.state) { + SignalBlockEntity.SignalState.RED -> + markerBuilder.fillColor(Color(getCssColor(mapStyle.colors.signal.red))) + + SignalBlockEntity.SignalState.YELLOW -> + markerBuilder.fillColor(Color(getCssColor(mapStyle.colors.signal.yellow))) + + SignalBlockEntity.SignalState.GREEN -> + markerBuilder.fillColor(Color(getCssColor(mapStyle.colors.signal.green))) + + else -> {} + } + } else if (signal.reverse != null) { + when (signal.reverse.state) { + SignalBlockEntity.SignalState.RED -> + markerBuilder.fillColor(Color(getCssColor(mapStyle.colors.signal.red))) + + SignalBlockEntity.SignalState.YELLOW -> + markerBuilder.fillColor(Color(getCssColor(mapStyle.colors.signal.yellow))) + + SignalBlockEntity.SignalState.GREEN -> + markerBuilder.fillColor(Color(getCssColor(mapStyle.colors.signal.green))) + + else -> {} + } + } + + blueMap.getWorld(signal.dimension).ifPresent { world -> + for (map in world.maps) { + val markerSet = getMarkerSet(map, BLUEMAP_SIGNAL_ID, BLUEMAP_SIGNAL_LABEL) + markerSet.markers[BLUEMAP_SIGNAL_ID + "-" + markerSet.markers.size] = markerBuilder.build() + } + } + } + + fun update() { + BlueMapAPI.getInstance().ifPresent { blueMap -> + blueMap.maps.forEach { map -> + map.markerSets.remove(BLUEMAP_TRACK_ID) + map.markerSets.remove(BLUEMAP_STATION_ID) + map.markerSets.remove(BLUEMAP_TRAIN_ID) + map.markerSets.remove(BLUEMAP_SIGNAL_ID) + } + + updateTracks(blueMap, TrackMap.network.tracks) + + for (station in TrackMap.network.stations) { + updateStation(blueMap, station) + } + + for (train in TrackMap.trains.trains) { + updateTrain(blueMap, train) + } + + for (signal in TrackMap.signals.signals) { + updateSignal(blueMap, signal) + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/littlechasiu/ctm/Extensions.kt b/src/main/kotlin/littlechasiu/ctm/Extensions.kt index 571889d..ebd650e 100644 --- a/src/main/kotlin/littlechasiu/ctm/Extensions.kt +++ b/src/main/kotlin/littlechasiu/ctm/Extensions.kt @@ -1,17 +1,35 @@ package littlechasiu.ctm +import com.simibubi.create.content.trains.display.GlobalTrainDisplayData import com.simibubi.create.content.trains.entity.Carriage import com.simibubi.create.content.trains.entity.Train import com.simibubi.create.content.trains.entity.TravellingPoint import com.simibubi.create.content.trains.graph.TrackEdge import com.simibubi.create.content.trains.graph.TrackNode import com.simibubi.create.content.trains.graph.TrackNodeLocation +import com.simibubi.create.content.trains.schedule.ScheduleEntry +import com.simibubi.create.content.trains.schedule.ScheduleRuntime +import com.simibubi.create.content.trains.schedule.condition.FluidThresholdCondition +import com.simibubi.create.content.trains.schedule.condition.IdleCargoCondition +import com.simibubi.create.content.trains.schedule.condition.ItemThresholdCondition +import com.simibubi.create.content.trains.schedule.condition.PlayerPassengerCondition +import com.simibubi.create.content.trains.schedule.condition.RedstoneLinkCondition +import com.simibubi.create.content.trains.schedule.condition.ScheduleWaitCondition +import com.simibubi.create.content.trains.schedule.condition.ScheduledDelay +import com.simibubi.create.content.trains.schedule.condition.StationPoweredCondition +import com.simibubi.create.content.trains.schedule.condition.StationUnloadedCondition +import com.simibubi.create.content.trains.schedule.condition.TimeOfDayCondition +import com.simibubi.create.content.trains.schedule.destination.ChangeThrottleInstruction +import com.simibubi.create.content.trains.schedule.destination.ChangeTitleInstruction +import com.simibubi.create.content.trains.schedule.destination.DestinationInstruction +import com.simibubi.create.content.trains.schedule.destination.ScheduleInstruction import com.simibubi.create.foundation.utility.Couple import littlechasiu.ctm.model.* import net.minecraft.resources.ResourceKey import net.minecraft.world.level.Level import net.minecraft.world.phys.Vec3 + fun MutableSet.replaceWith(other: Collection) { this.retainAll { other.contains(it) } this.addAll(other) @@ -84,4 +102,73 @@ val Train.sendable cars = carriages.map { it.sendable }.toList(), backwards = speed < 0, stopped = speed == 0.0, + schedule = runtime.sendable + ) + +val ScheduleInstruction.sendable + get() = + CreateScheduleInstruction( + destination = if (this is DestinationInstruction) filter else null, + newTitle = if (this is ChangeTitleInstruction) scheduleTitle else null, + newThrottle = if (this is ChangeThrottleInstruction) (throttle * 100).toString() + "%" else null, + ) + +val ScheduleWaitCondition.sendable + get() = + CreateScheduleCondition( + scheduledDelay = + if (this is ScheduledDelay) (value.toString() + unit.suffix) + else null, + timeOfDay = + if (this is TimeOfDayCondition) summary.second.string + " every " + (rotation / 1000) + " hour(s)" + else null, + fluidCargoCondition = + if (this is FluidThresholdCondition) getItem(0).displayName.string + " " + operator.ordinal + " " + threshold + " buckets" + else null, + itemCargoCondition = + if (this is ItemThresholdCondition) getItem(0).displayName.string + " " + operator.ordinal + " " + threshold + else null, + redstoneLink = + if (this is RedstoneLinkCondition) + "Frequency: " + freq.get(true).stack.displayName.string + "; " + freq.get(false).stack.displayName.string + + (if (lowActivation()) " is not powered" else " is powered") + else null, + playersSeated = + if (this is PlayerPassengerCondition) target.toString() + (if (canOvershoot()) " or above" else " exactly") + else null, + cargoInactivity = + if (this is IdleCargoCondition) (value.toString() + unit.suffix) + else null, + chunkUnloaded = + if (this is StationUnloadedCondition) "No info" + else null, + stationPowered = + if (this is StationPoweredCondition) "No info" + else null, + ) + +val ScheduleEntry.sendable + get() = + CreateScheduleEntry( + instruction = instruction.sendable, + conditions = conditions.map { a -> a.map { b -> b.sendable } }, + ) + +val ScheduleRuntime.sendable + get() = + if (schedule == null) + null + else + CreateSchedule( + currentEntry = currentEntry, + loops = schedule.cyclic, + entries = schedule.entries.map { it.sendable }) + +val GlobalTrainDisplayData.TrainDeparturePrediction.sendable + get() = + StationSummaryEntry( + scheduleTitle = scheduleTitle.string, + destination = destination, + trainName = train.name.string, + ticks = ticks, ) diff --git a/src/main/kotlin/littlechasiu/ctm/TrackMap.kt b/src/main/kotlin/littlechasiu/ctm/TrackMap.kt index e032808..6b45e4a 100644 --- a/src/main/kotlin/littlechasiu/ctm/TrackMap.kt +++ b/src/main/kotlin/littlechasiu/ctm/TrackMap.kt @@ -82,6 +82,7 @@ object TrackMap { server.mapView = config.mapView server.dimensions = config.dimensions server.layers = config.layers + BlueMapIntegration.mapStyle = config.mapStyle } private fun reload() { diff --git a/src/main/kotlin/littlechasiu/ctm/TrackWatcher.kt b/src/main/kotlin/littlechasiu/ctm/TrackWatcher.kt index 2dbb0ca..f3b4b85 100644 --- a/src/main/kotlin/littlechasiu/ctm/TrackWatcher.kt +++ b/src/main/kotlin/littlechasiu/ctm/TrackWatcher.kt @@ -1,6 +1,7 @@ package littlechasiu.ctm import com.simibubi.create.Create +import com.simibubi.create.content.trains.display.GlobalTrainDisplayData import com.simibubi.create.content.trains.entity.Train import com.simibubi.create.content.trains.graph.TrackEdge import com.simibubi.create.content.trains.graph.TrackGraph @@ -60,8 +61,7 @@ class TrackWatcher() { try { update() } catch (e: Exception) { - TrackMap.LOGGER.warn("Exception during update loop") - e.printStackTrace() + TrackMap.LOGGER.warn("Exception during update loop", e) continue } delay(watchInterval) @@ -84,22 +84,20 @@ class TrackWatcher() { private val angle get() = internal.angleOn(edge) private val assembling get() = internal.assembling - override fun equals(other: Any?) = - other != null && javaClass == other.javaClass && - internal == (other as CreateStation).internal + override fun equals(other: Any?) = other != null && javaClass == other.javaClass && internal == (other as CreateStation).internal override fun hashCode() = internal.hashCode() val sendable - get() = - Station( - id = id, - name = name, - dimension = dimension, - location = location.sendable, - angle = angle, - assembling = assembling, - ) + get() = Station( + id = id, + name = name, + dimension = dimension, + location = location.sendable, + angle = angle, + assembling = assembling, + summary = GlobalTrainDisplayData.prepare(name, 6).map { it.sendable }, + ) } data class CreateSignal( @@ -128,39 +126,36 @@ class TrackWatcher() { this.reverseSegment = rev } - override fun equals(other: Any?) = - other != null && javaClass == other.javaClass && - internal == (other as CreateSignal).internal + override fun equals(other: Any?) = other != null && javaClass == other.javaClass && internal == (other as CreateSignal).internal override fun hashCode() = internal.hashCode() val sendable - get() = - Signal( - id = id, - dimension = dimension, - location = location.sendable, - forward = when (forwardState) { - null -> null - SignalState.INVALID -> null - else -> SignalSide( - type = forwardType!!, - state = forwardState!!, - angle = forwardAngle, - block = forwardGroup?.id, - ) - }, - reverse = when (reverseState) { - null -> null - SignalState.INVALID -> null - else -> SignalSide( - type = reverseType!!, - state = reverseState!!, - angle = reverseAngle, - block = reverseGroup?.id, - ) - }, - ) + get() = Signal( + id = id, + dimension = dimension, + location = location.sendable, + forward = when (forwardState) { + null -> null + SignalState.INVALID -> null + else -> SignalSide( + type = forwardType!!, + state = forwardState!!, + angle = forwardAngle, + block = forwardGroup?.id, + ) + }, + reverse = when (reverseState) { + null -> null + SignalState.INVALID -> null + else -> SignalSide( + type = reverseType!!, + state = reverseState!!, + angle = reverseAngle, + block = reverseGroup?.id, + ) + }, + ) } data class CreateSignalBlock( @@ -173,21 +168,17 @@ class TrackWatcher() { val segments = mutableListOf() val portals = mutableSetOf() - override fun equals(other: Any?) = - other != null && javaClass == other.javaClass && - internal == (other as CreateSignalBlock).internal && - segments == other.segments + override fun equals(other: Any?) = other != null && javaClass == other.javaClass && internal == (other as CreateSignalBlock).internal && segments == other.segments override fun hashCode() = internal.hashCode() val sendable - get() = - Block( - id = id, - occupied = occupied, - reserved = reserved, - segments = segments.map { it.sendable }, - ) + get() = Block( + id = id, + occupied = occupied, + reserved = reserved, + segments = segments.map { it.sendable }, + ) } private var nodes = mutableSetOf() @@ -197,34 +188,27 @@ class TrackWatcher() { private var trains = mutableSetOf() private var blocks = mutableMapOf() - fun portalsInBlock(block: UUID): Collection = - blocks[block]?.portals ?: listOf() + fun portalsInBlock(block: UUID): Collection = blocks[block]?.portals ?: listOf() val network - get() = - Network( - tracks = edges.map { it.sendable }.filterIsInstance().toList(), - portals = edges.map { it.sendable }.filterIsInstance().toList(), - stations = stations.map { it.sendable }.toList(), - ) + get() = Network( + tracks = edges.map { it.sendable }.filterIsInstance().toList(), + portals = edges.map { it.sendable }.filterIsInstance().toList(), + stations = stations.map { it.sendable }.toList(), + ) val signalStatus - get() = - SignalStatus( - signals = signals.map { it.sendable }.toList(), - ) + get() = SignalStatus( + signals = signals.map { it.sendable }.toList(), + ) val blockStatus - get() = - BlockStatus( - blocks = blocks.values.map { it.sendable }.toList() - ) + get() = BlockStatus(blocks = blocks.values.map { it.sendable }.toList()) val trainStatus - get() = - TrainStatus( - trains = trains.map { it.sendable }.toList(), - ) + get() = TrainStatus( + trains = trains.map { it.sendable }.toList(), + ) private suspend fun update() { val networkEdges = mutableMapOf>() @@ -236,8 +220,7 @@ class TrackWatcher() { RR.trackNetworks.forEach { (_, net) -> // Track topology val netNodes = net.nodes.map { net.locateNode(it) } - val netEdges = netNodes.map { net.getConnectionsFrom(it) } - .flatMap { it.values } + val netEdges = netNodes.map { net.getConnectionsFrom(it) }.flatMap { it.values } thisNodes.addAll(netNodes) thisEdges.addAll(netEdges) @@ -273,12 +256,10 @@ class TrackWatcher() { } } else { if (edge.edgeData.hasSignalBoundaries()) { - val signals = - edge.edgeData.points.filterIsInstance() - .sortedBy { - if (it.isPrimary(edge.node2)) it.position - else edge.length - it.position - } + val signals = edge.edgeData.points.filterIsInstance().sortedBy { + if (it.isPrimary(edge.node2)) it.position + else edge.length - it.position + } if (signals.isEmpty()) { return } @@ -286,39 +267,21 @@ class TrackWatcher() { val path = edge.path val segments = mutableListOf() - segments.add( - path.divideAt( - ( - if (signals[0].isPrimary(edge.node2)) signals[0].position - else edge.length - signals[0].position - ) / edge.length - ).first - ) + segments.add(path.divideAt((if (signals[0].isPrimary(edge.node2)) signals[0].position + else edge.length - signals[0].position) / edge.length).first) signals.windowed(2).forEach { sigs -> val leftSig = sigs[0] val rightSig = sigs[1] - val (rest, _) = path.divideAt( - (if (rightSig.isPrimary(edge.node2)) rightSig.position - else edge.length - rightSig.position) / edge.length - ) - val (_, seg) = rest.divideAt( - (if (leftSig.isPrimary(edge.node2)) leftSig.position - else edge.length - leftSig.position) / edge.length - ) + val (rest, _) = path.divideAt((if (rightSig.isPrimary(edge.node2)) rightSig.position + else edge.length - rightSig.position) / edge.length) + val (_, seg) = rest.divideAt((if (leftSig.isPrimary(edge.node2)) leftSig.position + else edge.length - leftSig.position) / edge.length) segments.add(seg) } - segments.add( - path.divideAt( - ( - if (signals.last() - .isPrimary(edge.node2) - ) signals.last().position - else edge.length - signals.last().position - ) / edge.length - ).second - ) + segments.add(path.divideAt((if (signals.last().isPrimary(edge.node2)) signals.last().position + else edge.length - signals.last().position) / edge.length).second) segments.windowed(2).zip(signals).forEach { (segs, sig) -> val leftSeg = segs[0] @@ -328,9 +291,7 @@ class TrackWatcher() { thisBlocks[sig.getGroup(edge.node2)]?.segments?.add(rightSeg) } } else { - thisBlocks[edge.edgeData.getEffectiveEdgeGroupId(net)]?.segments?.add( - edge.path - ) + thisBlocks[edge.edgeData.getEffectiveEdgeGroupId(net)]?.segments?.add(edge.path) } } } @@ -345,5 +306,7 @@ class TrackWatcher() { signalChannel.send(signalStatus) blockChannel.send(blockStatus) trainChannel.send(trainStatus) + + BlueMapIntegration.update() } } diff --git a/src/main/kotlin/littlechasiu/ctm/model/Network.kt b/src/main/kotlin/littlechasiu/ctm/model/Network.kt index 7735c51..eadaa4c 100644 --- a/src/main/kotlin/littlechasiu/ctm/model/Network.kt +++ b/src/main/kotlin/littlechasiu/ctm/model/Network.kt @@ -56,6 +56,14 @@ data class Portal( val to: DimensionLocation, ) +@Serializable +data class StationSummaryEntry( + val scheduleTitle: String, + val destination: String, + val trainName: String, + val ticks: Int, +) + @Serializable data class Station( @Serializable(with = UUIDSerializer::class) @@ -65,6 +73,7 @@ data class Station( val location: Point, val angle: Double, val assembling: Boolean, + val summary: List, ) @Serializable @@ -129,6 +138,7 @@ data class CreateTrain( val cars: List, val backwards: Boolean, val stopped: Boolean, + val schedule: CreateSchedule?, ) @Serializable diff --git a/src/main/kotlin/littlechasiu/ctm/model/Schedule.kt b/src/main/kotlin/littlechasiu/ctm/model/Schedule.kt new file mode 100644 index 0000000..77f5fae --- /dev/null +++ b/src/main/kotlin/littlechasiu/ctm/model/Schedule.kt @@ -0,0 +1,36 @@ +package littlechasiu.ctm.model + +import kotlinx.serialization.Serializable + +@Serializable +data class CreateScheduleInstruction( + val destination: String?, + val newTitle: String?, + val newThrottle: String?, +) + +@Serializable +data class CreateScheduleCondition( + val scheduledDelay: String?, + val timeOfDay: String?, + val fluidCargoCondition: String?, + val itemCargoCondition: String?, + val redstoneLink: String?, + val playersSeated: String?, + val cargoInactivity: String?, + val chunkUnloaded: String?, + val stationPowered: String?, +) + +@Serializable +data class CreateScheduleEntry( + val instruction: CreateScheduleInstruction, + val conditions: List>, +) + +@Serializable +data class CreateSchedule( + val currentEntry: Int, + val loops: Boolean, + val entries: List, +) diff --git a/src/main/resources/assets/littlechasiu/ctm/static/assets/css/create-track-map.css b/src/main/resources/assets/littlechasiu/ctm/static/assets/css/create-track-map.css index 21434b3..e3e759b 100644 --- a/src/main/resources/assets/littlechasiu/ctm/static/assets/css/create-track-map.css +++ b/src/main/resources/assets/littlechasiu/ctm/static/assets/css/create-track-map.css @@ -79,6 +79,29 @@ body { fill: var(--lead-car-color); } +.train-schedule { + margin: 0; + padding-left: 1rem; + list-style-type: none; +} +.train-schedule .train-schedule-current { + list-style-type: disc; +} + +.station-schedule, +.station-schedule th, +.station-schedule td { + border: 1px solid currentColor; + border-collapse: collapse; +} +.station-schedule th, +.station-schedule td { + padding: .2rem .5rem; +} +.station-schedule td { + font-weight: normal; +} + .coords-control { font-family: var(--ui-font); font-size: 16px; diff --git a/src/main/resources/assets/littlechasiu/ctm/static/assets/js/create-track-map.js b/src/main/resources/assets/littlechasiu/ctm/static/assets/js/create-track-map.js index 8566466..0fae896 100644 --- a/src/main/resources/assets/littlechasiu/ctm/static/assets/js/create-track-map.js +++ b/src/main/resources/assets/littlechasiu/ctm/static/assets/js/create-track-map.js @@ -60,6 +60,15 @@ fetch("api/config.json") startMapUpdates() }) +function htmlEscape(str) { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\"/g, """) + .replace(/'/g, "'") +} + function startMapUpdates() { const dmgr = new DataManager() @@ -87,12 +96,41 @@ function startMapUpdates() { }) stations.forEach((stn) => { + const scheduleHtml = + "" + + stn.summary.map((entry) => { + let time + + if (entry.ticks === -1 || entry.ticks >= 12000 - 15 * 20) { + time = "later" + } else if (entry.ticks < 200) { + time = "now" + } else { + time = "in " + + let min = Math.floor(entry.ticks / 1200) + let sec = Math.floor(entry.ticks / 20) % 60 + sec = Math.ceil(sec / 15) * 15 + + if (sec === 60) { + min++ + sec = 0 + } + + time += min > 0 ? min : sec; + time += min > 0 ? "mins" : "secs"; + } + + return ``; + }).join("") + + "
DueTrainDestination
${time}${htmlEscape(entry.trainName)}${htmlEscape(entry.scheduleTitle)}
" + L.marker(xz(stn.location), { icon: stationIcon, rotationAngle: stn.angle, pane: "stations", }) - .bindTooltip(stn.name, { + .bindTooltip(stn.name + scheduleHtml, { className: "station-name", direction: "top", offset: L.point(0, -12), @@ -197,6 +235,20 @@ function startMapUpdates() { ] : [[car.leading.dimension, [xz(car.leading.location), xz(car.trailing.location)]]] + const scheduleHtml = + train.schedule ? + "
    " + + train.schedule.entries.map((entry, i) => { + let entryHtml = ""; + const className = train.schedule.currentEntry === i ? "train-schedule-current" : "" + + if (entry.instruction.destination) entryHtml += htmlEscape(entry.instruction.destination); + + return `
  • ${entryHtml}
  • `; + }).join("\n") + + "
" : + ""; + parts.map(([dim, part]) => L.polyline(part, { weight: 12, @@ -205,9 +257,9 @@ function startMapUpdates() { pane: "trains", }) .bindTooltip( - train.cars.length === 1 + (train.cars.length === 1 ? train.name - : `${train.name} ${i + 1}`, + : `${train.name} ${i + 1}`) + scheduleHtml, { className: "train-name", direction: "right",