跳转至

Kotlin 支持

Since 2.2.1

LDLib2 提供了类型安全的 Kotlin DSL 用于构建 UI 树。它通过构建器模式、嵌套 lambda 和运算符重载包装 Java API,使 UI 构建更加简洁和结构化。


核心概念

DSL 围绕两个互补的概念构建:

  • ElementSpec — 保存在构建时应用于元素的配置(id、类、布局、样式等)。
  • UIContainer — 在生成最终 UIElement 之前管理元素子项、事件和数据绑定的构建器。

每个构建器函数都遵循相同的双 lambda 签名:

element(
    /* spec block: 配置元素 */
    { layout = { size(200.px) } }
) {
    /* init block: 添加子项、事件、绑定 */
    label({ text("Hello!") })
}

spec block 是可选的。对于不需要配置的元素,可以完全省略:

element {
    button({ text("Click") })
}

Element Spec

spec block 是一个 ElementSpec 接收器。它公开以下属性:

属性 类型 描述
id String? 设置选择器和查询使用的元素 ID。
focusable Boolean? 元素是否可以接收键盘焦点。
visible Boolean? 元素是否被渲染。
active Boolean? 元素是否参与事件和 tick。
layout TaffyLayoutStyleDsl.() -> Unit 布局配置块。
style BasicStyle.() -> Unit 视觉样式配置块。
cls ClassPatchDsl.() -> Unit 类添加/移除块。
element({
    id = "my-panel"
    focusable = true
    visible = true
    active = true

    layout = {
        size(200.px)
        flexDirection(FlexDirection.ROW)
        gap { all(4.px) }
        padding { all(8.px) }
    }

    style = {
        background(MCSprites.RECT)
        opacity(0.9f)
        tooltips("my.tooltip.key")
    }

    cls = {
        +"active"     // adds the class "active"
        -"disabled"   // removes the class "disabled"
    }
}) { }

layout

使用 TaffyLayoutStyleDsl。阅读 布局 了解所有可用属性。常见示例:

layout = {
    size(200.px)           // width and height
    width(50.pct)          // 50%
    height(auto)           // auto
    flex(1)                // grow/shrink
    gap { all(4.px) }
    padding { all(8.px) }
    margin { top(2.px) }
    position(TaffyPosition.ABSOLUTE)
    pos { left(10.px); top(10.px) }
    display(TaffyDisplay.GRID)
    grid {
        templateColumns("1fr 1fr")
        templateRows("auto 1fr")
        row("1")
        column("2")
    }
}

style

直接使用 BasicStyle。阅读 UIElement 样式 了解所有可用属性。

cls

使用带有 +- 运算符的 ClassPatchDsl

cls = {
    +"selected"     // element.addClass("selected")
    -"hidden"       // element.removeClass("hidden")
}

构建子元素

第二个 lambda 是一个 UIContainer 接收器。你可以通过在其中调用 DSL 函数来添加子构建器:

element({ layout = { size(200.px); gap { all(4.px) } } }) {
    label({ text("Title") })
    button({ text("OK") }) {
        events { UIEvents.CLICK on { /* handle */ } }
    }
    row {
        switch()
        toggle()
    }
}

row {}column {}

预配置 flex 布局的简写构建器:

  • row {} — 设置 flexDirection = ROWalignItems = FLEX_START
  • column {} — 设置 alignItems = FLEX_START(默认列方向)

两者都接受可选的 spec:

row({ layout = { gap { all(4.px) }; padding { all(8.px) } } }) {
    fluidSlot()
    itemSlot({ item = Items.APPLE.defaultInstance })
}

column {
    switch()
    toggle()
}

事件

events {}events(capture = true) {} 块中注册事件监听器。lambda 接收 EventsDsl 作为 this,正在构建的元素作为第一个参数。

