Phase 3: Road Planner GUI with 2D map interface, drawing tools, and profile selection

This commit is contained in:
bnair123
2025-12-31 01:18:44 +04:00
parent ce691684d2
commit 114720393e
6 changed files with 496 additions and 1 deletions

View File

@@ -70,6 +70,7 @@ repositories {
maven("https://maven.fabricmc.net/") maven("https://maven.fabricmc.net/")
maven("https://maven.minecraftforge.net/") maven("https://maven.minecraftforge.net/")
maven("https://maven.neoforged.net/releases/") maven("https://maven.neoforged.net/releases/")
maven("https://maven.wispforest.io/releases/") { name = "Wisp Forest (owo-lib)" }
} }
val loaderVersion: String by project val loaderVersion: String by project
@@ -90,6 +91,10 @@ dependencies {
modImplementation("net.fabricmc:fabric-loader:${loaderVersion}") modImplementation("net.fabricmc:fabric-loader:${loaderVersion}")
modImplementation("net.fabricmc.fabric-api:fabric-api:${fabricApiVersion}") modImplementation("net.fabricmc.fabric-api:fabric-api:${fabricApiVersion}")
// owo-lib for GUI
modImplementation("io.wispforest:owo-lib:0.12.15+1.21")
include("io.wispforest:owo-sentinel:0.12.15+1.21")
} }
"neoforge" -> { "neoforge" -> {

View File

@@ -1,5 +1,7 @@
package com.bnair.roadrunner package com.bnair.roadrunner
import com.bnair.roadrunner.registry.ModItems
//? if fabric { //? if fabric {
import net.fabricmc.api.ModInitializer import net.fabricmc.api.ModInitializer
//?} //?}
@@ -11,6 +13,7 @@ internal const val MOD_ID = "roadrunner"
internal fun initializeMod() { internal fun initializeMod() {
println("RoadRunner Mod has been initialized.") println("RoadRunner Mod has been initialized.")
ModItems.init()
} }
//? if fabric { //? if fabric {

View File

@@ -0,0 +1,30 @@
package com.bnair.roadrunner.client
import com.bnair.roadrunner.MOD_ID
import com.bnair.roadrunner.client.gui.RoadPlannerScreen
import com.bnair.roadrunner.registry.ModItems
import net.minecraft.client.Minecraft
//? if fabric {
import net.fabricmc.api.ClientModInitializer
import net.fabricmc.fabric.api.event.player.UseItemCallback
import net.minecraft.world.InteractionResultHolder
//?}
//? if fabric {
class RoadRunnerClient : ClientModInitializer {
override fun onInitializeClient() {
println("$MOD_ID client initialized")
UseItemCallback.EVENT.register { player, world, hand ->
val stack = player.getItemInHand(hand)
if (stack.item == ModItems.ROAD_PLANNER && world.isClientSide) {
Minecraft.getInstance().setScreen(RoadPlannerScreen())
InteractionResultHolder.success(stack)
} else {
InteractionResultHolder.pass(stack)
}
}
}
}
//?}

View File

@@ -0,0 +1,414 @@
package com.bnair.roadrunner.client.gui
import com.bnair.roadrunner.math.BezierCurve
import com.bnair.roadrunner.math.Vec3d
import com.bnair.roadrunner.road.RoadNetwork
import com.bnair.roadrunner.road.RoadProfile
import com.bnair.roadrunner.road.RoadSegment
import com.mojang.blaze3d.systems.RenderSystem
import net.minecraft.client.Minecraft
import net.minecraft.client.gui.GuiGraphics
import net.minecraft.client.gui.screens.Screen
import net.minecraft.network.chat.Component
import org.lwjgl.glfw.GLFW
import kotlin.math.floor
enum class DrawingTool {
SELECT,
STRAIGHT,
CURVE
}
class RoadPlannerScreen : Screen(Component.literal("Road Planner")) {
private var currentTool = DrawingTool.STRAIGHT
private var currentProfile = RoadProfile.highway()
private var mapCenterX = 0.0
private var mapCenterZ = 0.0
private var mapScale = 2.0
private val plannedSegments = mutableListOf<PlannedSegment>()
private var currentSegment: PlannedSegment? = null
private var isDragging = false
private var lastMouseX = 0.0
private var lastMouseY = 0.0
private val toolbarHeight = 40
private val sidebarWidth = 150
data class PlannedSegment(
val points: MutableList<ScreenPoint> = mutableListOf(),
var profile: RoadProfile = RoadProfile.highway(),
var isComplete: Boolean = false
)
data class ScreenPoint(var screenX: Double, var screenY: Double)
override fun init() {
super.init()
val player = Minecraft.getInstance().player ?: return
mapCenterX = player.x
mapCenterZ = player.z
}
override fun render(graphics: GuiGraphics, mouseX: Int, mouseY: Int, partialTick: Float) {
renderBackground(graphics, mouseX, mouseY, partialTick)
renderMap(graphics, mouseX, mouseY)
renderToolbar(graphics, mouseX, mouseY)
renderSidebar(graphics, mouseX, mouseY)
renderCurrentDrawing(graphics, mouseX, mouseY)
renderPlannedSegments(graphics)
super.render(graphics, mouseX, mouseY, partialTick)
}
private fun renderMap(graphics: GuiGraphics, mouseX: Int, mouseY: Int) {
val mapLeft = sidebarWidth
val mapTop = toolbarHeight
val mapWidth = width - sidebarWidth
val mapHeight = height - toolbarHeight
graphics.fill(mapLeft, mapTop, width, height, 0xFF1a1a2e.toInt())
renderGrid(graphics, mapLeft, mapTop, mapWidth, mapHeight)
val playerScreenX = worldToScreenX(mapCenterX, mapLeft, mapWidth)
val playerScreenY = worldToScreenZ(mapCenterZ, mapTop, mapHeight)
graphics.fill(
playerScreenX.toInt() - 3,
playerScreenY.toInt() - 3,
playerScreenX.toInt() + 3,
playerScreenY.toInt() + 3,
0xFF00FF00.toInt()
)
val worldX = screenToWorldX(mouseX.toDouble(), mapLeft, mapWidth)
val worldZ = screenToWorldZ(mouseY.toDouble(), mapTop, mapHeight)
graphics.drawString(
font,
"X: ${floor(worldX).toInt()}, Z: ${floor(worldZ).toInt()}",
mapLeft + 5,
height - 15,
0xFFFFFF
)
}
private fun renderGrid(graphics: GuiGraphics, mapLeft: Int, mapTop: Int, mapWidth: Int, mapHeight: Int) {
val gridSpacing = 16.0 * mapScale
val startWorldX = screenToWorldX(mapLeft.toDouble(), mapLeft, mapWidth)
val endWorldX = screenToWorldX((mapLeft + mapWidth).toDouble(), mapLeft, mapWidth)
val startWorldZ = screenToWorldZ(mapTop.toDouble(), mapTop, mapHeight)
val endWorldZ = screenToWorldZ((mapTop + mapHeight).toDouble(), mapTop, mapHeight)
val gridStartX = floor(startWorldX / 16) * 16
val gridStartZ = floor(startWorldZ / 16) * 16
var x = gridStartX
while (x <= endWorldX) {
val screenX = worldToScreenX(x, mapLeft, mapWidth).toInt()
if (screenX in mapLeft until (mapLeft + mapWidth)) {
val color = if (x.toInt() % 64 == 0) 0x40FFFFFF else 0x20FFFFFF
graphics.fill(screenX, mapTop, screenX + 1, mapTop + mapHeight, color)
}
x += 16
}
var z = gridStartZ
while (z <= endWorldZ) {
val screenY = worldToScreenZ(z, mapTop, mapHeight).toInt()
if (screenY in mapTop until (mapTop + mapHeight)) {
val color = if (z.toInt() % 64 == 0) 0x40FFFFFF else 0x20FFFFFF
graphics.fill(mapLeft, screenY, mapLeft + mapWidth, screenY + 1, color)
}
z += 16
}
}
private fun renderToolbar(graphics: GuiGraphics, mouseX: Int, mouseY: Int) {
graphics.fill(0, 0, width, toolbarHeight, 0xFF2d2d44.toInt())
val tools = listOf(
Triple(DrawingTool.SELECT, "Select", 10),
Triple(DrawingTool.STRAIGHT, "Straight", 80),
Triple(DrawingTool.CURVE, "Curve", 160)
)
tools.forEach { (tool, name, x) ->
val isSelected = currentTool == tool
val bgColor = if (isSelected) 0xFF4a4a6a.toInt() else 0xFF3a3a5a.toInt()
val isHovered = mouseX in x until (x + 60) && mouseY in 5 until 35
graphics.fill(x, 5, x + 60, 35, if (isHovered) 0xFF5a5a7a.toInt() else bgColor)
graphics.drawCenteredString(font, name, x + 30, 15, 0xFFFFFF)
}
graphics.drawString(font, "Scroll to zoom | Drag to pan", 250, 15, 0xAAAAAA)
}
private fun renderSidebar(graphics: GuiGraphics, mouseX: Int, mouseY: Int) {
graphics.fill(0, toolbarHeight, sidebarWidth, height, 0xFF252538.toInt())
graphics.drawString(font, "Road Profile", 10, toolbarHeight + 10, 0xFFFFFF)
val profiles = listOf(
"Simple" to RoadProfile.singleLaneEachWay(),
"Highway 2L" to RoadProfile.highway(2),
"Highway 3L" to RoadProfile.highway(3),
"With Transit" to RoadProfile.withPublicTransport()
)
profiles.forEachIndexed { index, (name, profile) ->
val y = toolbarHeight + 30 + index * 25
val isSelected = currentProfile.totalWidth == profile.totalWidth
val bgColor = if (isSelected) 0xFF4a4a6a.toInt() else 0xFF3a3a5a.toInt()
val isHovered = mouseX in 5 until (sidebarWidth - 5) && mouseY in y until (y + 20)
graphics.fill(5, y, sidebarWidth - 5, y + 20, if (isHovered) 0xFF5a5a7a.toInt() else bgColor)
graphics.drawString(font, name, 10, y + 6, 0xFFFFFF)
}
graphics.drawString(font, "Segments: ${plannedSegments.size}", 10, height - 60, 0xAAAAAA)
val buildButtonY = height - 35
val isHovered = mouseX in 10 until (sidebarWidth - 10) && mouseY in buildButtonY until (buildButtonY + 25)
graphics.fill(10, buildButtonY, sidebarWidth - 10, buildButtonY + 25,
if (isHovered) 0xFF22AA22.toInt() else 0xFF118811.toInt())
graphics.drawCenteredString(font, "BUILD", sidebarWidth / 2, buildButtonY + 8, 0xFFFFFF)
}
private fun renderCurrentDrawing(graphics: GuiGraphics, mouseX: Int, mouseY: Int) {
val segment = currentSegment ?: return
segment.points.forEach { point ->
graphics.fill(
point.screenX.toInt() - 4,
point.screenY.toInt() - 4,
point.screenX.toInt() + 4,
point.screenY.toInt() + 4,
0xFFFF6600.toInt()
)
}
if (segment.points.isNotEmpty()) {
val lastPoint = segment.points.last()
graphics.fill(
lastPoint.screenX.toInt(),
lastPoint.screenY.toInt(),
mouseX,
mouseY,
0x80FF6600.toInt()
)
}
}
private fun renderPlannedSegments(graphics: GuiGraphics) {
val mapLeft = sidebarWidth
val mapTop = toolbarHeight
val mapWidth = width - sidebarWidth
val mapHeight = height - toolbarHeight
plannedSegments.forEach { segment ->
if (segment.points.size >= 2) {
val curve = createCurveFromPoints(segment.points, mapLeft, mapTop, mapWidth, mapHeight)
val samples = curve.sample(50)
for (i in 0 until samples.size - 1) {
val p1 = samples[i]
val p2 = samples[i + 1]
val sx1 = worldToScreenX(p1.x, mapLeft, mapWidth).toInt()
val sy1 = worldToScreenZ(p1.z, mapTop, mapHeight).toInt()
val sx2 = worldToScreenX(p2.x, mapLeft, mapWidth).toInt()
val sy2 = worldToScreenZ(p2.z, mapTop, mapHeight).toInt()
drawLine(graphics, sx1, sy1, sx2, sy2, 0xFF4488FF.toInt(), segment.profile.totalWidth.toInt() / 2)
}
}
}
}
private fun drawLine(graphics: GuiGraphics, x1: Int, y1: Int, x2: Int, y2: Int, color: Int, thickness: Int = 2) {
val dx = x2 - x1
val dy = y2 - y1
val steps = maxOf(kotlin.math.abs(dx), kotlin.math.abs(dy), 1)
for (i in 0..steps) {
val t = i.toFloat() / steps
val x = (x1 + dx * t).toInt()
val y = (y1 + dy * t).toInt()
graphics.fill(x - thickness, y - thickness, x + thickness, y + thickness, color)
}
}
override fun mouseClicked(mouseX: Double, mouseY: Double, button: Int): Boolean {
if (button == GLFW.GLFW_MOUSE_BUTTON_LEFT) {
if (mouseY < toolbarHeight) {
handleToolbarClick(mouseX.toInt(), mouseY.toInt())
return true
}
if (mouseX < sidebarWidth) {
handleSidebarClick(mouseX.toInt(), mouseY.toInt())
return true
}
if (currentTool == DrawingTool.STRAIGHT || currentTool == DrawingTool.CURVE) {
if (currentSegment == null) {
currentSegment = PlannedSegment(profile = currentProfile)
}
currentSegment?.points?.add(ScreenPoint(mouseX, mouseY))
return true
}
}
if (button == GLFW.GLFW_MOUSE_BUTTON_RIGHT) {
currentSegment?.let { segment ->
if (segment.points.size >= 2) {
segment.isComplete = true
plannedSegments.add(segment)
}
currentSegment = null
}
return true
}
if (button == GLFW.GLFW_MOUSE_BUTTON_MIDDLE) {
isDragging = true
lastMouseX = mouseX
lastMouseY = mouseY
return true
}
return super.mouseClicked(mouseX, mouseY, button)
}
override fun mouseReleased(mouseX: Double, mouseY: Double, button: Int): Boolean {
if (button == GLFW.GLFW_MOUSE_BUTTON_MIDDLE) {
isDragging = false
return true
}
return super.mouseReleased(mouseX, mouseY, button)
}
override fun mouseDragged(mouseX: Double, mouseY: Double, button: Int, dragX: Double, dragY: Double): Boolean {
if (isDragging) {
mapCenterX -= dragX * mapScale
mapCenterZ -= dragY * mapScale
return true
}
return super.mouseDragged(mouseX, mouseY, button, dragX, dragY)
}
override fun mouseScrolled(mouseX: Double, mouseY: Double, horizontalAmount: Double, verticalAmount: Double): Boolean {
val zoomFactor = if (verticalAmount > 0) 0.8 else 1.25
mapScale = (mapScale * zoomFactor).coerceIn(0.5, 20.0)
return true
}
private fun handleToolbarClick(mouseX: Int, mouseY: Int) {
when {
mouseX in 10 until 70 -> currentTool = DrawingTool.SELECT
mouseX in 80 until 140 -> currentTool = DrawingTool.STRAIGHT
mouseX in 160 until 220 -> currentTool = DrawingTool.CURVE
}
}
private fun handleSidebarClick(mouseX: Int, mouseY: Int) {
val profiles = listOf(
RoadProfile.singleLaneEachWay(),
RoadProfile.highway(2),
RoadProfile.highway(3),
RoadProfile.withPublicTransport()
)
profiles.forEachIndexed { index, profile ->
val y = toolbarHeight + 30 + index * 25
if (mouseY in y until (y + 20)) {
currentProfile = profile
return
}
}
val buildButtonY = height - 35
if (mouseY in buildButtonY until (buildButtonY + 25)) {
buildRoads()
}
}
private fun buildRoads() {
val mapLeft = sidebarWidth
val mapTop = toolbarHeight
val mapWidth = width - sidebarWidth
val mapHeight = height - toolbarHeight
val network = RoadNetwork()
plannedSegments.forEach { segment ->
if (segment.points.size >= 2) {
val curve = createCurveFromPoints(segment.points, mapLeft, mapTop, mapWidth, mapHeight)
network.addSegment(RoadSegment(curve, segment.profile))
}
}
val blocks = network.getAllBlocks()
val costs = network.estimateMaterialCost()
println("Road network planned:")
println(" Total blocks: ${blocks.size}")
println(" Materials needed:")
costs.forEach { (material, count) ->
println(" $material: $count")
}
Minecraft.getInstance().player?.sendSystemMessage(
Component.literal("Planned ${plannedSegments.size} road segments (${blocks.size} blocks)")
)
}
private fun createCurveFromPoints(points: List<ScreenPoint>, mapLeft: Int, mapTop: Int, mapWidth: Int, mapHeight: Int): BezierCurve {
val worldPoints = points.map { point ->
Vec3d(
screenToWorldX(point.screenX, mapLeft, mapWidth),
64.0,
screenToWorldZ(point.screenY, mapTop, mapHeight)
)
}
return when {
worldPoints.size == 2 -> BezierCurve.line(worldPoints[0], worldPoints[1])
worldPoints.size >= 4 -> BezierCurve(
worldPoints[0],
worldPoints[1],
worldPoints[worldPoints.size - 2],
worldPoints.last()
)
else -> {
val p0 = worldPoints[0]
val p3 = worldPoints.last()
val mid = if (worldPoints.size > 2) worldPoints[1] else (p0 + p3) / 2.0
BezierCurve(p0, mid, mid, p3)
}
}
}
private fun worldToScreenX(worldX: Double, mapLeft: Int, mapWidth: Int): Double {
return mapLeft + mapWidth / 2.0 + (worldX - mapCenterX) / mapScale
}
private fun worldToScreenZ(worldZ: Double, mapTop: Int, mapHeight: Int): Double {
return mapTop + mapHeight / 2.0 + (worldZ - mapCenterZ) / mapScale
}
private fun screenToWorldX(screenX: Double, mapLeft: Int, mapWidth: Int): Double {
return mapCenterX + (screenX - mapLeft - mapWidth / 2.0) * mapScale
}
private fun screenToWorldZ(screenY: Double, mapTop: Int, mapHeight: Int): Double {
return mapCenterZ + (screenY - mapTop - mapHeight / 2.0) * mapScale
}
override fun isPauseScreen() = false
}

View File

@@ -0,0 +1,39 @@
package com.bnair.roadrunner.registry
import com.bnair.roadrunner.MOD_ID
import net.minecraft.core.Registry
import net.minecraft.core.registries.BuiltInRegistries
import net.minecraft.resources.ResourceLocation
import net.minecraft.world.item.CreativeModeTabs
import net.minecraft.world.item.Item
//? if fabric {
import net.fabricmc.fabric.api.itemgroup.v1.ItemGroupEvents
//?}
object ModItems {
private val ITEMS = mutableMapOf<String, Item>()
val ROAD_PLANNER: Item = register("road_planner", Item(Item.Properties().stacksTo(1)))
private fun register(name: String, item: Item): Item {
ITEMS[name] = item
return item
}
fun init() {
//? if fabric {
ITEMS.forEach { (name, item) ->
Registry.register(
BuiltInRegistries.ITEM,
ResourceLocation.fromNamespaceAndPath(MOD_ID, name),
item
)
}
ItemGroupEvents.modifyEntriesEvent(CreativeModeTabs.TOOLS_AND_UTILITIES).register { content ->
content.accept(ROAD_PLANNER)
}
//?}
}
}

View File

@@ -15,6 +15,9 @@
"entrypoints": { "entrypoints": {
"main": [ "main": [
"com.bnair.roadrunner.RoadRunnerFabric" "com.bnair.roadrunner.RoadRunnerFabric"
],
"client": [
"com.bnair.roadrunner.client.RoadRunnerClient"
] ]
}, },
"mixins": [ "mixins": [
@@ -23,7 +26,8 @@
"depends": { "depends": {
"fabric": ">=${loader_version}", "fabric": ">=${loader_version}",
"fabric-api": "*", "fabric-api": "*",
"minecraft": "${minecraft_version}" "minecraft": "${minecraft_version}",
"owo-lib": "*"
}, },
"custom": { "custom": {
"modmenu": { "modmenu": {