diff --git a/build.gradle.kts b/build.gradle.kts index 87c3db6..59a0448 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -70,6 +70,7 @@ repositories { maven("https://maven.fabricmc.net/") maven("https://maven.minecraftforge.net/") maven("https://maven.neoforged.net/releases/") + maven("https://maven.wispforest.io/releases/") { name = "Wisp Forest (owo-lib)" } } val loaderVersion: String by project @@ -90,6 +91,10 @@ dependencies { modImplementation("net.fabricmc:fabric-loader:${loaderVersion}") 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" -> { diff --git a/src/main/kotlin/com/bnair/roadrunner/RoadRunner.kt b/src/main/kotlin/com/bnair/roadrunner/RoadRunner.kt index 1bd6250..2e51950 100644 --- a/src/main/kotlin/com/bnair/roadrunner/RoadRunner.kt +++ b/src/main/kotlin/com/bnair/roadrunner/RoadRunner.kt @@ -1,5 +1,7 @@ package com.bnair.roadrunner +import com.bnair.roadrunner.registry.ModItems + //? if fabric { import net.fabricmc.api.ModInitializer //?} @@ -11,6 +13,7 @@ internal const val MOD_ID = "roadrunner" internal fun initializeMod() { println("RoadRunner Mod has been initialized.") + ModItems.init() } //? if fabric { diff --git a/src/main/kotlin/com/bnair/roadrunner/client/RoadRunnerClient.kt b/src/main/kotlin/com/bnair/roadrunner/client/RoadRunnerClient.kt new file mode 100644 index 0000000..7f49530 --- /dev/null +++ b/src/main/kotlin/com/bnair/roadrunner/client/RoadRunnerClient.kt @@ -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) + } + } + } +} +//?} diff --git a/src/main/kotlin/com/bnair/roadrunner/client/gui/RoadPlannerScreen.kt b/src/main/kotlin/com/bnair/roadrunner/client/gui/RoadPlannerScreen.kt new file mode 100644 index 0000000..74e972d --- /dev/null +++ b/src/main/kotlin/com/bnair/roadrunner/client/gui/RoadPlannerScreen.kt @@ -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() + 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 = 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, 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 +} diff --git a/src/main/kotlin/com/bnair/roadrunner/registry/ModItems.kt b/src/main/kotlin/com/bnair/roadrunner/registry/ModItems.kt new file mode 100644 index 0000000..46434bc --- /dev/null +++ b/src/main/kotlin/com/bnair/roadrunner/registry/ModItems.kt @@ -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() + + 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) + } + //?} + } +} diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index 404661c..1bf665d 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -15,6 +15,9 @@ "entrypoints": { "main": [ "com.bnair.roadrunner.RoadRunnerFabric" + ], + "client": [ + "com.bnair.roadrunner.client.RoadRunnerClient" ] }, "mixins": [ @@ -23,7 +26,8 @@ "depends": { "fabric": ">=${loader_version}", "fabric-api": "*", - "minecraft": "${minecraft_version}" + "minecraft": "${minecraft_version}", + "owo-lib": "*" }, "custom": { "modmenu": {