element({ layout = { size(50.px) } }) {
    events { e ->
        // += operator: add a listener
        UIEvents.CLICK += UIEventListener { event ->
            e.animation().duration(0.3f).style(PropertyRegistry.OPACITY, 0f).start()
        }

        // on infix: concise single-expression form
        UIEvents.MOUSE_ENTER on { event -> event.currentElement.addClass("hover") }
        UIEvents.MOUSE_LEAVE on { event -> event.currentElement.removeClass("hover") }

        // -= operator: remove a previously registered listener
        UIEvents.CLICK -= myStoredListener
    }

    // Capture phase: fires before children see the event
    events(capture = true) {
        UIEvents.CLICK on { it.stopPropagation() }
    }
}

events { e -> ... } 中的 e 参数是正在构建的 UIElement。当你需要在事件处理器中引用元素时很有用(例如,调用 e.animation())。


服务端事件

serverEvents {} 的工作方式与 events {} 完全相同,但监听器在服务端执行:

button {
    serverEvents {
        UIEvents.MOUSE_DOWN += {
            // runs on the server
            fluidTank.setFluid(FluidStack(Fluids.WATER, 1000))
        }
    }
}

也支持捕获阶段:

serverEvents(capture = true) {
    UIEvents.CLICK on { it.stopPropagation() }
}

数据绑定

绑定在服务端和客户端之间同步值。它们在 UIContainerinit block 中设置。

绑定需要双端支持。它们只在 @LDLRegister(服务端)菜单上下文中有意义,而不是仅 @LDLRegisterClient 的屏幕。

bind — 双向绑定

// Bind a mutable Kotlin property reference (most concise)
switch { bind(::myBool) }
textField { bind(::myString) }
scrollerHorizontal({ layout = { width(100.pct) } }) { bind(::myFloat) }

// Bind with explicit getter and setter
switch { bind({ myData.enabled }, { myData.enabled = it }) }

bindS2C — 服务端 → 客户端(只读)

label {
    bindS2C({
        Component.literal("Value: ")
            .append(Component.literal(myBool.toString()).withStyle(ChatFormatting.AQUA))
    })
}

bindC2S — 客户端 → 服务端(只写)

textField {
    bindC2S({ newValue -> serverData = newValue })
}

dataSourceobserver

直接用于数据感知组件的底层辅助函数。这些是客户端的,不会跨网络同步:

var localValue = "hello"
label { dataSource({ Component.literal(localValue) }) }
textField {
    observer { localValue = it }
    dataSource { localValue }
}

RPC 事件

对于带有类型参数的显式客户端 → 服务端调用,在元素引用(UIContainerelement 属性)上使用 rpcEvent 扩展:

button {
    // Declare the RPC: the lambda runs on the server when triggered
    val rpcEvent = element.rpcEvent { value: String ->
        string = value
    }

    // Trigger from the client
    events {
        UIEvents.MOUSE_DOWN += { rpcEvent.send("rpc") }
    }
}

直接 API 访问

对于 spec 或 init block 未涵盖的方法,可以直接访问元素:

api {}

在底层 UIElement 上调用一个块。在构建阶段立即运行:

element({}) {
    api {
        setFocusable(true)
        setEnforceFocus { /* lost-focus handler */ }
        stopInteractionEventsPropagation()
    }
}

onBuild {}

在元素完全构建后调用。当你需要最终元素引用进行延迟设置时使用:

element({}) {
    onBuild { builtElement ->
        builtElement.addEventListener(UIEvents.ADDED) { /* ... */ }
    }
}

内联扩展函数

.build() 之后,返回的 UIElement 可以链式调用扩展函数:

element({}) { }
    .layoutDsl { width(100.px) }
    .styleDsl { background(MCSprites.RECT) }
    .clsDsl { +"my-class" }

完整示例:客户端 UI

一个包含动画、数据绑定组件和物品/流体槽的纯客户端 UI。

