diff --git a/src/main/java/de/bixilon/minosoft/config/key/KeyBinding.kt b/src/main/java/de/bixilon/minosoft/config/key/KeyBinding.kt index 9c02ee4bb..ad87e1791 100644 --- a/src/main/java/de/bixilon/minosoft/config/key/KeyBinding.kt +++ b/src/main/java/de/bixilon/minosoft/config/key/KeyBinding.kt @@ -14,23 +14,25 @@ package de.bixilon.minosoft.config.key import com.fasterxml.jackson.annotation.JsonInclude -import de.bixilon.minosoft.util.KUtil.synchronizedDeepCopy +import com.fasterxml.jackson.databind.annotation.JsonDeserialize class KeyBinding( + @field:JsonDeserialize(contentAs = LinkedHashSet::class) val action: MutableMap>, @JsonInclude(JsonInclude.Include.NON_DEFAULT) var ignoreConsumer: Boolean = false, ignored: Boolean = true, // to prevent constructor overloading ) { - constructor(keyBinding: KeyBinding) : this(keyBinding.action.synchronizedDeepCopy()) + constructor(keyBinding: KeyBinding) : this(keyBinding.action.copy(), keyBinding.ignoreConsumer) constructor(action: Map>, ignoreConsumer: Boolean = false) : this(action.copy(), ignoreConsumer) constructor(vararg action: Pair>, ignoreConsumer: Boolean = false) : this(mapOf(*action), ignoreConsumer) companion object { private fun Map>.copy(): MutableMap> { - val next: MutableMap> = mutableMapOf() + val next: MutableMap> = linkedMapOf() for ((action, codes) in this) { - next[action] = codes.toMutableSet() + // Use LinkedHashSet to save and display controls same order as entered by player after the restart. + next[action] = LinkedHashSet(codes) } return next } diff --git a/src/main/java/de/bixilon/minosoft/config/profile/delegate/types/EnumDelegate.kt b/src/main/java/de/bixilon/minosoft/config/profile/delegate/types/EnumDelegate.kt index 786423a4e..90db5dfae 100644 --- a/src/main/java/de/bixilon/minosoft/config/profile/delegate/types/EnumDelegate.kt +++ b/src/main/java/de/bixilon/minosoft/config/profile/delegate/types/EnumDelegate.kt @@ -1,6 +1,6 @@ /* * Minosoft - * Copyright (C) 2020-2023 Moritz Zwerger + * Copyright (C) 2020-2026 Moritz Zwerger * * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * @@ -20,7 +20,7 @@ import de.bixilon.minosoft.config.profile.profiles.Profile open class EnumDelegate>( override val profile: Profile, default: T, - values: ValuesEnum, + val values: ValuesEnum, ) : SimpleDelegate(profile, default) { override fun validate(value: T) = Unit diff --git a/src/main/java/de/bixilon/minosoft/data/language/IntegratedLanguage.kt b/src/main/java/de/bixilon/minosoft/data/language/IntegratedLanguage.kt index 40a18b76f..b341895e7 100644 --- a/src/main/java/de/bixilon/minosoft/data/language/IntegratedLanguage.kt +++ b/src/main/java/de/bixilon/minosoft/data/language/IntegratedLanguage.kt @@ -1,6 +1,6 @@ /* * Minosoft - * Copyright (C) 2020-2023 Moritz Zwerger + * Copyright (C) 2020-2026 Moritz Zwerger * * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * @@ -27,7 +27,7 @@ object IntegratedLanguage { fun load(name: String) { Log.log(LogMessageType.LOADING, LogLevels.VERBOSE) { "Loading language files (${name})" } - val language = LanguageUtil.load(name, null, IntegratedAssets.DEFAULT, minosoft("language/")) + val language = LanguageUtil.load(name, null, IntegratedAssets.DEFAULT, minosoft("language/"), integrated = false) LANGUAGE.translators[Namespaces.MINOSOFT] = language } } diff --git a/src/main/java/de/bixilon/minosoft/data/language/LanguageUtil.kt b/src/main/java/de/bixilon/minosoft/data/language/LanguageUtil.kt index 34a40e57c..b507cf5c1 100644 --- a/src/main/java/de/bixilon/minosoft/data/language/LanguageUtil.kt +++ b/src/main/java/de/bixilon/minosoft/data/language/LanguageUtil.kt @@ -1,6 +1,6 @@ /* * Minosoft - * Copyright (C) 2020-2025 Moritz Zwerger + * Copyright (C) 2020-2026 Moritz Zwerger * * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * @@ -99,7 +99,7 @@ object LanguageUtil { } - fun load(language: String, version: Version?, assets: AssetsManager, path: ResourceLocation = ResourceLocation.of("lang/")): Translator { + fun load(language: String, version: Version?, assets: AssetsManager, path: ResourceLocation = ResourceLocation.of("lang/"), integrated: Boolean = true): Translator { val name = language.lowercase() val json = version != null && version.jsonLanguage @@ -114,6 +114,10 @@ object LanguageUtil { } loadLanguage(FALLBACK_LANGUAGE, assets, json, path)?.let { translators += it } + if (integrated) { + translators += IntegratedLanguage.LANGUAGE + } + if (translators.size == 1) { return translators.first() } diff --git a/src/main/java/de/bixilon/minosoft/gui/rendering/gui/elements/input/button/AbstractButtonElement.kt b/src/main/java/de/bixilon/minosoft/gui/rendering/gui/elements/input/button/AbstractButtonElement.kt index bdc993bf2..54eaf8c84 100644 --- a/src/main/java/de/bixilon/minosoft/gui/rendering/gui/elements/input/button/AbstractButtonElement.kt +++ b/src/main/java/de/bixilon/minosoft/gui/rendering/gui/elements/input/button/AbstractButtonElement.kt @@ -15,7 +15,6 @@ package de.bixilon.minosoft.gui.rendering.gui.elements.input.button import de.bixilon.kmath.vec.vec2.f.Vec2f import de.bixilon.minosoft.config.key.KeyCodes -import de.bixilon.minosoft.data.registries.identified.Namespaces.minecraft import de.bixilon.minosoft.gui.rendering.gui.GUIRenderer import de.bixilon.minosoft.gui.rendering.gui.atlas.AtlasElement import de.bixilon.minosoft.gui.rendering.gui.elements.Element @@ -28,16 +27,18 @@ import de.bixilon.minosoft.gui.rendering.gui.elements.text.TextElement import de.bixilon.minosoft.gui.rendering.gui.input.mouse.MouseActions import de.bixilon.minosoft.gui.rendering.gui.input.mouse.MouseButtons import de.bixilon.minosoft.gui.rendering.gui.mesh.GUIVertexOptions +import de.bixilon.minosoft.gui.rendering.gui.mesh.GUIVertexOptions.Companion.copy import de.bixilon.minosoft.gui.rendering.gui.mesh.consumer.GuiVertexConsumer import de.bixilon.minosoft.gui.rendering.system.window.CursorShapes import de.bixilon.minosoft.gui.rendering.system.window.KeyChangeTypes +import de.bixilon.minosoft.data.registries.identified.Namespaces.minecraft abstract class AbstractButtonElement( guiRenderer: GUIRenderer, text: Any, disabled: Boolean = false, ) : Element(guiRenderer) { - protected val textElement = TextElement(guiRenderer, text, background = null, parent = this) + val textElement = TextElement(guiRenderer, text, background = null, parent = this) protected abstract val disabledAtlas: AtlasElement? protected abstract val normalAtlas: AtlasElement? protected abstract val hoveredAtlas: AtlasElement? @@ -80,7 +81,7 @@ abstract class AbstractButtonElement( if (_disabled == value) { return } - _disabled = disabled + _disabled = value forceApply() } @@ -113,8 +114,10 @@ abstract class AbstractButtonElement( background.size = size val textSize = textElement.size - background.render(offset, consumer, options) - textElement.render(offset + Vec2f(HorizontalAlignments.CENTER.getOffset(size.x, textSize.x), VerticalAlignments.CENTER.getOffset(size.y, textSize.y)), consumer, options) + val renderOptions = if (disabled) options.copy(alpha = 0.4f) else options + + background.render(offset, consumer, renderOptions) + textElement.render(offset + Vec2f(HorizontalAlignments.CENTER.getOffset(size.x, textSize.x), VerticalAlignments.CENTER.getOffset(size.y, textSize.y)), consumer, renderOptions) } override fun forceSilentApply() { @@ -188,7 +191,7 @@ abstract class AbstractButtonElement( } private companion object { - val CLICK_SOUND = minecraft("ui.button.click") + val CLICK_SOUND = minecraft("ui.button.click") // This still doesnt play sound for some reason... const val TEXT_PADDING = 4 } } diff --git a/src/main/java/de/bixilon/minosoft/gui/rendering/gui/elements/input/slider/SliderElement.kt b/src/main/java/de/bixilon/minosoft/gui/rendering/gui/elements/input/slider/SliderElement.kt new file mode 100644 index 000000000..1d01f947f --- /dev/null +++ b/src/main/java/de/bixilon/minosoft/gui/rendering/gui/elements/input/slider/SliderElement.kt @@ -0,0 +1,229 @@ +/* + * Minosoft + * Copyright (C) 2020-2025 Moritz Zwerger + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If not, see . + * + * This software is not affiliated with Mojang AB, the original developer of Minecraft. + */ + +package de.bixilon.minosoft.gui.rendering.gui.elements.input.slider + +import de.bixilon.kmath.vec.vec2.f.Vec2f +import de.bixilon.minosoft.data.registries.identified.Namespaces.minecraft +import de.bixilon.minosoft.gui.rendering.gui.GUIRenderer +import de.bixilon.minosoft.gui.rendering.gui.elements.Element +import de.bixilon.minosoft.gui.rendering.gui.elements.primitive.AtlasImageElement +import de.bixilon.minosoft.gui.rendering.gui.elements.text.TextElement +import de.bixilon.minosoft.gui.rendering.gui.input.MouseCapturing +import de.bixilon.minosoft.gui.rendering.gui.mesh.GUIVertexOptions.Companion.copy +import de.bixilon.minosoft.gui.rendering.gui.input.mouse.MouseActions +import de.bixilon.minosoft.gui.rendering.gui.input.mouse.MouseButtons +import de.bixilon.minosoft.gui.rendering.gui.mesh.GUIVertexOptions +import de.bixilon.minosoft.gui.rendering.gui.mesh.consumer.GuiVertexConsumer +import de.bixilon.minosoft.gui.rendering.system.window.CursorShapes +import kotlin.math.roundToInt + +class SliderElement( + guiRenderer: GUIRenderer, + private val label: String, + private val min: Float, + private val max: Float, + initialValue: Float, + private val onChange: (Float) -> Unit +) : Element(guiRenderer), MouseCapturing { + private val buttonAtlas = guiRenderer.atlas[BUTTON_ATLAS] + + var textElement: TextElement + private var isDragging = false + private var isHovered = false + private var isHandleHovered = false + + var disabled: Boolean = false + set(value) { + if (field == value) return + field = value + cacheUpToDate = false + } + + override val isCapturingMouse: Boolean get() = isDragging + + var value: Float = initialValue.coerceIn(min, max) + set(value) { + val clamped = value.coerceIn(min, max) + if (field != clamped) { + field = clamped + updateText() + onChange(clamped) + cacheUpToDate = false + } + } + + init { + textElement = TextElement(guiRenderer, getDisplayText(), background = null, parent = this) + updateText() + size = Vec2f(textElement.size.x + TEXT_PADDING * 2, SLIDER_HEIGHT) + } + + private fun getDisplayText(): String { + return "$label: ${value.roundToInt()}" + } + + private fun updateText() { + textElement.text = getDisplayText() + textElement.silentApply() + if (size.x == 0.0f) { + size = Vec2f(textElement.size.x + TEXT_PADDING * 2, SLIDER_HEIGHT) + } + } + + override fun forceSilentApply() { + textElement.silentApply() + cacheUpToDate = false + } + + override fun forceRender(offset: Vec2f, consumer: GuiVertexConsumer, options: GUIVertexOptions?) { + val size = size + val renderOptions = if (disabled) options.copy(alpha = 0.4f) else options + + val trackTexture = buttonAtlas?.get("disabled") ?: guiRenderer.context.textures.whiteTexture + val trackBackground = AtlasImageElement(guiRenderer, trackTexture) + trackBackground.size = size + trackBackground.render(offset, consumer, renderOptions) + + val normalizedValue = (value - min) / (max - min) + val handleWidth = HANDLE_WIDTH + val trackWidth = size.x - handleWidth + val handleX = trackWidth * normalizedValue + + val handleTexture = if (disabled) { + buttonAtlas?.get("disabled") + } else if (isHandleHovered || isDragging) { + buttonAtlas?.get("hovered") + } else { + buttonAtlas?.get("normal") + } ?: guiRenderer.context.textures.whiteTexture + + val slider = AtlasImageElement(guiRenderer, handleTexture) + slider.size = Vec2f(handleWidth, SLIDER_HEIGHT) + slider.render(offset + Vec2f(handleX, 0.0f), consumer, renderOptions) + + val textSize = textElement.size + val textX = (size.x - textSize.x) / 2 + val textY = (size.y - textSize.y) / 2 + textElement.render(offset + Vec2f(textX, textY), consumer, renderOptions) + } + + override fun onMouseAction(position: Vec2f, button: MouseButtons, action: MouseActions, count: Int): Boolean { + if (disabled) { + return true + } + if (button != MouseButtons.LEFT) { + return true + } + + when (action) { + MouseActions.PRESS -> { + isDragging = true + updateValueFromPosition(position.x) + cacheUpToDate = false + } + MouseActions.RELEASE -> { + isDragging = false + context.window.resetCursor() + cacheUpToDate = false + } + } + + return true + } + + override fun onMouseMove(position: Vec2f, absolute: Vec2f): Boolean { + if (isDragging) { + updateValueFromPosition(position.x) + } + + if (!isDragging) { + val normalizedValue = (value - min) / (max - min) + val handleWidth = HANDLE_WIDTH + val trackWidth = size.x - handleWidth + val handleX = trackWidth * normalizedValue + + val wasHandleHovered = isHandleHovered + isHandleHovered = position.x >= handleX && position.x < handleX + handleWidth + + if (wasHandleHovered != isHandleHovered) { + cacheUpToDate = false + } + } + + return true + } + + override fun onMouseEnter(position: Vec2f, absolute: Vec2f): Boolean { + isHovered = true + context.window.cursorShape = CursorShapes.HAND + + val normalizedValue = (value - min) / (max - min) + val handleWidth = HANDLE_WIDTH + val trackWidth = size.x - handleWidth + val handleX = trackWidth * normalizedValue + + isHandleHovered = position.x >= handleX && position.x < handleX + handleWidth + cacheUpToDate = false + return true + } + + override fun onMouseLeave(): Boolean { + isHovered = false + isHandleHovered = false + if (!isDragging) { + context.window.resetCursor() + } + cacheUpToDate = false + return super.onMouseLeave() + } + + override fun onMouseActionOutside(relativeX: Float, button: MouseButtons, action: MouseActions): Boolean { + if (!isDragging) return false + + if (button == MouseButtons.LEFT && action == MouseActions.RELEASE) { + isDragging = false + context.window.resetCursor() + cacheUpToDate = false + return true + } + return false + } + + override fun onMouseMoveOutside(relativeX: Float): Boolean { + if (!isDragging) return false + updateValueFromPosition(relativeX) + return true + } + + private fun updateValueFromPosition(x: Float) { + val handleWidth = HANDLE_WIDTH + val trackWidth = size.x - handleWidth + + if (trackWidth <= 0) { + value = min + return + } + + val normalizedX = (x - handleWidth / 2) / trackWidth + value = min + normalizedX * (max - min) + } + + companion object { + val BUTTON_ATLAS = minecraft("elements/button") + private const val TEXT_PADDING = 4.0f + private const val SLIDER_HEIGHT = 20.0f + private const val HANDLE_WIDTH = 8.0f + } +} + diff --git a/src/main/java/de/bixilon/minosoft/gui/rendering/gui/elements/input/textbox/TextBoxElement.kt b/src/main/java/de/bixilon/minosoft/gui/rendering/gui/elements/input/textbox/TextBoxElement.kt new file mode 100644 index 000000000..eb42fac08 --- /dev/null +++ b/src/main/java/de/bixilon/minosoft/gui/rendering/gui/elements/input/textbox/TextBoxElement.kt @@ -0,0 +1,146 @@ +/* + * Minosoft + * Copyright (C) 2020-2025 Moritz Zwerger + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If not, see . + * + * This software is not affiliated with Mojang AB, the original developer of Minecraft. + */ + +package de.bixilon.minosoft.gui.rendering.gui.elements.input.textbox + +import de.bixilon.kmath.vec.vec2.f.Vec2f +import de.bixilon.minosoft.config.key.KeyCodes +import de.bixilon.minosoft.data.text.TextComponent +import de.bixilon.minosoft.data.text.formatting.color.ChatColors +import de.bixilon.minosoft.data.text.formatting.color.RGBAColor +import de.bixilon.minosoft.gui.rendering.font.renderer.element.TextRenderProperties +import de.bixilon.minosoft.gui.rendering.gui.GUIRenderer +import de.bixilon.minosoft.gui.rendering.gui.elements.Element +import de.bixilon.minosoft.gui.rendering.gui.elements.HorizontalAlignments +import de.bixilon.minosoft.gui.rendering.gui.elements.primitive.ColorElement +import de.bixilon.minosoft.gui.rendering.gui.elements.text.TextElement +import de.bixilon.minosoft.gui.rendering.gui.gui.elements.input.TextInputElement +import de.bixilon.minosoft.gui.rendering.gui.input.mouse.MouseActions +import de.bixilon.minosoft.gui.rendering.gui.input.mouse.MouseButtons +import de.bixilon.minosoft.gui.rendering.gui.mesh.GUIVertexOptions +import de.bixilon.minosoft.gui.rendering.gui.mesh.consumer.GuiVertexConsumer +import de.bixilon.minosoft.gui.rendering.system.window.CursorShapes +import de.bixilon.minosoft.gui.rendering.system.window.KeyChangeTypes + +open class TextBoxElement( + guiRenderer: GUIRenderer, + value: String = "", + placeholder: String = "", + maxLength: Int = Int.MAX_VALUE, + onChangeCallback: () -> Unit = {}, + parent: Element? = null, +) : Element(guiRenderer) { + + private val inputElement = TextInputElement( + guiRenderer = guiRenderer, + value = value, + maxLength = maxLength, + onChangeCallback = onChangeCallback, + background = null, + properties = TextRenderProperties(HorizontalAlignments.LEFT), + parent = this + ) + + private val placeholderElement = TextElement( + guiRenderer = guiRenderer, + text = TextComponent(placeholder).color(ChatColors.DARK_GRAY), + background = null, + parent = this, + properties = TextRenderProperties(HorizontalAlignments.LEFT) + ) + + private val borderElement = ColorElement(guiRenderer, Vec2f.EMPTY, BORDER_COLOR) + private val backgroundElement = ColorElement(guiRenderer, Vec2f.EMPTY, BACKGROUND_COLOR) + + val value: String get() = inputElement.value + + init { + this.parent = parent + forceSilentApply() + } + + override var size: Vec2f + get() = super.size + set(value) { + super.size = value + val innerWidth = value.x - PADDING * 2 - BORDER_WIDTH * 2 + val innerHeight = value.y - PADDING * 2 - BORDER_WIDTH * 2 + inputElement.size = Vec2f(innerWidth, innerHeight) + placeholderElement.prefMaxSize = Vec2f(innerWidth, innerHeight) + borderElement.size = value + backgroundElement.size = Vec2f(value.x - BORDER_WIDTH * 2, value.y - BORDER_WIDTH * 2) + cacheUpToDate = false + } + + override fun forceRender(offset: Vec2f, consumer: GuiVertexConsumer, options: GUIVertexOptions?) { + + borderElement.render(offset, consumer, options) + + backgroundElement.render(offset + Vec2f(BORDER_WIDTH, BORDER_WIDTH), consumer, options) + + val innerHeight = size.y - BORDER_WIDTH * 2 + val textHeight = TextRenderProperties.DEFAULT.lineHeight + val verticalOffset = (innerHeight - textHeight) / 2 + val inputOffset = offset + Vec2f(BORDER_WIDTH + PADDING, BORDER_WIDTH + verticalOffset) + + if (inputElement.value.isEmpty()) { + placeholderElement.render(inputOffset, consumer, options) + } + + inputElement.render(inputOffset, consumer, options) + } + + override fun forceSilentApply() { + inputElement.silentApply() + placeholderElement.silentApply() + cacheUpToDate = false + } + + override fun onChildChange(child: Element) { + cacheUpToDate = false + } + + override fun onMouseEnter(position: Vec2f, absolute: Vec2f): Boolean { + guiRenderer.context.window.cursorShape = CursorShapes.IBEAM + return true + } + + override fun onMouseLeave(): Boolean { + guiRenderer.context.window.resetCursor() + return true + } + + override fun onMouseAction(position: Vec2f, button: MouseButtons, action: MouseActions, count: Int): Boolean { + val innerOffset = Vec2f(BORDER_WIDTH + PADDING, BORDER_WIDTH + PADDING) + return inputElement.onMouseAction(position - innerOffset, button, action, count) + } + + override fun onKey(key: KeyCodes, type: KeyChangeTypes): Boolean { + return inputElement.onKey(key, type) + } + + override fun onCharPress(char: Int): Boolean { + return inputElement.onCharPress(char) + } + + override fun tick() { + inputElement.tick() + } + + companion object { + private val BORDER_COLOR = RGBAColor(160, 160, 160, 255) // Light gray border + private val BACKGROUND_COLOR = RGBAColor(0, 0, 0, 255) // Black background + private const val BORDER_WIDTH = 1.0f + private const val PADDING = 2.0f + } +} diff --git a/src/main/java/de/bixilon/minosoft/gui/rendering/gui/gui/screen/menu/Menu.kt b/src/main/java/de/bixilon/minosoft/gui/rendering/gui/gui/screen/menu/Menu.kt index db53edff6..b6ebdb30c 100644 --- a/src/main/java/de/bixilon/minosoft/gui/rendering/gui/gui/screen/menu/Menu.kt +++ b/src/main/java/de/bixilon/minosoft/gui/rendering/gui/gui/screen/menu/Menu.kt @@ -1,6 +1,6 @@ /* * Minosoft - * Copyright (C) 2020-2025 Moritz Zwerger + * Copyright (C) 2020-2026 Moritz Zwerger * * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * @@ -16,10 +16,13 @@ package de.bixilon.minosoft.gui.rendering.gui.gui.screen.menu import de.bixilon.kmath.vec.vec2.f.MVec2f import de.bixilon.kmath.vec.vec2.f.Vec2f import de.bixilon.minosoft.config.key.KeyCodes +import de.bixilon.minosoft.data.language.IntegratedLanguage +import de.bixilon.minosoft.data.language.LanguageUtil.i18n import de.bixilon.minosoft.gui.rendering.gui.GUIRenderer import de.bixilon.minosoft.gui.rendering.gui.elements.Element import de.bixilon.minosoft.gui.rendering.gui.gui.AbstractLayout import de.bixilon.minosoft.gui.rendering.gui.gui.screen.Screen +import de.bixilon.minosoft.gui.rendering.gui.input.MouseCapturing import de.bixilon.minosoft.gui.rendering.gui.input.mouse.MouseActions import de.bixilon.minosoft.gui.rendering.gui.input.mouse.MouseButtons import de.bixilon.minosoft.gui.rendering.gui.mesh.GUIVertexOptions @@ -37,6 +40,8 @@ abstract class Menu( override var activeElement: Element? = null override var activeDragElement: Element? = null + private var capturingElement: MouseCapturing? = null + private var capturingElementOffset: Vec2f = Vec2f.EMPTY override fun forceSilentApply() { val elementWidth = maxOf(minOf(preferredElementWidth, size.x / 3), 0.0f) @@ -82,6 +87,17 @@ abstract class Menu( } override fun onMouseMove(position: Vec2f, absolute: Vec2f): Boolean { + // Check if an element is capturing mouse + val capturing = capturingElement + if (capturing != null && capturing.isCapturingMouse) { + val relativeX = position.x - capturingElementOffset.x + capturing.onMouseMoveOutside(relativeX) + return true + } + if (capturing != null && !capturing.isCapturingMouse) { + capturingElement = null + } + super.onMouseMove(position, absolute) return true } @@ -92,8 +108,26 @@ abstract class Menu( } override fun onMouseAction(position: Vec2f, button: MouseButtons, action: MouseActions, count: Int): Boolean { + val capturing = capturingElement + if (capturing != null && capturing.isCapturingMouse) { + val relativeX = position.x - capturingElementOffset.x + if (capturing.onMouseActionOutside(relativeX, button, action)) { + if (!capturing.isCapturingMouse) { + capturingElement = null + } + return true + } + } + val (element, delta) = getAt(position) ?: return true element.onMouseAction(delta, button, action, count) + + // Track elements that start capturing mouse + if (element is MouseCapturing && element.isCapturingMouse) { + capturingElement = element + capturingElementOffset = Vec2f(position.x - delta.x, position.y - delta.y) + } + return true } @@ -221,4 +255,14 @@ abstract class Menu( private companion object { const val BUTTON_Y_MARGIN = 5.0f } + + @Deprecated("i18n") + protected fun translate(key: String): String { + return IntegratedLanguage.LANGUAGE.forceTranslate(key.i18n().translationKey).message + } + + @Deprecated("i18n") + protected fun formatEnabled(key: String, enabled: Boolean): String { + return "${translate(key)}: ${if (enabled) "ON" else "OFF"}" + } } diff --git a/src/main/java/de/bixilon/minosoft/gui/rendering/gui/gui/screen/menu/buttons/BooleanDelegateButton.kt b/src/main/java/de/bixilon/minosoft/gui/rendering/gui/gui/screen/menu/buttons/BooleanDelegateButton.kt new file mode 100644 index 000000000..504a0cfa8 --- /dev/null +++ b/src/main/java/de/bixilon/minosoft/gui/rendering/gui/gui/screen/menu/buttons/BooleanDelegateButton.kt @@ -0,0 +1,30 @@ +/* + * Minosoft + * Copyright (C) 2020-2026 Moritz Zwerger + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If not, see . + * + * This software is not affiliated with Mojang AB, the original developer of Minecraft. + */ + +package de.bixilon.minosoft.gui.rendering.gui.gui.screen.menu.buttons + +import de.bixilon.minosoft.data.language.translate.Translatable +import de.bixilon.minosoft.data.text.TextComponent +import de.bixilon.minosoft.data.text.formatting.color.ChatColors +import de.bixilon.minosoft.gui.rendering.gui.GUIRenderer +import kotlin.reflect.KMutableProperty0 + +class BooleanDelegateButton(guiRenderer: GUIRenderer, key: Translatable, delegate: KMutableProperty0) : DelegateButton(guiRenderer, key, delegate) { + + override fun formatValue(value: Boolean) = if (value) TextComponent("ON").color(ChatColors.GREEN) else TextComponent("OFF").color(ChatColors.RED) // TODO: i18n + + override fun submit() { + super.submit() + delegate.set(!value) + } +} diff --git a/src/main/java/de/bixilon/minosoft/gui/rendering/gui/gui/screen/menu/buttons/DelegateButton.kt b/src/main/java/de/bixilon/minosoft/gui/rendering/gui/gui/screen/menu/buttons/DelegateButton.kt new file mode 100644 index 000000000..bb83e8f0e --- /dev/null +++ b/src/main/java/de/bixilon/minosoft/gui/rendering/gui/gui/screen/menu/buttons/DelegateButton.kt @@ -0,0 +1,42 @@ +/* + * Minosoft + * Copyright (C) 2020-2026 Moritz Zwerger + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If not, see . + * + * This software is not affiliated with Mojang AB, the original developer of Minecraft. + */ + +package de.bixilon.minosoft.gui.rendering.gui.gui.screen.menu.buttons + +import de.bixilon.kutil.observer.DataObserver.Companion.observe +import de.bixilon.minosoft.data.language.translate.Translatable +import de.bixilon.minosoft.data.text.BaseComponent +import de.bixilon.minosoft.data.text.ChatComponent +import de.bixilon.minosoft.gui.rendering.gui.GUIRenderer +import de.bixilon.minosoft.gui.rendering.gui.elements.input.button.ButtonElement +import kotlin.reflect.KMutableProperty0 + +abstract class DelegateButton(guiRenderer: GUIRenderer, key: Translatable, val delegate: KMutableProperty0) : ButtonElement(guiRenderer, "", onSubmit = {}) { + private val key = ChatComponent.of(key, translator = guiRenderer.session.language) + protected var value: T = delegate.get() + private set + + init { + delegate.observe(this) { value = it; updateText() } + } + + init { + updateText() + } + + protected fun updateText() { + textElement.text = BaseComponent(key, ": ", formatValue(value)) + } + + protected abstract fun formatValue(value: T): ChatComponent +} diff --git a/src/main/java/de/bixilon/minosoft/gui/rendering/gui/gui/screen/menu/buttons/EnumDelegateButton.kt b/src/main/java/de/bixilon/minosoft/gui/rendering/gui/gui/screen/menu/buttons/EnumDelegateButton.kt new file mode 100644 index 000000000..afb659416 --- /dev/null +++ b/src/main/java/de/bixilon/minosoft/gui/rendering/gui/gui/screen/menu/buttons/EnumDelegateButton.kt @@ -0,0 +1,44 @@ +/* + * Minosoft + * Copyright (C) 2020-2026 Moritz Zwerger + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If not, see . + * + * This software is not affiliated with Mojang AB, the original developer of Minecraft. + */ + +package de.bixilon.minosoft.gui.rendering.gui.gui.screen.menu.buttons + +import de.bixilon.kutil.cast.CastUtil.cast +import de.bixilon.kutil.enums.ValuesEnum +import de.bixilon.kutil.observer.ObserveUtil.observer +import de.bixilon.minosoft.config.profile.delegate.types.EnumDelegate +import de.bixilon.minosoft.data.language.translate.Translatable +import de.bixilon.minosoft.gui.rendering.gui.GUIRenderer +import de.bixilon.minosoft.util.KUtil.format +import kotlin.reflect.KMutableProperty0 + +class EnumDelegateButton>(guiRenderer: GUIRenderer, key: Translatable, delegate: KMutableProperty0) : DelegateButton(guiRenderer, key, delegate) { + private val observer = delegate.observer.cast>() + + override fun formatValue(value: T) = value.format() + + override fun submit() { + super.submit() + delegate.set(observer.values.nextPort(value)) + } + + + @Deprecated("crash in kutil, fixed in kutil 1.31") + fun > ValuesEnum.nextPort(current: X): X { + val next = current.ordinal + 1 + if (next >= VALUES.size) { + return VALUES[0] + } + return VALUES[next] + } +} diff --git a/src/main/java/de/bixilon/minosoft/gui/rendering/gui/gui/screen/menu/options/ChatSettingsMenu.kt b/src/main/java/de/bixilon/minosoft/gui/rendering/gui/gui/screen/menu/options/ChatSettingsMenu.kt new file mode 100644 index 000000000..11610f6ca --- /dev/null +++ b/src/main/java/de/bixilon/minosoft/gui/rendering/gui/gui/screen/menu/options/ChatSettingsMenu.kt @@ -0,0 +1,67 @@ +/* + * Minosoft + * Copyright (C) 2020-2026 Moritz Zwerger + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If not, see . + * + * This software is not affiliated with Mojang AB, the original developer of Minecraft. + */ + +package de.bixilon.minosoft.gui.rendering.gui.gui.screen.menu.options + +import de.bixilon.kmath.vec.vec2.f.Vec2f +import de.bixilon.minosoft.data.language.LanguageUtil.i18n +import de.bixilon.minosoft.gui.rendering.font.renderer.element.TextRenderProperties +import de.bixilon.minosoft.gui.rendering.gui.GUIRenderer +import de.bixilon.minosoft.gui.rendering.gui.elements.HorizontalAlignments +import de.bixilon.minosoft.gui.rendering.gui.elements.input.button.ButtonElement +import de.bixilon.minosoft.gui.rendering.gui.elements.spacer.SpacerElement +import de.bixilon.minosoft.gui.rendering.gui.elements.text.TextElement +import de.bixilon.minosoft.gui.rendering.gui.gui.GUIBuilder +import de.bixilon.minosoft.gui.rendering.gui.gui.LayoutedGUIElement +import de.bixilon.minosoft.gui.rendering.gui.gui.screen.menu.Menu +import de.bixilon.minosoft.gui.rendering.gui.gui.screen.menu.buttons.BooleanDelegateButton +import de.bixilon.minosoft.gui.rendering.gui.gui.screen.menu.buttons.EnumDelegateButton + +class ChatSettingsMenu(guiRenderer: GUIRenderer) : Menu(guiRenderer, PREFERRED_WIDTH) { + private val chatProfile = guiRenderer.context.session.profiles.gui.chat + + private val hiddenButton: ButtonElement + private val chatColorsButton: ButtonElement + + init { + this += TextElement(guiRenderer, "menu.options.chat.title".i18n(), background = null, properties = TextRenderProperties(HorizontalAlignments.CENTER, scale = 2.0f)) + this += SpacerElement(guiRenderer, Vec2f(0.0f, 10.0f)) + + hiddenButton = ButtonElement(guiRenderer, formatEnabled("menu.options.chat.hidden", chatProfile.hidden)) { + chatProfile.hidden = !chatProfile.hidden + hiddenButton.textElement.text = formatEnabled("menu.options.chat.hidden", chatProfile.hidden) + } + this += hiddenButton + + this += BooleanDelegateButton(guiRenderer, "menu.options.chat.text_filtering".i18n(), delegate = chatProfile::textFiltering) + + chatColorsButton = ButtonElement(guiRenderer, formatEnabled("menu.options.chat.colors", chatProfile.chatColors)) { + chatProfile.chatColors = !chatProfile.chatColors + chatColorsButton.textElement.text = formatEnabled("menu.options.chat.colors", chatProfile.chatColors) + } + this += chatColorsButton + + this += EnumDelegateButton(guiRenderer, "menu.options.chat.mode".i18n(), delegate = chatProfile::chatMode) + + this += SpacerElement(guiRenderer, Vec2f(0.0f, 10.0f)) + this += ButtonElement(guiRenderer, "menu.options.done".i18n()) { guiRenderer.gui.pop() } + } + + companion object : GUIBuilder> { + private const val PREFERRED_WIDTH = 200.0f + + override fun build(guiRenderer: GUIRenderer): LayoutedGUIElement { + return LayoutedGUIElement(ChatSettingsMenu(guiRenderer)) + } + } +} diff --git a/src/main/java/de/bixilon/minosoft/gui/rendering/gui/gui/screen/menu/options/CloudSettingsMenu.kt b/src/main/java/de/bixilon/minosoft/gui/rendering/gui/gui/screen/menu/options/CloudSettingsMenu.kt new file mode 100644 index 000000000..6150a3767 --- /dev/null +++ b/src/main/java/de/bixilon/minosoft/gui/rendering/gui/gui/screen/menu/options/CloudSettingsMenu.kt @@ -0,0 +1,92 @@ +/* + * Minosoft + * Copyright (C) 2020-2025 Moritz Zwerger + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If not, see . + * + * This software is not affiliated with Mojang AB, the original developer of Minecraft. + */ + +package de.bixilon.minosoft.gui.rendering.gui.gui.screen.menu.options + +import de.bixilon.kmath.vec.vec2.f.Vec2f +import de.bixilon.minosoft.data.language.LanguageUtil.i18n +import de.bixilon.minosoft.gui.rendering.font.renderer.element.TextRenderProperties +import de.bixilon.minosoft.gui.rendering.gui.GUIRenderer +import de.bixilon.minosoft.gui.rendering.gui.elements.HorizontalAlignments +import de.bixilon.minosoft.gui.rendering.gui.elements.input.button.ButtonElement +import de.bixilon.minosoft.gui.rendering.gui.elements.input.slider.SliderElement +import de.bixilon.minosoft.gui.rendering.gui.elements.spacer.SpacerElement +import de.bixilon.minosoft.gui.rendering.gui.elements.text.TextElement +import de.bixilon.minosoft.gui.rendering.gui.gui.GUIBuilder +import de.bixilon.minosoft.gui.rendering.gui.gui.LayoutedGUIElement +import de.bixilon.minosoft.gui.rendering.gui.gui.screen.menu.Menu + +class CloudSettingsMenu(guiRenderer: GUIRenderer) : Menu(guiRenderer, PREFERRED_WIDTH) { + private val cloudProfile = guiRenderer.context.profile.sky.clouds + + private val cloudsEnabledButton: ButtonElement + private val cloudsFlatButton: ButtonElement + private val cloudsMovementButton: ButtonElement + private val maxDistanceSlider: SliderElement + private val layersSlider: SliderElement + + init { + this += TextElement(guiRenderer, "menu.options.clouds.title".i18n(), background = null, properties = TextRenderProperties(HorizontalAlignments.CENTER, scale = 2.0f)) + this += SpacerElement(guiRenderer, Vec2f(0.0f, 10.0f)) + + cloudsEnabledButton = ButtonElement(guiRenderer, formatEnabled("menu.options.clouds.enabled", cloudProfile.enabled)) { + cloudProfile.enabled = !cloudProfile.enabled + cloudsEnabledButton.textElement.text = formatEnabled("menu.options.clouds.enabled", cloudProfile.enabled) + updateDisabledStates() + } + this += cloudsEnabledButton + + cloudsFlatButton = ButtonElement(guiRenderer, formatEnabled("menu.options.clouds.flat", cloudProfile.flat)) { + cloudProfile.flat = !cloudProfile.flat + cloudsFlatButton.textElement.text = formatEnabled("menu.options.clouds.flat", cloudProfile.flat) + } + this += cloudsFlatButton + + cloudsMovementButton = ButtonElement(guiRenderer, formatEnabled("menu.options.clouds.movement", cloudProfile.movement)) { + cloudProfile.movement = !cloudProfile.movement + cloudsMovementButton.textElement.text = formatEnabled("menu.options.clouds.movement", cloudProfile.movement) + } + this += cloudsMovementButton + + maxDistanceSlider = SliderElement(guiRenderer, translate("menu.options.clouds.max_distance"), 0.0f, 200.0f, cloudProfile.maxDistance) { + cloudProfile.maxDistance = it + } + this += maxDistanceSlider + + layersSlider = SliderElement(guiRenderer, translate("menu.options.clouds.layers"), 1.0f, 3.0f, cloudProfile.layers.toFloat()) { + cloudProfile.layers = it.toInt() + } + this += layersSlider + + this += SpacerElement(guiRenderer, Vec2f(0.0f, 10.0f)) + this += ButtonElement(guiRenderer, "menu.options.done".i18n()) { guiRenderer.gui.pop() } + + updateDisabledStates() + } + + private fun updateDisabledStates() { + val cloudsDisabled = !cloudProfile.enabled + cloudsFlatButton.disabled = cloudsDisabled + cloudsMovementButton.disabled = cloudsDisabled + maxDistanceSlider.disabled = cloudsDisabled + layersSlider.disabled = cloudsDisabled + } + + companion object : GUIBuilder> { + private const val PREFERRED_WIDTH = 200.0f + + override fun build(guiRenderer: GUIRenderer): LayoutedGUIElement { + return LayoutedGUIElement(CloudSettingsMenu(guiRenderer)) + } + } +} diff --git a/src/main/java/de/bixilon/minosoft/gui/rendering/gui/gui/screen/menu/options/ControlsSettingsMenu.kt b/src/main/java/de/bixilon/minosoft/gui/rendering/gui/gui/screen/menu/options/ControlsSettingsMenu.kt new file mode 100644 index 000000000..2b6051ad6 --- /dev/null +++ b/src/main/java/de/bixilon/minosoft/gui/rendering/gui/gui/screen/menu/options/ControlsSettingsMenu.kt @@ -0,0 +1,980 @@ +/* + * Minosoft + * Copyright (C) 2020-2025 Moritz Zwerger + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If not, see . + * + * This software is not affiliated with Mojang AB, the original developer of Minecraft. + */ + +package de.bixilon.minosoft.gui.rendering.gui.gui.screen.menu.options + +import de.bixilon.kmath.vec.vec2.f.MVec2f +import de.bixilon.kmath.vec.vec2.f.Vec2f +import de.bixilon.minosoft.config.key.KeyActions +import de.bixilon.minosoft.config.key.KeyBinding +import de.bixilon.minosoft.config.key.KeyCodes +import de.bixilon.minosoft.data.language.IntegratedLanguage +import de.bixilon.minosoft.data.language.LanguageUtil.i18n +import de.bixilon.minosoft.data.registries.identified.Namespaces.minosoft +import de.bixilon.minosoft.data.registries.identified.ResourceLocation +import de.bixilon.minosoft.data.text.TextComponent +import de.bixilon.minosoft.data.text.formatting.color.ChatColors +import de.bixilon.minosoft.data.text.formatting.color.RGBAColor +import de.bixilon.minosoft.gui.rendering.font.renderer.element.TextRenderProperties +import de.bixilon.minosoft.gui.rendering.gui.GUIRenderer +import de.bixilon.minosoft.gui.rendering.gui.elements.Element +import de.bixilon.minosoft.gui.rendering.gui.elements.HorizontalAlignments +import de.bixilon.minosoft.gui.rendering.gui.elements.input.button.ButtonElement +import de.bixilon.minosoft.gui.rendering.gui.elements.input.slider.SliderElement +import de.bixilon.minosoft.gui.rendering.gui.elements.input.textbox.TextBoxElement +import de.bixilon.minosoft.gui.rendering.gui.elements.primitive.ColorElement +import de.bixilon.minosoft.gui.rendering.gui.gui.elements.input.TextInputElement +import de.bixilon.minosoft.gui.rendering.gui.elements.text.TextElement +import de.bixilon.minosoft.gui.rendering.gui.gui.AbstractLayout +import de.bixilon.minosoft.gui.rendering.gui.gui.GUIBuilder +import de.bixilon.minosoft.gui.rendering.gui.gui.LayoutedGUIElement +import de.bixilon.minosoft.gui.rendering.gui.gui.screen.Screen +import de.bixilon.minosoft.gui.rendering.gui.input.mouse.MouseActions +import de.bixilon.minosoft.gui.rendering.gui.input.mouse.MouseButtons +import de.bixilon.minosoft.gui.rendering.gui.mesh.GUIVertexOptions +import de.bixilon.minosoft.gui.rendering.gui.mesh.consumer.GuiVertexConsumer +import de.bixilon.minosoft.gui.rendering.system.window.KeyChangeTypes + +class ControlsSettingsMenu(guiRenderer: GUIRenderer) : Screen(guiRenderer), AbstractLayout { + private val controlsProfile = guiRenderer.context.session.profiles.controls + + private val titleElement = TextElement(guiRenderer, "minosoft:key.title".i18n(), background = null, properties = TextRenderProperties(HorizontalAlignments.CENTER, scale = 2.0f)) + private val doneButton = ButtonElement(guiRenderer, "menu.options.done".i18n()) { guiRenderer.gui.pop() }.apply { parent = this@ControlsSettingsMenu } + private val resetButton = ButtonElement(guiRenderer, "minosoft:key.reset_all".i18n()) { resetAllBindings() }.apply { parent = this@ControlsSettingsMenu } + + private val listItems: MutableList = mutableListOf() + private val keyBindingEntries: MutableList = mutableListOf() + private var filteredItems: List = emptyList() + private var scrollOffset = 0 + private val maxVisibleEntries = 8 + + private var searchText: String = "" + private val searchBar = TextBoxElement(guiRenderer, "", "Search controls... (Search in quotes for key bindings)", maxLength = 50, onChangeCallback = { onSearchChanged() }).apply { parent = this@ControlsSettingsMenu } + + private var isDraggingScrollbar = false + private var dragStartY = 0f + private var dragStartScrollOffset = 0 + + private var editingEntry: KeyBindingEntry? = null + private val pressedKeys = linkedSetOf() + private var peakPressedKeys = listOf() + + override var activeElement: Element? = null + override var activeDragElement: Element? = null + private var focusedElement: Element? = null + + // Mouse sensitivity slider (range matches profile limits: 0.01 to 10.0, displayed as 1% to 1000%) + private val sensitivitySlider = SliderElement(guiRenderer, translate("minosoft:key.mouse_sensitivity"), 1.0f, 200.0f, controlsProfile.mouse.sensitivity * 100f) { + controlsProfile.mouse.sensitivity = it / 100.0f + }.apply { parent = this@ControlsSettingsMenu } + + init { + buildKeyBindingEntries() + filteredItems = listItems.toList() + updateDuplicateStatus() + forceSilentApply() + } + + private fun onSearchChanged() { + searchText = searchBar.value + updateFilteredItems() + scrollOffset = 0 + cacheUpToDate = false + } + + private fun updateFilteredItems() { + if (searchText.isBlank()) { + filteredItems = listItems.toList() + return + } + + val query = searchText.lowercase() + // Check if query is in quotes for key binding search + val isKeySearch = query.startsWith("\"") && query.endsWith("\"") && query.length > 2 + val keyQuery = if (isKeySearch) query.substring(1, query.length - 1) else null + + val matchingEntries = keyBindingEntries.filter { entry -> + val displayName = KeyBindingEntry.getDisplayName(entry.bindingName).lowercase() + val bindingPath = entry.bindingName.path.lowercase() + + if (keyQuery != null) { + // Search only key bindings when in quotes + val keyDisplayText = KeyBindingEntry.getKeyDisplayText(entry.getCurrentBinding()).lowercase() + keyDisplayText.contains(keyQuery) + } else { + // Normal search by name and path only + displayName.contains(query) || bindingPath.contains(query) + } + }.toSet() + + // Build filtered list including category headers that have matching entries + val result = mutableListOf() + var currentHeader: CategoryHeader? = null + var headerHasMatches = false + + for (item in listItems) { + when (item) { + is CategoryHeader -> { + currentHeader = item + headerHasMatches = false + } + is KeyBindingEntry -> { + if (item in matchingEntries) { + if (currentHeader != null && !headerHasMatches) { + result += currentHeader + headerHasMatches = true + } + result += item + } + } + } + } + + filteredItems = result + } + + private fun buildKeyBindingEntries() { + listItems.clear() + keyBindingEntries.clear() + + // Build entries from the categorized list of default keybindings + for ((category, bindings) in CATEGORIZED_KEYBINDINGS) { + val header = CategoryHeader(guiRenderer, category).apply { parent = this@ControlsSettingsMenu } + listItems += header + for ((name, defaultBinding) in bindings) { + val currentBinding = controlsProfile.bindings[name] ?: defaultBinding + val entry = KeyBindingEntry(guiRenderer, name, currentBinding, defaultBinding, this).apply { parent = this@ControlsSettingsMenu } + listItems += entry + keyBindingEntries += entry + } + } + } + + private fun resetAllBindings() { + for (entry in keyBindingEntries) { + val existingBinding = controlsProfile.bindings[entry.bindingName] + if (existingBinding != null) { + existingBinding.action.clear() + for ((action, codes) in entry.defaultBinding.action) { + existingBinding.action[action] = codes.toCollection(linkedSetOf()) + } + entry.updateBinding(existingBinding) + } else { + entry.updateBinding(entry.defaultBinding) + } + } + controlsProfile.storage?.invalidate() + guiRenderer.context.input.bindings.clear() + updateDuplicateStatus() + cacheUpToDate = false + } + + /** + * Builds a map of binding signature to list of binding names that use it. + * Used to detect duplicate key bindings. + */ + private fun buildBindingMap(): Map> { + val bindingMap = mutableMapOf>() + for (entry in keyBindingEntries) { + val signature = KeyBindingEntry.getBindingSignature(entry.getCurrentBinding()) + if (signature.isNotEmpty()) { + bindingMap.getOrPut(signature) { mutableListOf() }.add(entry.bindingName) + } + } + return bindingMap + } + + // Updates the duplicate status for all key binding entries then changes their color to red. + fun updateDuplicateStatus() { + val bindingMap = buildBindingMap() + for (entry in keyBindingEntries) { + val signature = KeyBindingEntry.getBindingSignature(entry.getCurrentBinding()) + val isDuplicate = signature.isNotEmpty() && (bindingMap[signature]?.size ?: 0) > 1 + entry.setDuplicateStatus(isDuplicate) + } + } + + fun startEditing(entry: KeyBindingEntry) { + editingEntry?.isEditing = false + + editingEntry = entry + entry.isEditing = true + pressedKeys.clear() + peakPressedKeys = emptyList() + cacheUpToDate = false + } + + fun stopEditing(keys: List) { + val entry = editingEntry ?: return + entry.isEditing = false + + if (keys.isNotEmpty() && KeyCodes.KEY_ESCAPE !in keys) { + val defaultAction = entry.defaultBinding.action.keys.firstOrNull() ?: KeyActions.CHANGE + + // Use LinkedHashSet to save and display controls same order as entered by player after the restart. + val newAction: Map> = if (keys.size == 1) { + linkedMapOf(defaultAction to linkedSetOf(keys.first())) + } else { + linkedMapOf( + KeyActions.MODIFIER to keys.dropLast(1).toCollection(linkedSetOf()), + defaultAction to linkedSetOf(keys.last()) + ) + } + + val existingBinding = controlsProfile.bindings[entry.bindingName] + if (existingBinding != null) { + existingBinding.action.clear() + for ((action, codes) in newAction) { + existingBinding.action[action] = codes.toCollection(linkedSetOf()) + } + entry.updateBinding(existingBinding) + } else { + val newBinding = KeyBinding(newAction) + controlsProfile.bindings[entry.bindingName] = newBinding + entry.updateBinding(newBinding) + } + + controlsProfile.storage?.invalidate() + guiRenderer.context.input.bindings.clear() + updateDuplicateStatus() + } + + editingEntry = null + pressedKeys.clear() + peakPressedKeys = emptyList() + cacheUpToDate = false + } + + private fun calculateElementWidth(): Float { + return maxOf(size.x * WIDTH_PERCENTAGE, MIN_BUTTON_WIDTH) + } + + private fun translate(key: String): String { + return IntegratedLanguage.LANGUAGE.forceTranslate(key.i18n().translationKey).message + } + + override fun forceSilentApply() { + titleElement.silentApply() + val elementWidth = calculateElementWidth() + + titleElement.prefMaxSize = Vec2f(elementWidth, -1f) + doneButton.size = Vec2f(elementWidth / 2 - 2f, doneButton.size.y) + resetButton.size = Vec2f(elementWidth / 2 - 2f, resetButton.size.y) + sensitivitySlider.size = Vec2f(elementWidth, sensitivitySlider.size.y) + searchBar.size = Vec2f(elementWidth, SEARCH_BAR_HEIGHT) + + for (item in listItems) { + when (item) { + is CategoryHeader -> { + item.setContainerWidth(elementWidth) + item.size = Vec2f(elementWidth, CATEGORY_HEIGHT) + } + is KeyBindingEntry -> item.size = Vec2f(elementWidth, ENTRY_HEIGHT) + } + } + + super.forceSilentApply() + cacheUpToDate = false + } + + override fun forceRender(offset: Vec2f, consumer: GuiVertexConsumer, options: GUIVertexOptions?) { + super.forceRender(offset, consumer, options) + + val screenSize = size + val elementWidth = calculateElementWidth() + val currentOffset = MVec2f(offset) + + val listHeight = maxVisibleEntries * (ENTRY_HEIGHT + ENTRY_Y_MARGIN) - ENTRY_Y_MARGIN + val totalHeight = titleElement.size.y + SPACING + + sensitivitySlider.size.y + SPACING + + listHeight + ENTRY_Y_MARGIN + + SEARCH_BAR_HEIGHT + SPACING + + SPACING + BUTTON_HEIGHT + + currentOffset.y += (screenSize.y - totalHeight) / 2 + currentOffset.x += (screenSize.x - elementWidth - SCROLLBAR_WIDTH - SCROLLBAR_MARGIN) / 2 + + titleElement.render(currentOffset.unsafe + Vec2f((elementWidth - titleElement.size.x) / 2, 0f), consumer, options) + currentOffset.y += titleElement.size.y + SPACING + + sensitivitySlider.render(currentOffset.unsafe + Vec2f((elementWidth - sensitivitySlider.size.x) / 2, 0f), consumer, options) + currentOffset.y += sensitivitySlider.size.y + SPACING + + val listStartY = currentOffset.y + val startIndex = scrollOffset + val endIndex = minOf(startIndex + maxVisibleEntries, filteredItems.size) + + for (i in startIndex until endIndex) { + val item = filteredItems[i] + val itemHeight = when (item) { + is CategoryHeader -> CATEGORY_HEIGHT + is KeyBindingEntry -> ENTRY_HEIGHT + } + item.render(currentOffset.unsafe + Vec2f((elementWidth - item.size.x) / 2, 0f), consumer, options) + currentOffset.y += itemHeight + ENTRY_Y_MARGIN + } + + if (filteredItems.size > maxVisibleEntries) { + val scrollbarX = offset.x + (screenSize.x - elementWidth - SCROLLBAR_WIDTH - SCROLLBAR_MARGIN) / 2 + elementWidth + SCROLLBAR_MARGIN + val scrollbarListHeight = maxVisibleEntries * (ENTRY_HEIGHT + ENTRY_Y_MARGIN) - ENTRY_Y_MARGIN + val trackElement = ColorElement(guiRenderer, Vec2f(SCROLLBAR_WIDTH, scrollbarListHeight + ENTRY_Y_MARGIN), SCROLLBAR_TRACK_COLOR) + trackElement.render(Vec2f(scrollbarX, listStartY), consumer, options) + val maxScroll = filteredItems.size - maxVisibleEntries + val thumbHeight = maxOf(SCROLLBAR_MIN_THUMB_HEIGHT, (scrollbarListHeight + ENTRY_Y_MARGIN) * maxVisibleEntries / filteredItems.size) + val thumbTravel = scrollbarListHeight + ENTRY_Y_MARGIN - thumbHeight + val thumbY = listStartY + (thumbTravel * scrollOffset / maxScroll) + + val thumbElement = ColorElement(guiRenderer, Vec2f(SCROLLBAR_WIDTH, thumbHeight), SCROLLBAR_THUMB_COLOR) + thumbElement.render(Vec2f(scrollbarX, thumbY), consumer, options) + } + + // Move to fixed position after list area + currentOffset.y = listStartY + listHeight + ENTRY_Y_MARGIN + currentOffset.y += SPACING - ENTRY_Y_MARGIN + + // Render search bar + searchBar.render(currentOffset.unsafe + Vec2f((elementWidth - searchBar.size.x) / 2, 0f), consumer, options) + currentOffset.y += SEARCH_BAR_HEIGHT + SPACING + + val buttonY = currentOffset.y + resetButton.render(currentOffset.unsafe + Vec2f((elementWidth / 2 - resetButton.size.x) / 2, 0f), consumer, options) + doneButton.render(Vec2f(currentOffset.x + elementWidth / 2 + (elementWidth / 2 - doneButton.size.x) / 2, buttonY), consumer, options) + } + + override fun onScroll(position: Vec2f, scrollOffset: Vec2f): Boolean { + if (editingEntry != null) return true + + val maxScroll = maxOf(0, filteredItems.size - maxVisibleEntries) + this.scrollOffset = (this.scrollOffset - scrollOffset.y.toInt()).coerceIn(0, maxScroll) + cacheUpToDate = false + + // Recalculate hover state after scrolling since elements move under the cursor. + val hit = getAt(position) + if (hit == null) { + activeElement?.onMouseLeave() + activeElement = null + } else { + val (element, delta) = hit + if (element != activeElement) { + activeElement?.onMouseLeave() + element.onMouseEnter(delta, position) + activeElement = element + } else { + element.onMouseMove(delta, position) + } + } + + return true + } + + override fun onMouseAction(position: Vec2f, button: MouseButtons, action: MouseActions, count: Int): Boolean { + if (editingEntry != null && action == MouseActions.PRESS) { + val keyCode = when (button) { + MouseButtons.LEFT -> KeyCodes.MOUSE_BUTTON_LEFT + MouseButtons.RIGHT -> KeyCodes.MOUSE_BUTTON_RIGHT + MouseButtons.MIDDLE -> KeyCodes.MOUSE_BUTTON_MIDDLE + MouseButtons.BUTTON_1 -> KeyCodes.MOUSE_BUTTON_1 + MouseButtons.BUTTON_2 -> KeyCodes.MOUSE_BUTTON_2 + MouseButtons.BUTTON_3 -> KeyCodes.MOUSE_BUTTON_3 + MouseButtons.BUTTON_4 -> KeyCodes.MOUSE_BUTTON_4 + MouseButtons.BUTTON_5 -> KeyCodes.MOUSE_BUTTON_5 + MouseButtons.BUTTON_6 -> KeyCodes.MOUSE_BUTTON_6 + MouseButtons.BUTTON_7 -> KeyCodes.MOUSE_BUTTON_7 + MouseButtons.BUTTON_8 -> KeyCodes.MOUSE_BUTTON_8 + MouseButtons.LAST -> KeyCodes.MOUSE_BUTTON_LAST + } + stopEditing(listOf(keyCode)) + return true + } + + if (sensitivitySlider.isCapturingMouse) { + val screenSize = size + val elementWidth = calculateElementWidth() + val startX = (screenSize.x - elementWidth - SCROLLBAR_WIDTH - SCROLLBAR_MARGIN) / 2 + val sliderX = position.x - startX - (elementWidth - sensitivitySlider.size.x) / 2 + if (sensitivitySlider.onMouseActionOutside(sliderX, button, action)) { + return true + } + } + + if (button == MouseButtons.LEFT && listItems.size > maxVisibleEntries) { + val scrollbarHit = getScrollbarHitArea(position) + if (scrollbarHit != null) { + if (action == MouseActions.PRESS) { + isDraggingScrollbar = true + dragStartY = position.y + dragStartScrollOffset = scrollOffset + return true + } + } + } + + if (action == MouseActions.RELEASE && isDraggingScrollbar) { + isDraggingScrollbar = false + return true + } + + // This part solves the focus issue, its about the parent class losing focus when not hovered, but search button should still keep focus... + val (element, delta) = getAt(position) ?: run { + if (action == MouseActions.PRESS) { + focusedElement = null + } + return true + } + if (action == MouseActions.PRESS) { + focusedElement = element + } + + element.onMouseAction(delta, button, action, count) + return true + } + + override fun onMouseEnter(position: Vec2f, absolute: Vec2f): Boolean { + val (element, delta) = getAt(position) ?: return true + element.onMouseEnter(delta, absolute) + activeElement = element + return true + } + + override fun onMouseMove(position: Vec2f, absolute: Vec2f): Boolean { + if (isDraggingScrollbar) { + val screenSize = size + val elementWidth = calculateElementWidth() + val visibleCount = minOf(maxVisibleEntries, filteredItems.size) + val listHeight = visibleCount * (ENTRY_HEIGHT + ENTRY_Y_MARGIN) - ENTRY_Y_MARGIN + + val maxScroll = filteredItems.size - maxVisibleEntries + val thumbHeight = maxOf(SCROLLBAR_MIN_THUMB_HEIGHT, (listHeight + ENTRY_Y_MARGIN) * maxVisibleEntries / filteredItems.size) + val thumbTravel = listHeight + ENTRY_Y_MARGIN - thumbHeight + + val deltaY = position.y - dragStartY + val scrollDelta = (deltaY / thumbTravel * maxScroll).toInt() + scrollOffset = (dragStartScrollOffset + scrollDelta).coerceIn(0, maxScroll) + cacheUpToDate = false + return true + } + + // Continue updating slider when it's capturing mouse + if (sensitivitySlider.isCapturingMouse) { + val screenSize = size + val elementWidth = calculateElementWidth() + val startX = (screenSize.x - elementWidth - SCROLLBAR_WIDTH - SCROLLBAR_MARGIN) / 2 + val sliderX = position.x - startX - (elementWidth - sensitivitySlider.size.x) / 2 + sensitivitySlider.onMouseMoveOutside(sliderX) + return true + } + + val (element, delta) = getAt(position) ?: run { + activeElement?.onMouseLeave() + activeElement = null + return true + } + + if (element != activeElement) { + activeElement?.onMouseLeave() + element.onMouseEnter(delta, absolute) + activeElement = element + } else { + element.onMouseMove(delta, absolute) + } + return true + } + + override fun onMouseLeave(): Boolean { + activeElement?.onMouseLeave() + activeElement = null + isDraggingScrollbar = false + return true + } + + private fun getScrollbarHitArea(position: Vec2f): Vec2f? { + if (filteredItems.size <= maxVisibleEntries) return null + + val screenSize = size + val elementWidth = calculateElementWidth() + val visibleCount = minOf(maxVisibleEntries, filteredItems.size) + val listHeight = visibleCount * (ENTRY_HEIGHT + ENTRY_Y_MARGIN) - ENTRY_Y_MARGIN + val totalHeight = titleElement.size.y + SPACING + + sensitivitySlider.size.y + SPACING + + listHeight + ENTRY_Y_MARGIN + + SEARCH_BAR_HEIGHT + SPACING + + SPACING + BUTTON_HEIGHT + + val startY = (screenSize.y - totalHeight) / 2 + val startX = (screenSize.x - elementWidth - SCROLLBAR_WIDTH - SCROLLBAR_MARGIN) / 2 + val listStartY = startY + titleElement.size.y + SPACING + sensitivitySlider.size.y + SPACING + + val scrollbarX = startX + elementWidth + SCROLLBAR_MARGIN + val trackHeight = listHeight + ENTRY_Y_MARGIN + + if (position.x < scrollbarX || position.x >= scrollbarX + SCROLLBAR_WIDTH) return null + if (position.y < listStartY || position.y >= listStartY + trackHeight) return null + val maxScroll = filteredItems.size - maxVisibleEntries + val thumbHeight = maxOf(SCROLLBAR_MIN_THUMB_HEIGHT, trackHeight * maxVisibleEntries / filteredItems.size) + val thumbTravel = trackHeight - thumbHeight + val thumbY = listStartY + (thumbTravel * scrollOffset / maxScroll) + return Vec2f(position.x - scrollbarX, position.y - thumbY) + } + + override fun getAt(position: Vec2f): Pair? { + val screenSize = size + val elementWidth = calculateElementWidth() + + // Used fixed list height based on maxVisibleEntries to prevent hitbox bouncing when scrolling. + val listHeight = maxVisibleEntries * (ENTRY_HEIGHT + ENTRY_Y_MARGIN) - ENTRY_Y_MARGIN + val totalHeight = titleElement.size.y + SPACING + + sensitivitySlider.size.y + SPACING + + listHeight + ENTRY_Y_MARGIN + + SEARCH_BAR_HEIGHT + SPACING + + SPACING + BUTTON_HEIGHT + + val startY = (screenSize.y - totalHeight) / 2 + val startX = (screenSize.x - elementWidth - SCROLLBAR_WIDTH - SCROLLBAR_MARGIN) / 2 + + if (position.x < startX || position.x >= startX + elementWidth) { + return null + } + + var currentY = startY + titleElement.size.y + SPACING + + if (position.y >= currentY && position.y < currentY + sensitivitySlider.size.y) { + val delta = Vec2f(position.x - startX - (elementWidth - sensitivitySlider.size.x) / 2, position.y - currentY) + if (delta.x >= 0 && delta.x < sensitivitySlider.size.x) { + return Pair(sensitivitySlider, delta) + } + } + currentY += sensitivitySlider.size.y + SPACING + + val listStartY = currentY + val startIndex = scrollOffset + val endIndex = minOf(startIndex + maxVisibleEntries, filteredItems.size) + + for (i in startIndex until endIndex) { + val item = filteredItems[i] + val itemHeight = if (item is CategoryHeader) CATEGORY_HEIGHT else ENTRY_HEIGHT + + if (position.y >= currentY && position.y < currentY + itemHeight) { + if (item is KeyBindingEntry) { + val delta = Vec2f(position.x - startX - (elementWidth - item.size.x) / 2, position.y - currentY) + if (delta.x >= 0 && delta.x < item.size.x) { + return Pair(item, delta) + } + } + } + currentY += itemHeight + ENTRY_Y_MARGIN + } + + currentY = listStartY + listHeight + ENTRY_Y_MARGIN + currentY += SPACING - ENTRY_Y_MARGIN + + if (position.y >= currentY && position.y < currentY + SEARCH_BAR_HEIGHT) { + val delta = Vec2f(position.x - startX - (elementWidth - searchBar.size.x) / 2, position.y - currentY) + if (delta.x >= 0 && delta.x < searchBar.size.x) { + return Pair(searchBar, delta) + } + } + currentY += SEARCH_BAR_HEIGHT + SPACING + + if (position.y >= currentY && position.y < currentY + BUTTON_HEIGHT) { + val resetX = startX + (elementWidth / 2 - resetButton.size.x) / 2 + if (position.x >= resetX && position.x < resetX + resetButton.size.x) { + val delta = Vec2f(position.x - resetX, position.y - currentY) + return Pair(resetButton, delta) + } + + val doneX = startX + elementWidth / 2 + (elementWidth / 2 - doneButton.size.x) / 2 + if (position.x >= doneX && position.x < doneX + doneButton.size.x) { + val delta = Vec2f(position.x - doneX, position.y - currentY) + return Pair(doneButton, delta) + } + } + + return null + } + + override fun onKey(key: KeyCodes, type: KeyChangeTypes): Boolean { + if (editingEntry != null) { + if (key == KeyCodes.KEY_ESCAPE && type == KeyChangeTypes.PRESS) { + stopEditing(emptyList()) + return true + } + + when (type) { + KeyChangeTypes.PRESS -> { + pressedKeys += key + if (pressedKeys.size > peakPressedKeys.size) { + peakPressedKeys = pressedKeys.toList() + } + editingEntry?.updatePressedKeys(pressedKeys) + cacheUpToDate = false + } + KeyChangeTypes.RELEASE -> { + if (pressedKeys.isNotEmpty()) { + pressedKeys -= key + if (pressedKeys.isEmpty()) { + stopEditing(peakPressedKeys) + } else { + editingEntry?.updatePressedKeys(pressedKeys) + } + } + } + else -> {} + } + return true + } + + if (type != KeyChangeTypes.RELEASE) { + when (key) { + KeyCodes.KEY_UP -> { + if (scrollOffset > 0) { + scrollOffset-- + cacheUpToDate = false + } + return true + } + KeyCodes.KEY_DOWN -> { + val maxScroll = maxOf(0, filteredItems.size - maxVisibleEntries) + if (scrollOffset < maxScroll) { + scrollOffset++ + cacheUpToDate = false + } + return true + } + else -> {} + } + } + focusedElement?.onKey(key, type) + return true + } + + override fun onCharPress(char: Int): Boolean { + return focusedElement?.onCharPress(char) ?: false + } + + override fun onChildChange(child: Element) { + cacheUpToDate = false + } + + override fun tick() { + super.tick() + titleElement.tick() + doneButton.tick() + resetButton.tick() + sensitivitySlider.tick() + searchBar.tick() + for (item in listItems) { + if (item is KeyBindingEntry) { + item.tick() + } + } + } + + sealed interface ListItem + + class CategoryHeader( + guiRenderer: GUIRenderer, + val categoryKey: String, + ) : Element(guiRenderer), ListItem { + + private val textElement = TextElement(guiRenderer, getCategoryDisplayName(categoryKey), background = null, parent = this).apply { + prefMaxSize = Vec2f(-1.0f, CATEGORY_HEIGHT) + } + + // Store the full width for centering + private var containerWidth: Float = 0f + + fun setContainerWidth(width: Float) { + containerWidth = width + } + + override fun forceRender(offset: Vec2f, consumer: GuiVertexConsumer, options: GUIVertexOptions?) { + val textX = if (containerWidth > 0) (containerWidth - textElement.size.x) / 2 else 0f + textElement.render(offset + Vec2f(textX, (CATEGORY_HEIGHT - textElement.size.y) / 2), consumer, options) + } + + override fun forceSilentApply() { + textElement.silentApply() + size = Vec2f(if (containerWidth > 0) containerWidth else textElement.size.x, CATEGORY_HEIGHT) + } + + override fun onChildChange(child: Element) { + cacheUpToDate = false + } + + companion object { + fun getCategoryDisplayName(categoryKey: String): String { + val translationKey = "minosoft:key.category.$categoryKey" + val translated = IntegratedLanguage.LANGUAGE.forceTranslate(translationKey.i18n().translationKey).message + return if (translated != translationKey) { + translated + } else { + categoryKey.replace('_', ' ').replaceFirstChar { it.uppercase() } + } + } + } + } + + class KeyBindingEntry( + guiRenderer: GUIRenderer, + val bindingName: ResourceLocation, + private var binding: KeyBinding, + val defaultBinding: KeyBinding, + private val menu: ControlsSettingsMenu, + ) : Element(guiRenderer), ListItem { + + private val nameElement = TextElement(guiRenderer, getDisplayName(bindingName), background = null, parent = this) + private val keyButton = ButtonElement(guiRenderer, getKeyDisplayText(binding)) { + menu.startEditing(this) + }.apply { parent = this@KeyBindingEntry } + + var isEditing: Boolean = false + set(value) { + field = value + updateKeyButtonText() + } + + private var currentPressedKeys: Set = emptySet() + private var isDuplicate: Boolean = false + + init { + updateKeyButtonText() + } + + fun getCurrentBinding(): KeyBinding = binding + + fun setDuplicateStatus(duplicate: Boolean) { + if (isDuplicate != duplicate) { + isDuplicate = duplicate + updateKeyButtonText() + } + } + + fun updateBinding(newBinding: KeyBinding) { + binding = newBinding + updateKeyButtonText() + } + + fun updatePressedKeys(keys: Set) { + currentPressedKeys = keys + if (isEditing) { + keyButton.textElement.text = if (keys.isEmpty()) { + "> ??? <" + } else { + "> ${keys.joinToString(" + ") { it.keyName }} <" + } + } + } + + private fun updateKeyButtonText() { + val displayText = if (isEditing) { + if (currentPressedKeys.isEmpty()) { + "> ??? <" + } else { + "> ${currentPressedKeys.joinToString(" + ") { it.keyName }} <" + } + } else { + currentPressedKeys = emptySet() + getKeyDisplayText(binding) + } + + // Apply red color if this binding has duplicate keys + keyButton.textElement.text = if (isDuplicate && !isEditing) { + TextComponent(displayText).color(ChatColors.RED) + } else { + displayText + } + } + + override fun forceRender(offset: Vec2f, consumer: GuiVertexConsumer, options: GUIVertexOptions?) { + val entrySize = size + val halfWidth = entrySize.x / 2 + + // Render name on the left, button on the right + nameElement.render(offset + Vec2f(4f, (entrySize.y - nameElement.size.y) / 2), consumer, options) + keyButton.size = Vec2f(halfWidth - 8f, entrySize.y - 2f) + keyButton.render(offset + Vec2f(halfWidth + 4f, 1f), consumer, options) + } + + override fun forceSilentApply() { + nameElement.silentApply() + keyButton.silentApply() + cacheUpToDate = false + } + + override fun onChildChange(child: Element) { + cacheUpToDate = false + } + + override fun onMouseAction(position: Vec2f, button: MouseButtons, action: MouseActions, count: Int): Boolean { + val halfWidth = size.x / 2 + if (position.x >= halfWidth) { + val delta = Vec2f(position.x - halfWidth - 4f, position.y - 1f) + return keyButton.onMouseAction(delta, button, action, count) + } + return true + } + + override fun onMouseEnter(position: Vec2f, absolute: Vec2f): Boolean { + val halfWidth = size.x / 2 + if (position.x >= halfWidth) { + keyButton.onMouseEnter(Vec2f(position.x - halfWidth - 4f, position.y - 1f), absolute) + } + return true + } + + override fun onMouseMove(position: Vec2f, absolute: Vec2f): Boolean { + val halfWidth = size.x / 2 + if (position.x >= halfWidth) { + keyButton.onMouseEnter(Vec2f(position.x - halfWidth - 4f, position.y - 1f), absolute) + } else { + keyButton.onMouseLeave() + } + return true + } + + override fun onMouseLeave(): Boolean { + keyButton.onMouseLeave() + return true + } + + override fun tick() { + nameElement.tick() + keyButton.tick() + } + + companion object { + fun getDisplayName(name: ResourceLocation): String { + // Try to get translated name using the key.* pattern, fallback to formatted path if no translation found. + val translationKey = "minosoft:key.${name.path}" + val translated = IntegratedLanguage.LANGUAGE.forceTranslate(translationKey.i18n().translationKey).message + return if (translated != translationKey) { + translated + } else { + name.path.replace('_', ' ').replaceFirstChar { it.uppercase() } + } + } + + fun getKeyDisplayText(binding: KeyBinding): String { + val keys = mutableListOf() + + for ((action, codes) in binding.action) { + for (code in codes) { + keys += code.keyName + } + } + + return if (keys.isEmpty()) { + "NONE" + } else { + keys.joinToString(" + ") + } + } + + fun getBindingSignature(binding: KeyBinding): String { + val allKeys = mutableSetOf() + for ((_, codes) in binding.action) { + allKeys.addAll(codes) + } + return allKeys.sortedBy { it.ordinal }.joinToString("+") { it.name } + } + } + } + + companion object : GUIBuilder> { + private const val WIDTH_PERCENTAGE = 0.35f + private const val MIN_BUTTON_WIDTH = 200.0f + private const val ENTRY_Y_MARGIN = 3.0f + private const val ENTRY_HEIGHT = 22.0f + private const val CATEGORY_HEIGHT = 18.0f + private const val BUTTON_HEIGHT = 20.0f + private const val SPACING = 10.0f + private const val SEARCH_BAR_HEIGHT = 20.0f + + private const val SCROLLBAR_WIDTH = 6.0f + private const val SCROLLBAR_MARGIN = 4.0f + private const val SCROLLBAR_MIN_THUMB_HEIGHT = 20.0f + private val SCROLLBAR_TRACK_COLOR = RGBAColor(40, 40, 40, 180) + private val SCROLLBAR_THUMB_COLOR = RGBAColor(120, 120, 120, 220) + + val CATEGORIZED_KEYBINDINGS: Map> = linkedMapOf( + // Movement category + "movement" to linkedMapOf( + minosoft("move_forward") to KeyBinding(KeyActions.CHANGE to setOf(KeyCodes.KEY_W)), + minosoft("move_backwards") to KeyBinding(KeyActions.CHANGE to setOf(KeyCodes.KEY_S)), + minosoft("move_left") to KeyBinding(KeyActions.CHANGE to setOf(KeyCodes.KEY_A)), + minosoft("move_right") to KeyBinding(KeyActions.CHANGE to setOf(KeyCodes.KEY_D)), + minosoft("move_jump") to KeyBinding(KeyActions.CHANGE to setOf(KeyCodes.KEY_SPACE)), + minosoft("move_sneak") to KeyBinding(KeyActions.CHANGE to setOf(KeyCodes.KEY_LEFT_SHIFT)), + minosoft("move_sprint") to KeyBinding(KeyActions.CHANGE to setOf(KeyCodes.KEY_LEFT_CONTROL)), + ), + + // Gameplay category + "gameplay" to linkedMapOf( + minosoft("attack") to KeyBinding(KeyActions.CHANGE to setOf(KeyCodes.MOUSE_BUTTON_LEFT)), + minosoft("use_item") to KeyBinding(KeyActions.CHANGE to setOf(KeyCodes.MOUSE_BUTTON_RIGHT)), + minosoft("pick_item") to KeyBinding(KeyActions.PRESS to setOf(KeyCodes.MOUSE_BUTTON_MIDDLE)), + minosoft("drop_item") to KeyBinding(KeyActions.PRESS to setOf(KeyCodes.KEY_Q)), + minosoft("swap_items") to KeyBinding(KeyActions.PRESS to setOf(KeyCodes.KEY_F)), + minosoft("local_inventory") to KeyBinding(KeyActions.PRESS to setOf(KeyCodes.KEY_E)), + minosoft("stop_spectating") to KeyBinding(KeyActions.PRESS to setOf(KeyCodes.KEY_LEFT_SHIFT)), + ), + + // Hotbar category + "hotbar" to linkedMapOf( + minosoft("hotbar_slot_1") to KeyBinding(KeyActions.PRESS to setOf(KeyCodes.KEY_1)), + minosoft("hotbar_slot_2") to KeyBinding(KeyActions.PRESS to setOf(KeyCodes.KEY_2)), + minosoft("hotbar_slot_3") to KeyBinding(KeyActions.PRESS to setOf(KeyCodes.KEY_3)), + minosoft("hotbar_slot_4") to KeyBinding(KeyActions.PRESS to setOf(KeyCodes.KEY_4)), + minosoft("hotbar_slot_5") to KeyBinding(KeyActions.PRESS to setOf(KeyCodes.KEY_5)), + minosoft("hotbar_slot_6") to KeyBinding(KeyActions.PRESS to setOf(KeyCodes.KEY_6)), + minosoft("hotbar_slot_7") to KeyBinding(KeyActions.PRESS to setOf(KeyCodes.KEY_7)), + minosoft("hotbar_slot_8") to KeyBinding(KeyActions.PRESS to setOf(KeyCodes.KEY_8)), + minosoft("hotbar_slot_9") to KeyBinding(KeyActions.PRESS to setOf(KeyCodes.KEY_9)), + ), + + // Camera category + "camera" to linkedMapOf( + minosoft("zoom") to KeyBinding(KeyActions.CHANGE to setOf(KeyCodes.KEY_C)), + minosoft("camera_third_person") to KeyBinding(KeyActions.PRESS to setOf(KeyCodes.KEY_F5)), + ), + + // Miscellaneous category + "miscellaneous" to linkedMapOf( + minosoft("take_screenshot") to KeyBinding(KeyActions.PRESS to setOf(KeyCodes.KEY_F2)), + minosoft("toggle_fullscreen") to KeyBinding(KeyActions.PRESS to setOf(KeyCodes.KEY_F11)), + minosoft("open_chat") to KeyBinding(KeyActions.PRESS to setOf(KeyCodes.KEY_T)), + minosoft("open_command") to KeyBinding(KeyActions.PRESS to setOf(KeyCodes.KEY_SLASH)), + minosoft("enable_hud") to KeyBinding(KeyActions.PRESS to setOf(KeyCodes.KEY_F1)), + ), + + // Debug category + "debug" to linkedMapOf( + minosoft("debug_polygon") to KeyBinding(KeyActions.MODIFIER to setOf(KeyCodes.KEY_F4), KeyActions.PRESS to setOf(KeyCodes.KEY_P)), + minosoft("cursor_mode") to KeyBinding(KeyActions.MODIFIER to setOf(KeyCodes.KEY_F4), KeyActions.PRESS to setOf(KeyCodes.KEY_M)), + minosoft("pause_incoming") to KeyBinding(KeyActions.MODIFIER to setOf(KeyCodes.KEY_F4), KeyActions.PRESS to setOf(KeyCodes.KEY_I)), + minosoft("pause_outgoing") to KeyBinding(KeyActions.MODIFIER to setOf(KeyCodes.KEY_F4), KeyActions.PRESS to setOf(KeyCodes.KEY_O)), + minosoft("camera_debug_view") to KeyBinding(KeyActions.MODIFIER to setOf(KeyCodes.KEY_F4), KeyActions.PRESS to setOf(KeyCodes.KEY_V)), + minosoft("toggle_hitboxes") to KeyBinding(KeyActions.MODIFIER to setOf(KeyCodes.KEY_F3), KeyActions.PRESS to setOf(KeyCodes.KEY_B)), + minosoft("chunk_border") to KeyBinding(KeyActions.MODIFIER to setOf(KeyCodes.KEY_F3), KeyActions.PRESS to setOf(KeyCodes.KEY_G)), + minosoft("clear_chunk_cache") to KeyBinding(KeyActions.MODIFIER to setOf(KeyCodes.KEY_F3), KeyActions.PRESS to setOf(KeyCodes.KEY_A)), + minosoft("recalculate_light") to KeyBinding(KeyActions.MODIFIER to setOf(KeyCodes.KEY_F4), KeyActions.PRESS to setOf(KeyCodes.KEY_A)), + minosoft("fullbright") to KeyBinding(KeyActions.MODIFIER to setOf(KeyCodes.KEY_F4), KeyActions.PRESS to setOf(KeyCodes.KEY_C)), + minosoft("switch_fun_effects") to KeyBinding(KeyActions.MODIFIER to setOf(KeyCodes.KEY_F4), KeyActions.PRESS to setOf(KeyCodes.KEY_J)), + ), + ) + + override fun build(guiRenderer: GUIRenderer): LayoutedGUIElement { + return LayoutedGUIElement(ControlsSettingsMenu(guiRenderer)) + } + } +} diff --git a/src/main/java/de/bixilon/minosoft/gui/rendering/gui/gui/screen/menu/options/GUISettingsMenu.kt b/src/main/java/de/bixilon/minosoft/gui/rendering/gui/gui/screen/menu/options/GUISettingsMenu.kt new file mode 100644 index 000000000..10f1d66d6 --- /dev/null +++ b/src/main/java/de/bixilon/minosoft/gui/rendering/gui/gui/screen/menu/options/GUISettingsMenu.kt @@ -0,0 +1,121 @@ +/* + * Minosoft + * Copyright (C) 2020-2025 Moritz Zwerger + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If not, see . + * + * This software is not affiliated with Mojang AB, the original developer of Minecraft. + */ + +package de.bixilon.minosoft.gui.rendering.gui.gui.screen.menu.options + +import de.bixilon.kmath.vec.vec2.f.Vec2f +import de.bixilon.minosoft.data.language.LanguageUtil.i18n +import de.bixilon.minosoft.gui.rendering.font.renderer.element.TextRenderProperties +import de.bixilon.minosoft.gui.rendering.gui.GUIRenderer +import de.bixilon.minosoft.gui.rendering.gui.elements.HorizontalAlignments +import de.bixilon.minosoft.gui.rendering.gui.elements.input.button.ButtonElement +import de.bixilon.minosoft.gui.rendering.gui.elements.spacer.SpacerElement +import de.bixilon.minosoft.gui.rendering.gui.elements.text.TextElement +import de.bixilon.minosoft.gui.rendering.gui.gui.GUIBuilder +import de.bixilon.minosoft.gui.rendering.gui.gui.LayoutedGUIElement +import de.bixilon.minosoft.gui.rendering.gui.gui.screen.menu.Menu + +class GUISettingsMenu(guiRenderer: GUIRenderer) : Menu(guiRenderer, PREFERRED_WIDTH) { + private val guiProfile = guiRenderer.context.session.profiles.gui + private val wawlaProfile = guiProfile.hud.wawla + + private val hudScaleButton: ButtonElement + private val wawlaEnabledButton: ButtonElement + private val wawlaLimitReachButton: ButtonElement + private val wawlaIdentifierButton: ButtonElement + private val wawlaBlockEnabledButton: ButtonElement + private val wawlaEntityEnabledButton: ButtonElement + private val wawlaEntityHealthButton: ButtonElement + private val wawlaEntityHandButton: ButtonElement + + init { + this += TextElement(guiRenderer, "menu.options.gui.title".i18n(), background = null, properties = TextRenderProperties(HorizontalAlignments.CENTER, scale = 2.0f)) + this += SpacerElement(guiRenderer, Vec2f(0.0f, 10.0f)) + + val currentHudScale = guiProfile.scale.toInt().coerceIn(1, 4) + hudScaleButton = ButtonElement(guiRenderer, "${translate("menu.options.gui.hud_scale")}: ${currentHudScale}x") { + val currentIndex = HUD_SCALE_OPTIONS.indexOf(guiProfile.scale).let { if (it == -1) 0 else it } + val nextIndex = (currentIndex + 1) % HUD_SCALE_OPTIONS.size + guiProfile.scale = HUD_SCALE_OPTIONS[nextIndex] + hudScaleButton.textElement.text = "${translate("menu.options.gui.hud_scale")}: ${HUD_SCALE_OPTIONS[nextIndex].toInt()}x" + } + this += hudScaleButton + + wawlaEnabledButton = ButtonElement(guiRenderer, formatEnabled("menu.options.gui.wawla", wawlaProfile.enabled)) { + wawlaProfile.enabled = !wawlaProfile.enabled + wawlaEnabledButton.textElement.text = formatEnabled("menu.options.gui.wawla", wawlaProfile.enabled) + updateDisabledStates() + } + this += wawlaEnabledButton + + wawlaLimitReachButton = ButtonElement(guiRenderer, formatEnabled("menu.options.gui.wawla_limit_reach", wawlaProfile.limitReach)) { + wawlaProfile.limitReach = !wawlaProfile.limitReach + wawlaLimitReachButton.textElement.text = formatEnabled("menu.options.gui.wawla_limit_reach", wawlaProfile.limitReach) + } + this += wawlaLimitReachButton + + wawlaIdentifierButton = ButtonElement(guiRenderer, formatEnabled("menu.options.gui.wawla_identifier", wawlaProfile.identifier)) { + wawlaProfile.identifier = !wawlaProfile.identifier + wawlaIdentifierButton.textElement.text = formatEnabled("menu.options.gui.wawla_identifier", wawlaProfile.identifier) + } + this += wawlaIdentifierButton + + wawlaBlockEnabledButton = ButtonElement(guiRenderer, formatEnabled("menu.options.gui.wawla_block", wawlaProfile.block.enabled)) { + wawlaProfile.block.enabled = !wawlaProfile.block.enabled + wawlaBlockEnabledButton.textElement.text = formatEnabled("menu.options.gui.wawla_block", wawlaProfile.block.enabled) + } + this += wawlaBlockEnabledButton + + wawlaEntityEnabledButton = ButtonElement(guiRenderer, formatEnabled("menu.options.gui.wawla_entity", wawlaProfile.entity.enabled)) { + wawlaProfile.entity.enabled = !wawlaProfile.entity.enabled + wawlaEntityEnabledButton.textElement.text = formatEnabled("menu.options.gui.wawla_entity", wawlaProfile.entity.enabled) + } + this += wawlaEntityEnabledButton + + wawlaEntityHealthButton = ButtonElement(guiRenderer, formatEnabled("menu.options.gui.wawla_entity_health", wawlaProfile.entity.health)) { + wawlaProfile.entity.health = !wawlaProfile.entity.health + wawlaEntityHealthButton.textElement.text = formatEnabled("menu.options.gui.wawla_entity_health", wawlaProfile.entity.health) + } + this += wawlaEntityHealthButton + + wawlaEntityHandButton = ButtonElement(guiRenderer, formatEnabled("menu.options.gui.wawla_entity_hand", wawlaProfile.entity.hand)) { + wawlaProfile.entity.hand = !wawlaProfile.entity.hand + wawlaEntityHandButton.textElement.text = formatEnabled("menu.options.gui.wawla_entity_hand", wawlaProfile.entity.hand) + } + this += wawlaEntityHandButton + + this += SpacerElement(guiRenderer, Vec2f(0.0f, 10.0f)) + this += ButtonElement(guiRenderer, "menu.options.done".i18n()) { guiRenderer.gui.pop() } + + updateDisabledStates() + } + + private fun updateDisabledStates() { + val wawlaDisabled = !wawlaProfile.enabled + wawlaLimitReachButton.disabled = wawlaDisabled + wawlaIdentifierButton.disabled = wawlaDisabled + wawlaBlockEnabledButton.disabled = wawlaDisabled + wawlaEntityEnabledButton.disabled = wawlaDisabled + wawlaEntityHealthButton.disabled = wawlaDisabled + wawlaEntityHandButton.disabled = wawlaDisabled + } + + companion object : GUIBuilder> { + private const val PREFERRED_WIDTH = 200.0f + private val HUD_SCALE_OPTIONS = listOf(1.0f, 2.0f, 3.0f, 4.0f) + + override fun build(guiRenderer: GUIRenderer): LayoutedGUIElement { + return LayoutedGUIElement(GUISettingsMenu(guiRenderer)) + } + } +} diff --git a/src/main/java/de/bixilon/minosoft/gui/rendering/gui/gui/screen/menu/options/LanguageSettingsMenu.kt b/src/main/java/de/bixilon/minosoft/gui/rendering/gui/gui/screen/menu/options/LanguageSettingsMenu.kt new file mode 100644 index 000000000..8b5a6b6c9 --- /dev/null +++ b/src/main/java/de/bixilon/minosoft/gui/rendering/gui/gui/screen/menu/options/LanguageSettingsMenu.kt @@ -0,0 +1,678 @@ +/* + * Minosoft + * Copyright (C) 2020-2025 Moritz Zwerger + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If not, see . + * + * This software is not affiliated with Mojang AB, the original developer of Minecraft. + */ + +package de.bixilon.minosoft.gui.rendering.gui.gui.screen.menu.options + +import de.bixilon.kmath.vec.vec2.f.MVec2f +import de.bixilon.kmath.vec.vec2.f.Vec2f +import de.bixilon.minosoft.config.key.KeyCodes +import de.bixilon.minosoft.data.language.IntegratedLanguage +import de.bixilon.minosoft.data.language.LanguageUtil +import de.bixilon.minosoft.data.language.LanguageUtil.i18n +import de.bixilon.minosoft.gui.rendering.font.renderer.element.TextRenderProperties +import de.bixilon.minosoft.gui.rendering.gui.GUIRenderer +import de.bixilon.minosoft.data.text.formatting.color.RGBAColor +import de.bixilon.minosoft.gui.rendering.gui.elements.Element +import de.bixilon.minosoft.gui.rendering.gui.elements.HorizontalAlignments +import de.bixilon.minosoft.gui.rendering.gui.elements.input.button.ButtonElement +import de.bixilon.minosoft.gui.rendering.gui.elements.primitive.ColorElement +import de.bixilon.minosoft.gui.rendering.gui.elements.text.TextElement +import de.bixilon.minosoft.gui.rendering.gui.gui.AbstractLayout +import de.bixilon.minosoft.gui.rendering.gui.gui.GUIBuilder +import de.bixilon.minosoft.gui.rendering.gui.gui.LayoutedGUIElement +import de.bixilon.minosoft.gui.rendering.gui.gui.screen.Screen +import de.bixilon.minosoft.gui.rendering.gui.input.mouse.MouseActions +import de.bixilon.minosoft.gui.rendering.gui.input.mouse.MouseButtons +import de.bixilon.minosoft.gui.rendering.gui.mesh.GUIVertexOptions +import de.bixilon.minosoft.gui.rendering.gui.mesh.consumer.GuiVertexConsumer +import de.bixilon.minosoft.gui.rendering.system.window.KeyChangeTypes + +class LanguageSettingsMenu(guiRenderer: GUIRenderer) : Screen(guiRenderer), AbstractLayout { + private val erosProfile = guiRenderer.context.session.profiles.eros.general + private val currentLanguage: String get() = erosProfile.language + + private val titleElement = TextElement(guiRenderer, "menu.options.language.title".i18n(), background = null, properties = TextRenderProperties(HorizontalAlignments.CENTER, scale = 2.0f)) + private val doneButton = ButtonElement(guiRenderer, "menu.options.done".i18n()) { guiRenderer.gui.pop() }.apply { parent = this@LanguageSettingsMenu } + + private val languageButtons: MutableList = mutableListOf() + private var scrollOffset = 0 + private val maxVisibleLanguages = 6 + + private var isDraggingScrollbar = false + private var dragStartY = 0f + private var dragStartScrollOffset = 0 + + override var activeElement: Element? = null + override var activeDragElement: Element? = null + + init { + for (language in AVAILABLE_LANGUAGES) { + val button = LanguageButtonElement(guiRenderer, language, language == currentLanguage) { + selectLanguage(language) + } + button.parent = this + languageButtons += button + } + forceSilentApply() + } + + private fun selectLanguage(language: String) { + erosProfile.language = language + IntegratedLanguage.load(language) + + // Update all button states + for (button in languageButtons) { + button.isSelected = button.languageCode == language + } + + // Update title and done button text + titleElement.text = "menu.options.language.title".i18n() + doneButton.textElement.text = "menu.options.done".i18n() + } + + private fun calculateElementWidth(): Float { + return maxOf(size.x * WIDTH_PERCENTAGE, MIN_BUTTON_WIDTH) + } + + override fun forceSilentApply() { + titleElement.silentApply() // Ensure title size is calculated first + val elementWidth = calculateElementWidth() + + titleElement.prefMaxSize = Vec2f(elementWidth, -1f) + doneButton.size = Vec2f(elementWidth, doneButton.size.y) + + for (button in languageButtons) { + button.size = Vec2f(elementWidth, button.size.y) + } + + super.forceSilentApply() + cacheUpToDate = false + } + + override fun forceRender(offset: Vec2f, consumer: GuiVertexConsumer, options: GUIVertexOptions?) { + super.forceRender(offset, consumer, options) + + val screenSize = size + val elementWidth = calculateElementWidth() + val currentOffset = MVec2f(offset) + + val visibleCount = minOf(maxVisibleLanguages, languageButtons.size) + val listHeight = visibleCount * (BUTTON_HEIGHT + BUTTON_Y_MARGIN) - BUTTON_Y_MARGIN + val totalHeight = titleElement.size.y + SPACING + + listHeight + BUTTON_Y_MARGIN + + SPACING + doneButton.size.y + + currentOffset.y += (screenSize.y - totalHeight) / 2 + currentOffset.x += (screenSize.x - elementWidth - SCROLLBAR_WIDTH - SCROLLBAR_MARGIN) / 2 + + titleElement.render(currentOffset.unsafe + Vec2f((elementWidth - titleElement.size.x) / 2, 0f), consumer, options) + currentOffset.y += titleElement.size.y + SPACING + + val listStartY = currentOffset.y + + val startIndex = scrollOffset + val endIndex = minOf(startIndex + maxVisibleLanguages, languageButtons.size) + + for (i in startIndex until endIndex) { + val button = languageButtons[i] + button.render(currentOffset.unsafe + Vec2f((elementWidth - button.size.x) / 2, 0f), consumer, options) + currentOffset.y += BUTTON_HEIGHT + BUTTON_Y_MARGIN + } + + if (languageButtons.size > maxVisibleLanguages) { + val scrollbarX = offset.x + (screenSize.x - elementWidth - SCROLLBAR_WIDTH - SCROLLBAR_MARGIN) / 2 + elementWidth + SCROLLBAR_MARGIN + val trackElement = ColorElement(guiRenderer, Vec2f(SCROLLBAR_WIDTH, listHeight + BUTTON_Y_MARGIN), SCROLLBAR_TRACK_COLOR) + trackElement.render(Vec2f(scrollbarX, listStartY), consumer, options) + val maxScroll = languageButtons.size - maxVisibleLanguages + val thumbHeight = maxOf(SCROLLBAR_MIN_THUMB_HEIGHT, (listHeight + BUTTON_Y_MARGIN) * maxVisibleLanguages / languageButtons.size) + val thumbTravel = listHeight + BUTTON_Y_MARGIN - thumbHeight + val thumbY = listStartY + (thumbTravel * scrollOffset / maxScroll) + + val thumbElement = ColorElement(guiRenderer, Vec2f(SCROLLBAR_WIDTH, thumbHeight), SCROLLBAR_THUMB_COLOR) + thumbElement.render(Vec2f(scrollbarX, thumbY), consumer, options) + } + + currentOffset.y += SPACING - BUTTON_Y_MARGIN + + doneButton.render(currentOffset.unsafe + Vec2f((elementWidth - doneButton.size.x) / 2, 0f), consumer, options) + } + + override fun onScroll(position: Vec2f, scrollOffset: Vec2f): Boolean { + val maxScroll = maxOf(0, languageButtons.size - maxVisibleLanguages) + this.scrollOffset = (this.scrollOffset - scrollOffset.y.toInt()).coerceIn(0, maxScroll) + cacheUpToDate = false + return true + } + + override fun onMouseAction(position: Vec2f, button: MouseButtons, action: MouseActions, count: Int): Boolean { + if (button == MouseButtons.LEFT && languageButtons.size > maxVisibleLanguages) { + val scrollbarHit = getScrollbarHitArea(position) + if (scrollbarHit != null) { + if (action == MouseActions.PRESS) { + isDraggingScrollbar = true + dragStartY = position.y + dragStartScrollOffset = scrollOffset + return true + } + } + } + + if (action == MouseActions.RELEASE && isDraggingScrollbar) { + isDraggingScrollbar = false + return true + } + + val (element, delta) = getAt(position) ?: return true + element.onMouseAction(delta, button, action, count) + return true + } + + override fun onMouseEnter(position: Vec2f, absolute: Vec2f): Boolean { + val (element, delta) = getAt(position) ?: return true + element.onMouseEnter(delta, absolute) + activeElement = element + return true + } + + override fun onMouseMove(position: Vec2f, absolute: Vec2f): Boolean { + if (isDraggingScrollbar) { + val screenSize = size + val elementWidth = calculateElementWidth() + val visibleCount = minOf(maxVisibleLanguages, languageButtons.size) + val listHeight = visibleCount * (BUTTON_HEIGHT + BUTTON_Y_MARGIN) - BUTTON_Y_MARGIN + + val maxScroll = languageButtons.size - maxVisibleLanguages + val thumbHeight = maxOf(SCROLLBAR_MIN_THUMB_HEIGHT, (listHeight + BUTTON_Y_MARGIN) * maxVisibleLanguages / languageButtons.size) + val thumbTravel = listHeight + BUTTON_Y_MARGIN - thumbHeight + + val deltaY = position.y - dragStartY + val scrollDelta = (deltaY / thumbTravel * maxScroll).toInt() + scrollOffset = (dragStartScrollOffset + scrollDelta).coerceIn(0, maxScroll) + cacheUpToDate = false + return true + } + + val (element, delta) = getAt(position) ?: run { + activeElement?.onMouseLeave() + activeElement = null + return true + } + + if (element != activeElement) { + activeElement?.onMouseLeave() + element.onMouseEnter(delta, absolute) + activeElement = element + } + return true + } + + override fun onMouseLeave(): Boolean { + activeElement?.onMouseLeave() + activeElement = null + isDraggingScrollbar = false + return true + } + + /** + * Check if position is within the scrollbar thumb area + * Returns the relative position within the scrollbar if hit, null otherwise + * Couldn't find a better way for this. + */ + private fun getScrollbarHitArea(position: Vec2f): Vec2f? { + if (languageButtons.size <= maxVisibleLanguages) return null + + val screenSize = size + val elementWidth = calculateElementWidth() + val visibleCount = minOf(maxVisibleLanguages, languageButtons.size) + val listHeight = visibleCount * (BUTTON_HEIGHT + BUTTON_Y_MARGIN) - BUTTON_Y_MARGIN + val totalHeight = titleElement.size.y + SPACING + + listHeight + BUTTON_Y_MARGIN + + SPACING + doneButton.size.y + + val startY = (screenSize.y - totalHeight) / 2 + val startX = (screenSize.x - elementWidth - SCROLLBAR_WIDTH - SCROLLBAR_MARGIN) / 2 + val listStartY = startY + titleElement.size.y + SPACING + + val scrollbarX = startX + elementWidth + SCROLLBAR_MARGIN + val trackHeight = listHeight + BUTTON_Y_MARGIN + + if (position.x < scrollbarX || position.x >= scrollbarX + SCROLLBAR_WIDTH) return null + if (position.y < listStartY || position.y >= listStartY + trackHeight) return null + val maxScroll = languageButtons.size - maxVisibleLanguages + val thumbHeight = maxOf(SCROLLBAR_MIN_THUMB_HEIGHT, trackHeight * maxVisibleLanguages / languageButtons.size) + val thumbTravel = trackHeight - thumbHeight + val thumbY = listStartY + (thumbTravel * scrollOffset / maxScroll) + return Vec2f(position.x - scrollbarX, position.y - thumbY) + } + + override fun getAt(position: Vec2f): Pair? { + val screenSize = size + val elementWidth = calculateElementWidth() + + val visibleCount = minOf(maxVisibleLanguages, languageButtons.size) + val listHeight = visibleCount * (BUTTON_HEIGHT + BUTTON_Y_MARGIN) - BUTTON_Y_MARGIN + val totalHeight = titleElement.size.y + SPACING + + listHeight + BUTTON_Y_MARGIN + + SPACING + doneButton.size.y + + val startY = (screenSize.y - totalHeight) / 2 + val startX = (screenSize.x - elementWidth - SCROLLBAR_WIDTH - SCROLLBAR_MARGIN) / 2 + + if (position.x < startX || position.x >= startX + elementWidth) { + return null + } + + var currentY = startY + titleElement.size.y + SPACING + + val startIndex = scrollOffset + val endIndex = minOf(startIndex + maxVisibleLanguages, languageButtons.size) + + for (i in startIndex until endIndex) { + if (position.y >= currentY && position.y < currentY + BUTTON_HEIGHT) { + val button = languageButtons[i] + val delta = Vec2f(position.x - startX - (elementWidth - button.size.x) / 2, position.y - currentY) + if (delta.x >= 0 && delta.x < button.size.x) { + return Pair(button, delta) + } + } + currentY += BUTTON_HEIGHT + BUTTON_Y_MARGIN + } + + currentY += SPACING - BUTTON_Y_MARGIN + + if (position.y >= currentY && position.y < currentY + doneButton.size.y) { + val delta = Vec2f(position.x - startX - (elementWidth - doneButton.size.x) / 2, position.y - currentY) + if (delta.x >= 0 && delta.x < doneButton.size.x) { + return Pair(doneButton, delta) + } + } + + return null + } + + override fun onKey(key: KeyCodes, type: KeyChangeTypes): Boolean { + if (type != KeyChangeTypes.RELEASE) { + when (key) { + KeyCodes.KEY_UP -> { + if (scrollOffset > 0) { + scrollOffset-- + cacheUpToDate = false + } + return true + } + KeyCodes.KEY_DOWN -> { + val maxScroll = maxOf(0, languageButtons.size - maxVisibleLanguages) + if (scrollOffset < maxScroll) { + scrollOffset++ + cacheUpToDate = false + } + return true + } + else -> {} + } + } + activeElement?.onKey(key, type) + return true + } + + override fun onChildChange(child: Element) { + cacheUpToDate = false + } + + override fun tick() { + super.tick() + titleElement.tick() + doneButton.tick() + for (button in languageButtons) { + button.tick() + } + } + + private class LanguageButtonElement( + guiRenderer: GUIRenderer, + val languageCode: String, + isSelected: Boolean, + onSubmit: () -> Unit, + ) : ButtonElement(guiRenderer, getDisplayName(languageCode), false, onSubmit) { + + var isSelected: Boolean = isSelected + set(value) { + if (field != value) { + field = value + updateText() + } + } + + init { + updateText() + } + + private fun updateText() { + textElement.text = if (isSelected) { + "» ${getDisplayName(languageCode)} «" + } else { + getDisplayName(languageCode) + } + } + + override var size: Vec2f + get() = Vec2f(super.size.x, BUTTON_HEIGHT) + set(value) { super.size = value } + + companion object { + fun getDisplayName(code: String): String { + return LANGUAGE_NAMES[code] ?: code + } + } + } + + companion object : GUIBuilder> { + private const val WIDTH_PERCENTAGE = 0.25f // 25% of window width + private const val MIN_BUTTON_WIDTH = 150.0f + private const val BUTTON_Y_MARGIN = 5.0f + private const val BUTTON_HEIGHT = 20.0f + private const val SPACING = 10.0f + + private const val SCROLLBAR_WIDTH = 6.0f + private const val SCROLLBAR_MARGIN = 4.0f + private const val SCROLLBAR_MIN_THUMB_HEIGHT = 20.0f + private val SCROLLBAR_TRACK_COLOR = RGBAColor(40, 40, 40, 180) + private val SCROLLBAR_THUMB_COLOR = RGBAColor(120, 120, 120, 220) + + // Available languages - all 137 Minecraft languages, not sure if theres a way to dynamically get them from files of game. + val AVAILABLE_LANGUAGES = listOf( + "af_za", // Afrikaans + "ar_sa", // Arabic + "ast_es", // Asturian + "az_az", // Azerbaijani + "ba_ru", // Bashkir + "bar", // Bavarian + "be_by", // Belarusian (Cyrillic) + "be_latn", // Belarusian (Latin) + "bg_bg", // Bulgarian + "br_fr", // Breton + "brb", // Brabantian + "bs_ba", // Bosnian + "ca_es", // Catalan + "cs_cz", // Czech + "cy_gb", // Welsh + "da_dk", // Danish + "de_at", // Austrian German + "de_ch", // Swiss German + "de_de", // German + "el_gr", // Greek + "en_au", // Australian English + "en_ca", // Canadian English + "en_gb", // British English + "en_nz", // New Zealand English + "en_pt", // Pirate Speak + "en_ud", // Upside down English + LanguageUtil.FALLBACK_LANGUAGE, // en_us - American English + "enp", // Anglish + "enws", // Shakespearean English + "eo_uy", // Esperanto + "es_ar", // Argentinian Spanish + "es_cl", // Chilean Spanish + "es_ec", // Ecuadorian Spanish + "es_es", // European Spanish + "es_mx", // Mexican Spanish + "es_uy", // Uruguayan Spanish + "es_ve", // Venezuelan Spanish + "esan", // Andalusian + "et_ee", // Estonian + "eu_es", // Basque + "fa_ir", // Persian + "fi_fi", // Finnish + "fil_ph", // Filipino + "fo_fo", // Faroese + "fr_ca", // Canadian French + "fr_fr", // European French + "fra_de", // East Franconian + "fur_it", // Friulian + "fy_nl", // Frisian + "ga_ie", // Irish + "gd_gb", // Scottish Gaelic + "gl_es", // Galician + "hal_ua", // Halychian + "haw_us", // Hawaiian + "he_il", // Hebrew + "hi_in", // Hindi + "hn_no", // High Norwegian + "hr_hr", // Croatian + "hu_hu", // Hungarian + "hy_am", // Armenian + "id_id", // Indonesian + "ig_ng", // Igbo + "io_en", // Ido + "is_is", // Icelandic + "isv", // Interslavic + "it_it", // Italian + "ja_jp", // Japanese + "jbo_en", // Lojban + "ka_ge", // Georgian + "kk_kz", // Kazakh + "kn_in", // Kannada + "ko_kr", // Korean + "ksh", // Kölsch/Ripuarian + "kw_gb", // Cornish + "ky_kg", // Kyrgyz + "la_la", // Latin + "lb_lu", // Luxembourgish + "li_li", // Limburgish + "lmo", // Lombard + "lo_la", // Lao + "lol_us", // LOLCAT + "lt_lt", // Lithuanian + "lv_lv", // Latvian + "lzh", // Literary Chinese + "mk_mk", // Macedonian + "mn_mn", // Mongolian + "ms_my", // Malay + "mt_mt", // Maltese + "nah", // Nahuatl + "nds_de", // Low German + "nl_be", // Dutch (Flemish) + "nl_nl", // Dutch + "nn_no", // Norwegian Nynorsk + "no_no", // Norwegian Bokmål + "oc_fr", // Occitan + "ovd", // Elfdalian + "pl_pl", // Polish + "pls", // Popoloca + "pt_br", // Brazilian Portuguese + "pt_pt", // European Portuguese + "qcb_es", // Cantabrian + "qid", // Indonesian (Pre-reform) + "qya_aa", // Quenya + "ro_ro", // Romanian + "rpr", // Russian (Pre-revolutionary) + "ru_ru", // Russian + "ry_ua", // Rusyn + "sah_sah", // Yakut + "se_no", // Northern Sami + "sk_sk", // Slovak + "sl_si", // Slovenian + "so_so", // Somali + "sq_al", // Albanian + "sr_cs", // Serbian (Latin) + "sr_sp", // Serbian (Cyrillic) + "sv_se", // Swedish + "sxu", // Upper Saxon German + "szl", // Silesian + "ta_in", // Tamil + "th_th", // Thai + "tl_ph", // Tagalog + "tlh_aa", // Klingon + "tok", // Toki Pona + "tr_tr", // Turkish + "tt_ru", // Tatar + "tzo_mx", // Tzotzil + "uk_ua", // Ukrainian + "val_es", // Valencian + "vec_it", // Venetian + "vi_vn", // Vietnamese + "vp_vl", // Viossa + "yi_de", // Yiddish + "yo_ng", // Yoruba + "zh_cn", // Chinese Simplified + "zh_hk", // Chinese Traditional (Hong Kong) + "zh_tw", // Chinese Traditional (Taiwan) + "zlm_arab", // Malay (Jawi) + ) + + // Language display names + val LANGUAGE_NAMES = mapOf( + "af_za" to "Afrikaans (Suid-Afrika)", + "ar_sa" to "العربية (العالم العربي)", + "ast_es" to "Asturianu (Asturies)", + "az_az" to "Azərbaycanca (Azərbaycan)", + "ba_ru" to "Башҡортса (Башҡортостан)", + "bar" to "Boarisch (Bayern)", + "be_by" to "Беларуская (Беларусь)", + "be_latn" to "Biełaruskaja (Biełaruś)", + "bg_bg" to "Български (България)", + "br_fr" to "Brezhoneg (Breizh)", + "brb" to "Braobans (Braobant)", + "bs_ba" to "Bosanski (Bosna i Hercegovina)", + "ca_es" to "Català (Catalunya)", + "cs_cz" to "Čeština (Česko)", + "cy_gb" to "Cymraeg (Cymru)", + "da_dk" to "Dansk (Danmark)", + "de_at" to "Deitsch (Österreich)", + "de_ch" to "Schwiizerdutsch (Schwiiz)", + "de_de" to "Deutsch (Deutschland)", + "el_gr" to "Ελληνικά (Ελλάδα)", + "en_au" to "English (Australia)", + "en_ca" to "English (Canada)", + "en_gb" to "English (United Kingdom)", + "en_nz" to "English (New Zealand)", + "en_pt" to "Pirate Speak (The Seven Seas)", + "en_ud" to "ɥsᴉꞁᵷuƎ (uʍoᗡ ǝpᴉsd∩)", + "en_us" to "English (US)", + "enp" to "Anglish (Oned Riches)", + "enws" to "Shakespearean English", + "eo_uy" to "Esperanto (Esperantujo)", + "es_ar" to "Español (Argentina)", + "es_cl" to "Español (Chile)", + "es_ec" to "Español (Ecuador)", + "es_es" to "Español (España)", + "es_mx" to "Español (México)", + "es_uy" to "Español (Uruguay)", + "es_ve" to "Español (Venezuela)", + "esan" to "Andalûh (Andaluçía)", + "et_ee" to "Eesti (Eesti)", + "eu_es" to "Euskara (Euskal Herria)", + "fa_ir" to "فارسی (ایران)", + "fi_fi" to "Suomi (Suomi)", + "fil_ph" to "Filipino (Pilipinas)", + "fo_fo" to "Føroyskt (Føroyar)", + "fr_ca" to "Français (Canada)", + "fr_fr" to "Français (France)", + "fra_de" to "Fränggisch (Franggn)", + "fur_it" to "Furlan (Friûl)", + "fy_nl" to "Frysk (Fryslân)", + "ga_ie" to "Gaeilge (Éire)", + "gd_gb" to "Gàidhlig (Alba)", + "gl_es" to "Galego (Galicia)", + "hal_ua" to "Галицка (Галичина)", + "haw_us" to "'Ōlelo Hawai'i (Hawai'i)", + "he_il" to "עברית (ישראל)", + "hi_in" to "हिंदी (भारत)", + "hn_no" to "Høgnorsk (Norig)", + "hr_hr" to "Hrvatski (Hrvatska)", + "hu_hu" to "Magyar (Magyarország)", + "hy_am" to "Հայերեն (Հայաստան)", + "id_id" to "Bahasa Indonesia (Indonesia)", + "ig_ng" to "Igbo (Naigeria)", + "io_en" to "Ido (Idia)", + "is_is" to "Íslenska (Ísland)", + "isv" to "Medžuslovjansky (Slovjanščina)", + "it_it" to "Italiano (Italia)", + "ja_jp" to "日本語 (日本)", + "jbo_en" to "la .lojban. (la jbogu'e)", + "ka_ge" to "ქართული (საქართველო)", + "kk_kz" to "Қазақша (Қазақстан)", + "kn_in" to "ಕನ್ನಡ (ಭಾರತ)", + "ko_kr" to "한국어 (대한민국)", + "ksh" to "Kölsch/Ripoarisch (Rhingland)", + "kw_gb" to "Kernewek (Kernow)", + "ky_kg" to "Кыргызча (Кыргызстан)", + "la_la" to "Latina (Latium)", + "lb_lu" to "Lëtzebuergesch (Lëtzebuerg)", + "li_li" to "Limburgs (Limburg)", + "lmo" to "Lombard (Lombardia)", + "lo_la" to "ລາວ (ປະເທດລາວ)", + "lol_us" to "LOLCAT (Kingdom of Cats)", + "lt_lt" to "Lietuvių (Lietuva)", + "lv_lv" to "Latviešu (Latvija)", + "lzh" to "文言 (華夏)", + "mk_mk" to "Македонски (Северна Македонија)", + "mn_mn" to "Монгол (Монгол Улс)", + "ms_my" to "Bahasa Melayu (Malaysia)", + "mt_mt" to "Malti (Malta)", + "nah" to "Mēxikatlahtōlli (Mēxiko)", + "nds_de" to "Plattdüütsh (Düütschland)", + "nl_be" to "Vlaams (België)", + "nl_nl" to "Nederlands (Nederland)", + "nn_no" to "Norsk nynorsk (Noreg)", + "no_no" to "Norsk bokmål (Norge)", + "oc_fr" to "Occitan (Occitània)", + "ovd" to "Övdalska (Swerre)", + "pl_pl" to "Polski (Polska)", + "pls" to "Ngiiwa (Ndanìꞌngà)", + "pt_br" to "Português (Brasil)", + "pt_pt" to "Português (Portugal)", + "qcb_es" to "Cántabru/Montañés (Cantabria)", + "qid" to "Bahasa Indonesia edjaän lama", + "qya_aa" to "Quenya (Arda)", + "ro_ro" to "Română (România)", + "rpr" to "Русскій дореформенный", + "ru_ru" to "Русский (Россия)", + "ry_ua" to "Руснацькый (Пудкарпатя)", + "sah_sah" to "Сахалыы (Cаха Сирэ)", + "se_no" to "Davvisámegiella (Sápmi)", + "sk_sk" to "Slovenčina (Slovensko)", + "sl_si" to "Slovenščina (Slovenija)", + "so_so" to "Af-Soomaali (Soomaaliya)", + "sq_al" to "Shqip (Shqiperia)", + "sr_cs" to "Srpski (Srbija)", + "sr_sp" to "Српски (Србија)", + "sv_se" to "Svenska (Sverige)", + "sxu" to "Säggs'sch (Saggsn)", + "szl" to "Ślōnski (Gōrny Ślōnsk)", + "ta_in" to "தமிழ் (இந்தியா)", + "th_th" to "ไทย (ประเทศไทย)", + "tl_ph" to "Tagalog (Pilipinas)", + "tlh_aa" to "tlhIngan Hol (tlhIngan wo')", + "tok" to "toki pona (ma pona)", + "tr_tr" to "Türkçe (Türkiye)", + "tt_ru" to "Татарча (Татарстан)", + "tzo_mx" to "Bats'i k'op (Jobel)", + "uk_ua" to "Українська (Україна)", + "val_es" to "Català (Valencià)", + "vec_it" to "Vèneto (Veneto)", + "vi_vn" to "Tiếng Việt (Việt Nam)", + "vp_vl" to "Viossa (Vilant)", + "yi_de" to "ייִדיש (אשכנזיש יידן)", + "yo_ng" to "Yorùbá (Nàìjíríà)", + "zh_cn" to "简体中文 (中国大陆)", + "zh_hk" to "繁體中文 (香港)", + "zh_tw" to "繁體中文 (台灣)", + "zlm_arab" to "بهاس ملايو (مليسيا)", + ) + + override fun build(guiRenderer: GUIRenderer): LayoutedGUIElement { + return LayoutedGUIElement(LanguageSettingsMenu(guiRenderer)) + } + } +} diff --git a/src/main/java/de/bixilon/minosoft/gui/rendering/gui/gui/screen/menu/options/OptionsMenu.kt b/src/main/java/de/bixilon/minosoft/gui/rendering/gui/gui/screen/menu/options/OptionsMenu.kt new file mode 100644 index 000000000..d95266448 --- /dev/null +++ b/src/main/java/de/bixilon/minosoft/gui/rendering/gui/gui/screen/menu/options/OptionsMenu.kt @@ -0,0 +1,78 @@ +/* + * Minosoft + * Copyright (C) 2020-2025 Moritz Zwerger + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If not, see . + * + * This software is not affiliated with Mojang AB, the original developer of Minecraft. + */ + +package de.bixilon.minosoft.gui.rendering.gui.gui.screen.menu.options + +import de.bixilon.kmath.vec.vec2.f.Vec2f +import de.bixilon.minosoft.data.language.LanguageUtil.i18n +import de.bixilon.minosoft.gui.eros.Eros +import de.bixilon.minosoft.gui.eros.util.JavaFXUtil +import de.bixilon.minosoft.gui.rendering.font.renderer.element.TextRenderProperties +import de.bixilon.minosoft.gui.rendering.gui.GUIRenderer +import de.bixilon.minosoft.gui.rendering.gui.elements.HorizontalAlignments +import de.bixilon.minosoft.gui.rendering.gui.elements.input.button.ButtonElement +import de.bixilon.minosoft.gui.rendering.gui.elements.input.slider.SliderElement +import de.bixilon.minosoft.gui.rendering.gui.elements.spacer.SpacerElement +import de.bixilon.minosoft.gui.rendering.gui.elements.text.TextElement +import de.bixilon.minosoft.gui.rendering.gui.gui.GUIBuilder +import de.bixilon.minosoft.gui.rendering.gui.gui.LayoutedGUIElement +import de.bixilon.minosoft.gui.rendering.gui.gui.screen.menu.Menu +import de.bixilon.minosoft.gui.rendering.gui.gui.screen.menu.debug.DebugMenu + +class OptionsMenu(guiRenderer: GUIRenderer) : Menu(guiRenderer, PREFERRED_WIDTH) { + private val renderingProfile = guiRenderer.context.profile + private val audioProfile = guiRenderer.context.session.profiles.audio + + init { + this += TextElement(guiRenderer, "menu.options.title".i18n(), background = null, properties = TextRenderProperties(HorizontalAlignments.CENTER, scale = 2.0f)) + this += SpacerElement(guiRenderer, Vec2f(0f, 10f)) + + this += SliderElement(guiRenderer, translate("menu.options.fov"), 60.0f, 110.0f, renderingProfile.camera.fov) { newValue -> + renderingProfile.camera.fov = newValue + } + + this += SliderElement(guiRenderer, translate("menu.options.master_volume"), 0.0f, 100.0f, audioProfile.volume.master * 100.0f) { + audioProfile.volume.master = it / 100.0f + } + + this += ButtonElement(guiRenderer, "menu.options.video".i18n()) { + guiRenderer.gui.push(VideoSettingsMenu) + } + this += ButtonElement(guiRenderer, "menu.options.chat".i18n()) { + guiRenderer.gui.push(ChatSettingsMenu) + } + this += ButtonElement(guiRenderer, "menu.options.language".i18n()) { + guiRenderer.gui.push(LanguageSettingsMenu) + } + this += ButtonElement(guiRenderer, "menu.options.resource_packs".i18n()) { + JavaFXUtil.runLater { Eros.setVisibility(true) } + } + this += ButtonElement(guiRenderer, "menu.options.controls".i18n()) { + guiRenderer.gui.push(ControlsSettingsMenu) + } + this += ButtonElement(guiRenderer, "menu.pause.options.debug".i18n()) { + guiRenderer.gui.push(DebugMenu) + } + + this += SpacerElement(guiRenderer, Vec2f(0f, 10f)) + this += ButtonElement(guiRenderer, "menu.options.done".i18n()) { guiRenderer.gui.pop() } + } + + companion object : GUIBuilder> { + private const val PREFERRED_WIDTH = 200.0f + + override fun build(guiRenderer: GUIRenderer): LayoutedGUIElement { + return LayoutedGUIElement(OptionsMenu(guiRenderer)) + } + } +} diff --git a/src/main/java/de/bixilon/minosoft/gui/rendering/gui/gui/screen/menu/options/VideoSettingsMenu.kt b/src/main/java/de/bixilon/minosoft/gui/rendering/gui/gui/screen/menu/options/VideoSettingsMenu.kt new file mode 100644 index 000000000..73a16f3bb --- /dev/null +++ b/src/main/java/de/bixilon/minosoft/gui/rendering/gui/gui/screen/menu/options/VideoSettingsMenu.kt @@ -0,0 +1,107 @@ +/* + * Minosoft + * Copyright (C) 2020-2025 Moritz Zwerger + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If not, see . + * + * This software is not affiliated with Mojang AB, the original developer of Minecraft. + */ + +package de.bixilon.minosoft.gui.rendering.gui.gui.screen.menu.options + +import de.bixilon.kmath.vec.vec2.f.Vec2f +import de.bixilon.minosoft.data.language.LanguageUtil.i18n +import de.bixilon.minosoft.gui.rendering.font.renderer.element.TextRenderProperties +import de.bixilon.minosoft.gui.rendering.gui.GUIRenderer +import de.bixilon.minosoft.gui.rendering.gui.elements.HorizontalAlignments +import de.bixilon.minosoft.gui.rendering.gui.elements.input.button.ButtonElement +import de.bixilon.minosoft.gui.rendering.gui.elements.input.slider.SliderElement +import de.bixilon.minosoft.gui.rendering.gui.elements.spacer.SpacerElement +import de.bixilon.minosoft.gui.rendering.gui.elements.text.TextElement +import de.bixilon.minosoft.gui.rendering.gui.gui.GUIBuilder +import de.bixilon.minosoft.gui.rendering.gui.gui.LayoutedGUIElement +import de.bixilon.minosoft.gui.rendering.gui.gui.screen.menu.Menu + +class VideoSettingsMenu(guiRenderer: GUIRenderer) : Menu(guiRenderer, PREFERRED_WIDTH) { + private val renderingProfile = guiRenderer.context.profile + private val blockProfile = guiRenderer.session.profiles.block + private val lightProfile = renderingProfile.light + private val cameraProfile = renderingProfile.camera + private val skyProfile = renderingProfile.sky + private val fogProfile = renderingProfile.fog + + private val fullbrightButton: ButtonElement + private val viewBobbingButton: ButtonElement + private val fogButton: ButtonElement + private val dynamicFovButton: ButtonElement + private val fullscreenButton: ButtonElement + + init { + this += TextElement(guiRenderer, "menu.options.video.title".i18n(), background = null, properties = TextRenderProperties(HorizontalAlignments.CENTER, scale = 2.0f)) + this += SpacerElement(guiRenderer, Vec2f(0.0f, 10.0f)) + + this += ButtonElement(guiRenderer, "menu.options.video.gui".i18n()) { + guiRenderer.gui.push(GUISettingsMenu) + } + this += ButtonElement(guiRenderer, "menu.options.video.clouds".i18n()) { + guiRenderer.gui.push(CloudSettingsMenu) + } + + fullscreenButton = ButtonElement(guiRenderer, formatEnabled("menu.options.video.fullscreen", guiRenderer.context.window.fullscreen)) { + guiRenderer.context.window.fullscreen = !guiRenderer.context.window.fullscreen + fullscreenButton.textElement.text = formatEnabled("menu.options.video.fullscreen", guiRenderer.context.window.fullscreen) + } + this += fullscreenButton + + this += SliderElement(guiRenderer, translate("menu.options.video.render_distance"), 2.0f, 32.0f, blockProfile.viewDistance.toFloat()) { + blockProfile.viewDistance = it.toInt() + } + + this += SliderElement(guiRenderer, translate("menu.options.video.biome_radius"), 0.0f, 5.0f, skyProfile.biomeRadius.toFloat()) { + skyProfile.biomeRadius = it.toInt() + } + + this += SliderElement(guiRenderer, translate("menu.options.video.brightness"), 0.0f, 100.0f, lightProfile.gamma * 100.0f) { + lightProfile.gamma = it / 100.0f + } + + viewBobbingButton = ButtonElement(guiRenderer, formatEnabled("menu.options.video.view_bobbing", cameraProfile.shaking.walking)) { + cameraProfile.shaking.walking = !cameraProfile.shaking.walking + viewBobbingButton.textElement.text = formatEnabled("menu.options.video.view_bobbing", cameraProfile.shaking.walking) + } + this += viewBobbingButton + + fullbrightButton = ButtonElement(guiRenderer, formatEnabled("menu.options.video.fullbright", lightProfile.fullbright)) { + lightProfile.fullbright = !lightProfile.fullbright + fullbrightButton.textElement.text = formatEnabled("menu.options.video.fullbright", lightProfile.fullbright) + } + this += fullbrightButton + + fogButton = ButtonElement(guiRenderer, formatEnabled("menu.options.video.fog", fogProfile.enabled)) { + fogProfile.enabled = !fogProfile.enabled + fogButton.textElement.text = formatEnabled("menu.options.video.fog", fogProfile.enabled) + } + this += fogButton + + dynamicFovButton = ButtonElement(guiRenderer, formatEnabled("menu.options.video.dynamic_fov", cameraProfile.dynamicFOV)) { + cameraProfile.dynamicFOV = !cameraProfile.dynamicFOV + dynamicFovButton.textElement.text = formatEnabled("menu.options.video.dynamic_fov", cameraProfile.dynamicFOV) + } + this += dynamicFovButton + + this += SpacerElement(guiRenderer, Vec2f(0.0f, 10.0f)) + this += ButtonElement(guiRenderer, "menu.options.done".i18n()) { guiRenderer.gui.pop() } + } + + companion object : GUIBuilder> { + private const val PREFERRED_WIDTH = 200.0f + + override fun build(guiRenderer: GUIRenderer): LayoutedGUIElement { + return LayoutedGUIElement(VideoSettingsMenu(guiRenderer)) + } + } +} diff --git a/src/main/java/de/bixilon/minosoft/gui/rendering/gui/gui/screen/menu/pause/PauseMenu.kt b/src/main/java/de/bixilon/minosoft/gui/rendering/gui/gui/screen/menu/pause/PauseMenu.kt index b7aca45b9..7e1aa1032 100644 --- a/src/main/java/de/bixilon/minosoft/gui/rendering/gui/gui/screen/menu/pause/PauseMenu.kt +++ b/src/main/java/de/bixilon/minosoft/gui/rendering/gui/gui/screen/menu/pause/PauseMenu.kt @@ -30,6 +30,7 @@ import de.bixilon.minosoft.gui.rendering.gui.gui.GUIBuilder import de.bixilon.minosoft.gui.rendering.gui.gui.LayoutedGUIElement import de.bixilon.minosoft.gui.rendering.gui.gui.screen.menu.Menu import de.bixilon.minosoft.gui.rendering.gui.gui.screen.menu.debug.DebugMenu +import de.bixilon.minosoft.gui.rendering.gui.gui.screen.menu.options.OptionsMenu import de.bixilon.minosoft.terminal.RunConfiguration class PauseMenu(guiRenderer: GUIRenderer) : Menu(guiRenderer) { @@ -39,6 +40,7 @@ class PauseMenu(guiRenderer: GUIRenderer) : Menu(guiRenderer) { this += SpacerElement(guiRenderer, Vec2f(0, 20)) this += ButtonElement(guiRenderer, "menu.pause.back_to_game".i18n()) { guiRenderer.gui.popOrPause() } + this += ButtonElement(guiRenderer, "menu.pause.options".i18n()) { guiRenderer.gui.push(OptionsMenu) } this += ButtonElement(guiRenderer, "menu.pause.options.debug".i18n()) { guiRenderer.gui.push(DebugMenu) } this += NeutralizedButtonElement(guiRenderer, "menu.pause.disconnect".i18n(), "menu.pause.disconnect.confirm".i18n()) { guiRenderer.session.terminate() } if (ErosProfileManager.selected.general.hideErosOnceConnected) { diff --git a/src/main/java/de/bixilon/minosoft/gui/rendering/gui/input/MouseCapturing.kt b/src/main/java/de/bixilon/minosoft/gui/rendering/gui/input/MouseCapturing.kt new file mode 100644 index 000000000..02b191637 --- /dev/null +++ b/src/main/java/de/bixilon/minosoft/gui/rendering/gui/input/MouseCapturing.kt @@ -0,0 +1,53 @@ +/* + * Minosoft + * Copyright (C) 2020-2025 Moritz Zwerger + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If not, see . + * + * This software is not affiliated with Mojang AB, the original developer of Minecraft. + */ + +package de.bixilon.minosoft.gui.rendering.gui.input + +import de.bixilon.minosoft.gui.rendering.gui.input.mouse.MouseActions +import de.bixilon.minosoft.gui.rendering.gui.input.mouse.MouseButtons + +/** + * Interface for GUI elements that can capture mouse input. + * When an element is capturing mouse, the parent layout should continue + * forwarding mouse events to it even when the mouse position is outside its bounds. + * + * This is useful for elements like sliders that need to track mouse movement + * during drag operations even when the cursor moves outside the element. + * + * This file exists purely for sliders in menus to be able to capture mouse outside their bounds. + * Could have more uses in future... + */ +interface MouseCapturing { + val isCapturingMouse: Boolean + + /** + * Handle mouse action from parent when mouse is outside this element. + * Called by parent layouts when mouse action occurs outside the element's bounds + * but the element is still capturing mouse. + * + * @param relativeX The x position relative to this element's position + * @param button The mouse button that was pressed/released + * @param action The type of mouse action (press/release) + * @return true if the event was handled + */ + fun onMouseActionOutside(relativeX: Float, button: MouseButtons, action: MouseActions): Boolean + + /** + * Handle mouse move from parent when mouse is outside this element. + * Called by parent layouts during mouse movement when the element is capturing mouse. + * + * @param relativeX The x position relative to this element's position + * @return true if the event was handled + */ + fun onMouseMoveOutside(relativeX: Float): Boolean +} diff --git a/src/main/java/de/bixilon/minosoft/gui/rendering/input/key/manager/binding/BindingsManager.kt b/src/main/java/de/bixilon/minosoft/gui/rendering/input/key/manager/binding/BindingsManager.kt index b568e9ebb..53eba165f 100644 --- a/src/main/java/de/bixilon/minosoft/gui/rendering/input/key/manager/binding/BindingsManager.kt +++ b/src/main/java/de/bixilon/minosoft/gui/rendering/input/key/manager/binding/BindingsManager.kt @@ -103,10 +103,115 @@ class BindingsManager( } fun onKey(code: KeyCodes, pressed: Boolean, handler: InputHandler?, time: ValueTimeMark) { + // Find all satisfied bindings that use this key and track their key counts + // This part of the code is to make sure that combination keys take priority over single keys AND combination keys that include other combination keys. + // It can have problems in a schenario if a third combination that has more keys but includes another combination exists. But who in their right name bother with that... + // This should technically include all possible use cases. + val satisfiedBindings = mutableMapOf() // name -> total key count + var maxKeyCount = 0 + + // Track if the current key is part of a partially satisfied combination + val partiallyMatchedBindings = mutableSetOf() + + if (pressed) { + // Check which bindings are satisfied + for ((name, state) in bindings) { + if (handler != null && !state.binding.ignoreConsumer) continue + + val binding = state.binding + val modifierKeys = binding.action[KeyActions.MODIFIER] ?: emptySet() + + // Check if the current key is a modifier key for this binding and if other modifier keys are pressed + if (code in modifierKeys) { + val otherModifiers = modifierKeys - code + // This is for combination building, it ignores cases like if you press keys in different order than you set. + if (otherModifiers.isEmpty() || otherModifiers.any { input.isKeyDown(it) }) { + partiallyMatchedBindings += name + } + } + + // Check if all modifier keys are currently pressed (if any) + if (modifierKeys.isNotEmpty() && !input.areKeysDown(modifierKeys)) continue + + // Check if this binding uses the current key (in any action that's not MODIFIER) + val usesCurrentKey = binding.action.entries.any { (action, keys) -> + action != KeyActions.MODIFIER && code in keys + } + + if (usesCurrentKey) { + // Count total keys in this binding (modifiers + action keys) + val actionKeys = binding.action.entries + .filter { it.key != KeyActions.MODIFIER } + .flatMap { it.value } + .toSet() + val totalKeyCount = modifierKeys.size + actionKeys.size + + satisfiedBindings[name] = totalKeyCount + if (totalKeyCount > maxKeyCount) { + maxKeyCount = totalKeyCount + } + } + } + } + for ((name, state) in bindings) { if (handler != null && !state.binding.ignoreConsumer) { continue } + + // If there are satisfied bindings, only trigger those with the maximum key count + if (pressed && satisfiedBindings.isNotEmpty()) { + val keyCount = satisfiedBindings[name] + + if (keyCount != null) { + // This binding is satisfied, but skip if it's not the most specific + if (keyCount < maxKeyCount) { + continue + } + } else { + // This binding is not in satisfiedBindings, check if it uses the current key + val binding = state.binding + val usesCurrentKey = binding.action.entries.any { (action, keys) -> + action != KeyActions.MODIFIER && code in keys + } + + if (usesCurrentKey) { + continue + } + } + } + + // Skip satisfied bindings if the current key is being used in another combination. + if (pressed && partiallyMatchedBindings.isNotEmpty() && name !in partiallyMatchedBindings) { + val binding = state.binding + val bindingModifiers = binding.action[KeyActions.MODIFIER] ?: emptySet() + val usesCurrentKey = binding.action.values.any { code in it } + + if (usesCurrentKey) { + // Check if any partially matched binding has more keys than this one + val thisBindingKeyCount = bindingModifiers.size + binding.action.entries + .filter { it.key != KeyActions.MODIFIER } + .flatMap { it.value } + .toSet().size + + val anyLargerPartialMatch = partiallyMatchedBindings.any { partialName -> + val partialState = bindings[partialName] ?: return@any false + val partialBinding = partialState.binding + val partialModifiers = partialBinding.action[KeyActions.MODIFIER] ?: emptySet() + val partialActionKeys = partialBinding.action.entries + .filter { it.key != KeyActions.MODIFIER } + .flatMap { it.value } + .toSet() + val partialKeyCount = partialModifiers.size + partialActionKeys.size + partialKeyCount > thisBindingKeyCount + } + + if (anyLargerPartialMatch) { + continue + } + } + } + onKey(name, state, pressed, code, time) } } diff --git a/src/main/java/de/bixilon/minosoft/gui/rendering/input/key/manager/binding/actions/KeyActionFilter.kt b/src/main/java/de/bixilon/minosoft/gui/rendering/input/key/manager/binding/actions/KeyActionFilter.kt index 21792cd69..f4c18d204 100644 --- a/src/main/java/de/bixilon/minosoft/gui/rendering/input/key/manager/binding/actions/KeyActionFilter.kt +++ b/src/main/java/de/bixilon/minosoft/gui/rendering/input/key/manager/binding/actions/KeyActionFilter.kt @@ -65,7 +65,13 @@ interface KeyActionFilter { override fun check(filter: KeyBindingFilterState, codes: Set, input: InputManager, name: ResourceLocation, state: KeyBindingState, code: KeyCodes, pressed: Boolean, time: ValueTimeMark) { if (!pressed) { - filter.satisfied = false + // On release, check if the released key is a modifier key for this binding + // Just having filter.satisfied = false breaks combination keys when player stops holding the keys. + if (code in codes) { + // A modifier key was released, allow the binding deactivation + filter.result = false + return + } return } if (code in codes) return diff --git a/src/main/java/de/bixilon/minosoft/gui/rendering/sky/clouds/CloudRenderer.kt b/src/main/java/de/bixilon/minosoft/gui/rendering/sky/clouds/CloudRenderer.kt index a290e38c5..b358a1655 100644 --- a/src/main/java/de/bixilon/minosoft/gui/rendering/sky/clouds/CloudRenderer.kt +++ b/src/main/java/de/bixilon/minosoft/gui/rendering/sky/clouds/CloudRenderer.kt @@ -14,6 +14,7 @@ package de.bixilon.minosoft.gui.rendering.sky.clouds import de.bixilon.kmath.vec.vec2.i.Vec2i +import de.bixilon.kmath.vec.vec3.f.Vec3f import de.bixilon.kutil.latch.AbstractLatch import de.bixilon.kutil.observer.DataObserver.Companion.observe import de.bixilon.kutil.time.TimeUtil.now @@ -172,7 +173,8 @@ class CloudRenderer( } private fun draw() { - shader.cloudsColor = color.calculate() + val fullbright = context.session.profiles.rendering.light.fullbright + shader.cloudsColor = if (fullbright) WHITE else color.calculate() setYOffset() @@ -187,6 +189,7 @@ class CloudRenderer( } companion object : RendererBuilder { + private val WHITE = Vec3f(1.0f, 1.0f, 1.0f) override fun build(session: PlaySession, context: RenderContext): CloudRenderer? { val sky = context.renderer[SkyRenderer] ?: return null diff --git a/src/main/resources/assets/minosoft/language/en_us.lang b/src/main/resources/assets/minosoft/language/en_us.lang index 82a6279d1..a9925f9ee 100644 --- a/src/main/resources/assets/minosoft/language/en_us.lang +++ b/src/main/resources/assets/minosoft/language/en_us.lang @@ -193,6 +193,7 @@ minosoft:profiles.profile.list.button.create=Create profile ### Pause minosoft:menu.pause.back_to_game=Back to game +minosoft:menu.pause.options=Options minosoft:menu.pause.options.debug=Debug options minosoft:menu.pause.disconnect=Disconnect minosoft:menu.pause.disconnect.confirm=§cClick again to disconnect @@ -200,6 +201,138 @@ minosoft:menu.pause.show_eros=Show eros again minosoft:menu.pause.exit=Exit minosoft:menu.pause.exit.confirm=§cClick again to exit +### Options Menu + +minosoft:menu.options.title=Options +minosoft:menu.options.fov=FOV +minosoft:menu.options.master_volume=Master Volume +minosoft:menu.options.realms_notifications=Realms Notifications: ON +minosoft:menu.options.skin_customization=Skin Customization... +minosoft:menu.options.secret_settings=Super Secret Settings... +minosoft:menu.options.music=Music & Sounds... +minosoft:menu.options.broadcast=Broadcast Settings... +minosoft:menu.options.video=Video Settings... +minosoft:menu.options.controls=Controls... +minosoft:menu.options.language=Language... + +### Controls Settings + +minosoft:key.title=Controls +minosoft:key.reset_all=Reset All +minosoft:key.mouse_sensitivity=Mouse Sensitivity + +# Category Headers +minosoft:key.category.movement=Movement +minosoft:key.category.gameplay=Gameplay +minosoft:key.category.hotbar=Inventory +minosoft:key.category.camera=Camera +minosoft:key.category.miscellaneous=Miscellaneous +minosoft:key.category.debug=Debug + +# Movement +minosoft:key.move_forward=Move Forward +minosoft:key.move_backwards=Move Backwards +minosoft:key.move_left=Strafe Left +minosoft:key.move_right=Strafe Right +minosoft:key.move_jump=Jump +minosoft:key.move_sneak=Sneak +minosoft:key.move_sprint=Sprint + +# Gameplay +minosoft:key.attack=Attack/Destroy +minosoft:key.use_item=Use Item/Place Block +minosoft:key.pick_item=Pick Block +minosoft:key.drop_item=Drop Item +minosoft:key.swap_items=Swap Item in Hands +minosoft:key.local_inventory=Open/Close Inventory +minosoft:key.stop_spectating=Stop Spectating + +# Hotbar +minosoft:key.hotbar_slot_1=Hotbar Slot 1 +minosoft:key.hotbar_slot_2=Hotbar Slot 2 +minosoft:key.hotbar_slot_3=Hotbar Slot 3 +minosoft:key.hotbar_slot_4=Hotbar Slot 4 +minosoft:key.hotbar_slot_5=Hotbar Slot 5 +minosoft:key.hotbar_slot_6=Hotbar Slot 6 +minosoft:key.hotbar_slot_7=Hotbar Slot 7 +minosoft:key.hotbar_slot_8=Hotbar Slot 8 +minosoft:key.hotbar_slot_9=Hotbar Slot 9 + +# Camera +minosoft:key.zoom=Zoom +minosoft:key.camera_third_person=Toggle Third Person + +# Misc +minosoft:key.take_screenshot=Take Screenshot +minosoft:key.toggle_fullscreen=Toggle Fullscreen +minosoft:key.open_chat=Open Chat +minosoft:key.open_command=Open Command +minosoft:key.enable_hud=Toggle HUD + +# Debug +minosoft:key.debug_polygon=Toggle Polygon Mode +minosoft:key.cursor_mode=Toggle Cursor Mode +minosoft:key.pause_incoming=Pause Incoming Packets +minosoft:key.pause_outgoing=Pause Outgoing Packets +minosoft:key.camera_debug_view=Toggle Debug Camera +minosoft:key.toggle_hitboxes=Toggle Hitboxes +minosoft:key.chunk_border=Toggle Chunk Borders +minosoft:key.clear_chunk_cache=Clear Chunk Cache +minosoft:key.recalculate_light=Recalculate Light +minosoft:key.fullbright=Toggle Fullbright +minosoft:key.switch_fun_effects=Switch Fun Effects +minosoft:menu.options.chat=Chat Settings... +minosoft:menu.options.resource_packs=Resource Packs... +minosoft:menu.options.snooper=Snooper Settings... +minosoft:menu.options.done=Done + +### Video Settings + +minosoft:menu.options.video.title=Video Settings +minosoft:menu.options.video.gui=GUI Settings... +minosoft:menu.options.video.clouds=Clouds... +minosoft:menu.options.video.fullscreen=Fullscreen +minosoft:menu.options.video.render_distance=Render Distance +minosoft:menu.options.video.biome_radius=Biome Radius +minosoft:menu.options.video.brightness=Brightness +minosoft:menu.options.video.view_bobbing=View Bobbing +minosoft:menu.options.video.fullbright=Fullbright +minosoft:menu.options.video.fog=Fog +minosoft:menu.options.video.dynamic_fov=Dynamic FOV + +### Cloud Settings + +minosoft:menu.options.clouds.title=Cloud Settings +minosoft:menu.options.clouds.enabled=Clouds +minosoft:menu.options.clouds.flat=Flat Clouds +minosoft:menu.options.clouds.movement=Cloud Movement +minosoft:menu.options.clouds.max_distance=Max Distance +minosoft:menu.options.clouds.layers=Cloud Layers + +### Chat Settings + +minosoft:menu.options.chat.title=Chat Settings +minosoft:menu.options.chat.hidden=Hidden +minosoft:menu.options.chat.text_filtering=Text Filtering +minosoft:menu.options.chat.colors=Chat Colors +minosoft:menu.options.chat.mode=Chat Mode + +### Language Settings + +minosoft:menu.options.language.title=Language Settings + +### GUI Settings + +minosoft:menu.options.gui.title=GUI Settings +minosoft:menu.options.gui.hud_scale=HUD Scale +minosoft:menu.options.gui.wawla=WAWLA +minosoft:menu.options.gui.wawla_limit_reach=WAWLA Limit Reach +minosoft:menu.options.gui.wawla_identifier=WAWLA Identifier +minosoft:menu.options.gui.wawla_block=WAWLA Block Info +minosoft:menu.options.gui.wawla_entity=WAWLA Entity Info +minosoft:menu.options.gui.wawla_entity_health=WAWLA Entity Health +minosoft:menu.options.gui.wawla_entity_hand=WAWLA Entity Hand + minosoft:main.about.text=Glad that you test my software :)\nThis project is in a really early stage, don't use it on online servers!\nI am looking for contributors :) Please help me and make this usable minosoft:main.about.crash=Create crash report diff --git a/src/main/resources/assets/minosoft/language/es_es.lang b/src/main/resources/assets/minosoft/language/es_es.lang index 12d76b58a..77684c7ca 100644 --- a/src/main/resources/assets/minosoft/language/es_es.lang +++ b/src/main/resources/assets/minosoft/language/es_es.lang @@ -128,3 +128,8 @@ minosoft:general.dialog.profile.create.cancel_button=Cancelar minosoft:profiles.profile.name=Nombre minosoft:profiles.profile.description=Descripción minosoft:profiles.profile.disk_path=Ruta del disco + +### Language Settings + +minosoft:menu.options.language.title=Ajustes de Idioma +minosoft:menu.options.done=Hecho