@LDLRegisterClient(name = "dsl", registry = "ldlib2:screen_test")
class TestDSL : IScreenTest {
    override fun createUI(entityPlayer: Player?): ModularUI? {
        return ModularUI.of(UI.of(
            element({
                layout = { size(200.px); gap { all(3.px) }; padding { all(4.px) } }
                style = { background(Sprites.RECT) }
                cls = { +"cla" }
            }) {
                // Click to animate: shrink + fade out, then restore
                element({
                    layout = { size(30.px) }
                    style = { background(Sprites.RECT_SOLID).tooltips("animation") }
                }) {
                    events { e ->
                        UIEvents.CLICK += {
                            e.animation()
                                .duration(1f).ease(Eases.QUAD_IN_OUT)
                                .style(PropertyRegistry.TRANSFORM_2D, Transform2D().scale(0.5f).translate(100f, 0f))
                                .style(PropertyRegistry.OPACITY, 0f)
                                .onFinished { _ ->
                                    e.animation()
                                        .ease(Eases.QUART_IN_OUT)
                                        .style(PropertyRegistry.TRANSFORM_2D, Transform2D())
                                        .style(PropertyRegistry.OPACITY, 1f)
                                        .start()
                                }
                                .start()
                        }
                    }
                }

                // Label bound to a local variable
                var value = "hello"
                label { dataSource({ Component.literal(value) }) }

                // Button toggles the value
                button({
                    text("hello <-> world")
                    onClick = { value = if (value == "hello") "world" else "hello" }
                })

                // Numeric text field (client-side only)
                var number = 10.4f
                textField {
                    observer { number = it.toFloatOrNull() ?: number }
                    dataSource { number.toString() }
                }.asNumeric(0.3f, 100f)

                // Row with slots and controls
                row({ layout = { gap { all(2.px) } } }) {
                    fluidSlot()
                    itemSlot({ item = Items.APPLE.defaultInstance })
                    column {
                        switch()
                        toggle()
                    }
                }
            }
        ), entityPlayer)
    }
}

完整示例:同步菜单 UI

一个带有双向数据绑定、服务端事件和 RPC 的服务端同步菜单。

@LDLRegister(name = "dsl_sync", registry = "ldlib2:menu_test")
class TestMenuDSL : IMenuTest {
    private var bool = true
    private var string = "hello"
    private var number = 0.5f

    override fun createUI(player: Player): ModularUI {
        val itemHandler = ItemStackHandler(2)
        val fluidTank = FluidTank(2000)

        val root = element({ cls = { +"panel_bg" } }) {
            label({ text("Data Between Screen and Menu") })

            // Slots with bound inventory/tank
            row({ layout = { gap { all(2.px) } } }) {
                itemSlot({ bind(itemHandler, 0) })
                itemSlot({ bind(ItemHandlerSlot(itemHandler, 1).setCanTake({ false })) })
                fluidSlot({ bind(fluidTank, 0) })
            }

            element({ layout = { gap { all(2.px) } } }) {
                // Bidirectional sync via Kotlin property references
                switch { bind(::bool) }
                textField { bind(::string) }
                scrollerHorizontal({ layout = { width(100.pct) } }) { bind(::number) }

                // Server-to-client read-only: always reflects server state
                label {
                    bindS2C({
                        Component.literal("s->c only: ")
                            .append(Component.literal(bool.toString()).withStyle(ChatFormatting.AQUA)).append(" ")
                            .append(Component.literal(string).withStyle(ChatFormatting.RED)).append(" ")
                            .append(Component.literal("%.2f".format(number)).withStyle(ChatFormatting.YELLOW))
                    })
                }

                button {
                    // Server-side event: runs on server when the button is clicked
                    serverEvents {
                        UIEvents.MOUSE_DOWN += {
                            fluidTank.setFluid(
                                if (fluidTank.fluid.fluid === Fluids.WATER)
                                    FluidStack(Fluids.LAVA, 1000)
                                else
                                    FluidStack(Fluids.WATER, 1000)
                            )
                        }
                    }
                    // RPC event: client sends a string value to the server
                    val rpcEvent = element.rpcEvent { clickValue: String -> string = clickValue }
                    events {
                        UIEvents.MOUSE_DOWN += { rpcEvent.send("rpc") }
                    }
                }

                inventorySlots()
            }
        }
        return ModularUI(UI.of(root, StylesheetManager.MODERN), player)
    }
}