Skip to content
kzheart edited this page Oct 19, 2026 · 23 revisions

UI 模块

构建物品栏菜单、分页、物品投放区、聊天控制面板和聊天输入流程。点击、拖拽、数字键、双击、关闭归还和异步聊天事件由统一监听器处理,业务代码只描述模型和动作。

模块名 制品 自动带入
ui me.kzheart.klib:klib-ui core、item、command(含 lang)

快速开始

klib { modules { ui() } }

依赖、直接引用制品和打包方式见 构建与打包。

ui 通过 item 使用原生标签,无需 Item-NBT-API 或为其添加 CodeMC 仓库。手动打包时包含表中的模块及其 Kotlin、协程、配置和文本运行时依赖,具体规则见构建页。

最简单的用法是注解菜单:

Menus menus = Menus.install(this);
menus.open(player, new Screen());

会话菜单注解

  • Menus.install(plugin) 创建绑定插件的服务;组件内用 Menus.install(context(), plugin)。
  • menus.open(player, new Screen()) 读取 @Menu、@Button、@Entries、@Click,通过现有 MenuCompiler 和 MenuRenderer 打开。
  • 每个菜单对象保存自己的页码和筛选状态。MenuClick.refresh() 重新计算模型,原会话、物品栏和任务归属不变。
  • 底层 MenuHolder.refresh(MenuModel) 同样可以更新模型,条件是标题、尺寸不变且不覆盖投放区。

完整签名、列表容量和关闭约束见 组件与注解。

创建并打开菜单

三步:构建 MenuTemplate → 编译为不可变 MenuModel → 交给作用域持有的 MenuRenderer 打开。

import me.kzheart.klib.item.Items;
import me.kzheart.klib.ui.MenuCompiler;
import me.kzheart.klib.ui.MenuEntry;
import me.kzheart.klib.ui.MenuModel;
import me.kzheart.klib.ui.MenuRenderer;
import me.kzheart.klib.ui.MenuTemplate;

MenuRenderer menus = MenuRenderer.install(scope, plugin);

MenuTemplate template = MenuTemplate.builder("采集菜单", 3)
        .layout(
                "         ",
                "    T    ",
                "         ")
        .character('T', MenuEntry.of(
                Items.of("IRON_PICKAXE").name("&6领取工具").build(),
                click -> giveTool(click.player())))
        .build();

MenuModel model = MenuCompiler.compile(template);
menus.open(player, "gather-main", model);
  • 行数必须是 1 到 6。使用布局时行数必须与菜单行数一致,每行恰好 9 个字符,空格表示空槽。
  • 也可以用 slot(...) 或 MenuCompiler.compileSlots(...) 直接绑定槽位。
  • 编译后的模型不可变,可以给多个玩家重复使用。
  • MenuEntry 保存物品快照和点击动作。框架拦截已编译条目的移动,统一处理双击、数字键、拖拽和 shift-click。业务动作不要再自行修改菜单中的展示物品。

会话状态与合并刷新

openReactive(...) 在现有 MenuSession 上保存每次打开独立的状态。Java 和 Kotlin 共用同一核心;状态由业务定义,使用不可变对象,集合也应复制后再保存。

import me.kzheart.klib.ui.ReactiveMenuSession;

ReactiveMenuSession<Integer> counter = menus.openReactive(
        player, "counter", Integer.valueOf(0), ui ->
                MenuCompiler.compile(MenuTemplate.builder("计数", 1)
                        .slot(0, MenuEntry.of(
                                Items.of("PAPER").name("次数:" + ui.state()).build(),
                                click -> ui.update(value -> value + 1)))
                        .slot(8, MenuEntry.of(
                                Items.of("BARRIER").name("关闭").build(),
                                click -> ui.navigate(() -> ui.viewer().closeInventory())))
                        .build()));
入口 行为
state() / update(reducer) 主线程读写;状态按 equals 相等时不刷新,返回 null 或重入更新直接失败
invalidate() 可从任意线程通知;下一 tick 合并一次重建,关闭后的通知忽略
bind(subscription) 把 Disposable 订阅交给已有菜单 Scope,关闭、替换、父作用域重建/关闭时释放
holder() / scope() 获取当前窗口的 MenuHolder 与 MenuSession.scope();标题或尺寸变化会更换窗口和 Scope
isCurrent() 主线程检查原 Player 实例仍在线,且顶部物品栏持有本次打开唯一的 MenuHolder
navigate(action) 下一 tick 执行导航;等待期间的重复调用返回 false,状态改变或会话失效会丢弃动作
  • 视图函数在首次打开和刷新时调用,只构建完整 MenuModel。不要在里面扫描解锁、查询数据库、订阅事件或修改状态;读取/扫描放在打开前或明确的业务事件中。
  • 相同菜单定义可以复用,每次打开的状态、读取与订阅不共享。框架不会深复制任意业务状态,不能原地修改状态对象来绕过 update。
  • Bukkit 可能为同一个底层物品栏返回不同包装对象;会话使用 MenuHolder 对象身份判断当前菜单,仍拒绝其他 holder、已关闭会话和重连后的旧 Player。
  • 例如图鉴 lore 翻页只更新以条目 ID 为键的页码;商品浏览使用已准备好的目录/计数器快照。配方、价格、解锁规则仍由业务决定。

外部变更源返回 Disposable 时直接绑定,异步通知只请求刷新:

counter.bind(catalogChanges.subscribe(counter::invalidate));

Kotlin 直接传入已有 Disposable;把业务取消函数适配为订阅时显式写 SAM,避免泛型 bind 的裸 lambda 推断失败:

import me.kzheart.klib.scope.Disposable

counter.bind(Disposable { unsubscribe() }) // unsubscribe 由业务提供

这里的 catalogChanges 由业务提供。没有默认轮询;需要定时刷新时复用 counter.scope().every(period, counter::invalidate)。

异步读取与过期结果

readLatest 只观察已有的读取结果,不负责启动或取消领域操作。它把成功归并与错误处理切回主线程,并检查本次会话、原 Player、当前 Inventory、状态版本和同 key 请求身份。

CompletionStage<Boolean> applied = counter.readLatest(
        "available-count", service.readAvailableCount(player.getUniqueId()),
        (current, available) -> available,
        failure -> player.sendMessage("读取失败,请重试"));
情况 观察结果与回调
当前读取成功 主线程调用 reducer;结果为 true,变化的状态下一 tick 刷新
同 key 新读取、状态已改变或菜单关闭/被替换 旧观察结果为 false;不调用 reducer 或错误回调
同 UUID 已是另一个 Player,或玩家已换 Inventory 不应用结果,返回 false
当前来源失败 主线程调用错误回调;观察结果保留原异常
调度被拒绝、reducer 失败 观察结果异常终结
  • 每次真实状态改变会让此前所有 key 的读取过期,避免旧分类/页码结果覆盖新状态。互相独立的数据可以先在业务层合并为同一快照,再一次更新。
  • true 表示结果通过检查并已归并,不代表一定发生槽位写入;相等状态不会重画。返回阶段上的普通 thenAccept 等回调没有线程保证,需要 GUI 操作时再次使用 AsyncTasks.thenSync。
  • 调用 applied.toCompletableFuture().cancel(false) 会立即释放本次观察与排队回调;外部源 future 保持运行。
  • 关闭只终结展示观察,不会取消传入的源 future,也不会撤销、重试或判定购买/存款/领取结果。交易寿命和版本核验留在领域服务中,不要用 readLatest 包裹写操作。

安全导航与槽位差量

  • navigate(...) 用显式一 tick 延迟处理打开/关闭物品栏、返回和其他界面跳转;不要在 InventoryClickEvent 动作里直接调用 openInventory / closeInventory。
  • MenuHolder.refresh(...) 先校验完整候选模型,再比较每个展示槽位的完整 ItemStack.equals,包括数量和 meta;相等图标跳过 setItem,但动作表仍更新到新模型。
  • 消失的展示槽置空,不再全页 clear。未归框架管理的槽位不参与展示 diff;投放区始终使用 DropZoneController 的权威快照,关闭归还协议不变。
  • 状态菜单的标题、尺寸变化会自动经 navigate 重开;底层 MenuHolder.refresh 仍只接受相同标题和尺寸。刷新重叠投放区直接拒绝。
  • 展示按钮只响应左、右及 Shift 左右点击;数字键、双击、丢弃、副手交换及未知输入不触发业务动作。玩家真实输入只放在投放区。
  • 槽位提交失败时关闭会话,原动作表不会发布一半;关闭原因是 RENDER_FAILED。状态菜单的视图构建失败也会关闭并记录错误,防止半更新界面继续接受动作。
  • 聊天输入会关闭当前 Inventory 时,不要尝试复活已关闭的状态会话。需要跨输入恢复的流程由业务在外层 Scope 保留导航上下文,再显式打开新会话。

页面导航与结构变化

MenuPage<S> 保存可重用的页面定义。push(childPage, childState) 进入子页,back() 返回实际父页;父页保留离开时的状态引用,业务必须使用不可变状态。 两者与 navigate 共用一 tick 门禁;同 tick 重复请求返回 false,执行前重新核对 Player 实例、holder 和状态代际。

import me.kzheart.klib.ui.MenuPage;

MenuPage<Integer> detail = MenuPage.<Integer>builder("detail", ui ->
        MenuCompiler.compile(MenuTemplate.builder("详情:" + ui.state(), 1)
                .slot(8, MenuEntry.of(Items.of("ARROW").name("返回").build(),
                        click -> ui.back())).build())).build();

MenuPage<Integer> browse = MenuPage.<Integer>builder("browse", ui ->
        MenuCompiler.compile(MenuTemplate.builder("列表", 1)
                .slot(0, MenuEntry.of(Items.of("PAPER").name("查看").build(),
                        click -> ui.push(detail, ui.state()))).build()))
        .onOpen(ui -> ui.bind(catalogChanges.subscribe(ui::invalidate)))
        .build();

menus.openReactive(player, browse, Integer.valueOf(0), () -> openMain(player));

catalogChanges 和 openMain 由业务提供。根页的 back() 执行传入的根动作;没有强制命令、服务器名或默认主菜单。 原有 openReactive(player, name, state, view) 共用该引擎,根动作默认关闭物品栏。

页面入口 执行时机与用途
builder(id, view) 声明页面 ID 与纯视图;视图只返回 MenuModel
configureWindow(Consumer<MenuSession>) 每个新窗口显示前配置投放区;不能依赖已经附着的 UI 或在此导航
onOpen(Consumer<ReactiveMenuSession<S>>) 窗口附着并打开后绑定订阅、定时器及 readLatest 读取
push(page, state) / back() 下一 tick 切换;父页返回时重新创建会话、窗口与订阅
  • 同页标题或尺寸变化时,刷新 tick 只安排导航,下一 tick 再按最新模型重建;标题、尺寸相同仍走差量刷新。
  • 若结构刷新遇到已排队的导航,会合并保留刷新请求;导航过期或执行后仍停留本页时,后续 tick 自动按最新状态刷新,无需再次调用 invalidate()。成功离页或关闭后,旧窗口的待刷新任务随 Scope 取消。
  • 同页重建保留状态和逻辑 UI 控制器,但 holder 和 Scope 更换。不要缓存旧 holder()、scope();旧窗口的排队导航和异步观察不能作用到新窗口。
  • 离开或重建时,旧窗口的投放物按关闭协议恰好归还一次,旧订阅释放;新投放区为空,由 configureWindow 重建,不迁移真实玩家物品。
  • 每个新窗口都重新执行 onOpen。这里只重建读取与订阅,不发起购买、扣款或领取等业务写操作;源业务 future 不取消,也不重放。
  • 手动关窗、退出、被外部界面替换、Scope 关闭或渲染失败会清除导航链。关闭流程中的旧 holder 在实际关窗前仍拦截点击和拖拽。

需要 MiniMessage 字体标题时,在安装时复用 MenuRenderer.install(scope, plugin, BukkitAdventure::inventory, errorHandler);布局只存标题文本,不计算资源包背景偏移。

从配置编译菜单

配置模块把 YAML 解码为普通 Map<String, Object> 后,用 MenuCompiler.compileYaml(...) 编译:

  • 支持的键:title、rows、cancel-clicks、layout、items、slots。
  • 业务通过 YamlItemResolver 把配置中的物品 ID 解析成 MenuEntry,配置层不需要知道 Bukkit 或第三方物品 API。

配置布局与动态字符绑定

动态菜单使用 MenuLayout.from(yaml, sourcePath, functionalCharacters, resolver) 加载不可变候选布局,再在纯视图里 compile(bindings)。 配置使用上述相同键;items 中的值是交给 resolver 的物品 ID,代码只绑定功能字符,不固定槽位和列表容量。

title: '物品列表'
rows: 2
layout:
  - 'MMMMMMMMM'
  - '    B    '
items:
  B: back
val layouts = ConfigSnapshot(MenuLayout.from(yaml, "menus.yml:browse", setOf('M', 'B'), resolver))
val browse = MenuPage.builder<BrowseState>("browse") { ui ->
    val layout = layouts.value()
    val page = Paginator(values, layout.slots('M').size).page(ui.state().page)
    layout.compile { bindings ->
        bindings.entries('M', page.values().map { value ->
            MenuEntry.of(value.icon(), MenuAction { ui.push(detail, value.id) })
        })
        bindings.action('B', MenuAction { ui.back() })
    }
}.build()

ConfigSnapshot 来自 Config 模块;BrowseState、values、detail 和 resolver 由业务提供,数据读取在进入视图前完成。 slots('M') 按布局行列顺序返回不可修改列表,可直接作为现有 Paginator 的容量来源。 Java 使用相同入口,例如 layout.compile(b -> { b.entries('M', entries); b.action('B', click -> ui.back()); })。

MenuBindings 方法 行为
character(char, MenuEntry) 对该字符全部槽位绑定同一展示条目
action(char, MenuAction) 保留该字符在 YAML 的图标和点击声音,只绑定动作;必须配置图标
entries(char, List<MenuEntry>) 顺序填充,剩余槽位为空;超过布局容量时报错,不截断
hide(char) 明确绑定但不显示图标,例如没有上一页
reserve(char) 明确留空;需要真实物品输入时仍必须由 configureWindow 添加 DropZoneController
  • 加载时校验 1~6 行、每行 9 字符、所有功能字符存在、静态字符有图标、显式槽位不冲突及未知配置键。
  • 编译时每个功能字符必须且只能绑定一次。条件不满足也要调用 hide 或 reserve;绑定对象不能保存到编译回调之外使用。
  • 加载和绑定错误包含 sourcePath 及具体路径,例如 menus.yml:browse.layout[0][3]、menus.yml:browse.bindings[M]。
  • from 不发布配置。把所有布局放进现有 ConfigSnapshot.prepare(...) 候选,全部通过后 commit;加载失败保留旧快照,UI 不新增另一套重载机制。
  • 重载后调用当前 UI 的 invalidate();视图读取本轮快照。会话投放区槽位若需变化,应通过标题/尺寸改变或显式导航重开窗口,不在原窗口覆盖真实输入槽。

分页

Paginator<T> 会复制数据,并把请求页码限制到合法范围:

Paginator<Listing> paginator = new Paginator<Listing>(listings, 45);
Page<Listing> page = paginator.page(requestedPage);

for (Listing listing : page.values()) {
    // 将当前页条目编译到菜单槽位
}
  • 页码从 0 开始。
  • 数据为空时 pageCount() 也返回 1。
  • 上一页、下一页按钮按 Page 的当前索引和总页数决定是否展示。

接收玩家投放的物品

在 open(...) 的会话配置回调中添加 DropZoneController。投放区持有收到物品的克隆,支持放置、shift 插入、拖拽和取回。

Set<Integer> inputSlots = new LinkedHashSet<Integer>(
        Arrays.asList(Integer.valueOf(10), Integer.valueOf(11)));

menus.open(player, "recycle", model, session -> {
    DropZoneController zone = new DropZoneController(
            inputSlots,
            item -> item.getType() != Material.AIR);
    session.addDropZone(zone);
});
  • 菜单关闭、被其他菜单替换或插件作用域关闭时,会话排空投放区,把物品交给打开菜单时配置的归还目标。
  • MenuRenderer 默认先归还玩家背包,溢出时掉落在玩家位置。
  • 不要把真实玩家物品作为普通 MenuEntry 展示;可取回的物品放进投放区。

通过聊天收集输入

每个插件作用域只需安装一个 BukkitChatPrompts:

BukkitChatPrompts prompts = BukkitChatPrompts.install(scope, plugin);

PromptSession<Integer> prompt = prompts.start(player, PromptSpec
        .builder(input -> {
            try {
                int value = Integer.parseInt(input.trim());
                return value > 0
                        ? Optional.of(Integer.valueOf(value))
                        : Optional.<Integer>empty();
            } catch (NumberFormatException ignored) {
                return Optional.empty();
            }
        })
        .timeout(Ticks.seconds(30))
        .cancelKeyword("cancel")
        .invalidMessage("请输入正整数,或输入 cancel 取消")
        .build());

AsyncTasks.thenSync(prompt.completionSync(), scope, outcome -> {
    if (outcome.status() == PromptStatus.ANSWERED && player.isOnline()) {
        int amount = outcome.value().get().intValue();
        openConfirmMenu(player, amount);
    }
});
  • 同一玩家启动新提示会取消旧提示。
  • 框架会取消对应聊天消息,业务插件不用再维护 AsyncPlayerChatEvent 监听器。
  • 示例使用 AsyncTasks.thenSync 消费结果,即使提示已经完成,玩家操作也会重新投递到主线程;关闭期间观察结果会终结,不留下悬挂的派生阶段。

聊天控制面板

聊天面板用于字段、开关和列表编辑,交互方式接近 Adyeshach 的 NPC 编辑器:整页一条消息、一行多个 [按钮]、点击后按实时状态刷新。

类型 职责
ChatPanel 页面定义:id、标题、权限、guard 和每次显示都会调用的页面函数
ChatPanelView 页面函数里的构建器:分组、行、按钮、页脚
ChatPanelButton 回调、命令、预填、复制按钮,可带悬停、当前值和 (R) 重置
ChatPanelSession 玩家会话:跳转、返回、状态行、输入、业务输出
ChatCommandMenu 分组管理入口、可选择的参数字段、草稿与一次性确认
BukkitChatPanels 启动时声明的按钮命令、路由、刷新和告示牌输入

在 KPlugin.setup() 中先安装 Command,再复用同一份聊天提示服务安装面板:

import me.kzheart.klib.command.CommandModule;
import me.kzheart.klib.lang.RichText;
import me.kzheart.klib.ui.chat.BukkitChatPanels;
import me.kzheart.klib.ui.chat.ChatPanel;
import me.kzheart.klib.ui.chat.ChatPanelButton;
import me.kzheart.klib.ui.chat.ChatPanelInput;
import me.kzheart.klib.ui.prompt.BukkitChatPrompts;
import org.bukkit.entity.Player;

CommandModule.install(this);
BukkitChatPrompts prompts = BukkitChatPrompts.install(context().scope(), this);
BukkitChatPanels panels = BukkitChatPanels.install(
        context().scope(), this, "myplugin_panel", prompts);

ChatPanel flight(Player target) {
    return ChatPanel.builder("flight", RichText.plain("飞行设置"))
            .permission("myplugin.flight")
            .guard(viewer -> target.isOnline())
            .content(view -> view
                    .group("飞行")
                    .buttons(
                            ChatPanelButton.action("fly", RichText.plain("允许飞行"),
                                    session -> target.setAllowFlight(!target.getAllowFlight()))
                                    .value(target.getAllowFlight() ? "开" : "关"),
                            ChatPanelButton.action("speed", RichText.plain("速度"),
                                    session -> session.input(ChatPanelInput.of(Speeds::parse)
                                            .hint(RichText.plain("输入 0~1 的速度"))
                                            .current(String.valueOf(target.getFlySpeed())),
                                            value -> {
                                                target.setFlySpeed(value);
                                                session.status("已设置飞行速度");
                                            }))
                                    .value(String.valueOf(target.getFlySpeed()))
                                    .hover(RichText.plain("点击输入新的速度"))
                                    .reset(session -> target.setFlySpeed(0.1f))))
            .build();
}

commands().register("flight", root -> root.playerOnly()
        .permission("myplugin.flight")
        .executes(call -> panels.open((Player) call.sender(), flight((Player) call.sender()))));
  • Speeds::parse 是业务的纯解析函数,返回 Optional<Float>。
  • 安装调用必须位于启动主线程;按钮命令名应以插件为前缀,不能与其他插件共享同一个裸标签。
  • 默认发送复用 Lang 的 Bukkit 富文本桥接;自定义宿主可通过 ChatPanelOptions.builder().sender(...) 传入 BiConsumer<Player, RichText>。

页面函数与刷新

  • 页面函数在打开、每次点击、翻页和输入完成后重新调用,按钮上的值总是实时状态。
  • 动作执行后,面板在下一 tick 重画当前页;多次请求合并为一次。动作里调用 session.open(...) 或 session.input(...) 时显示新的页面或输入状态。
  • 动作里直接调用 panels.open(...) 重新打开时立即发送,不再额外重画;本次动作设置的状态行会保留。
  • 整页作为一条消息发送,并补满固定行数(默认 20 行),旧面板被完整顶出可见区。
  • 布局自上而下:标题、分组正文、状态与导航。导航紧跟正文,留白补在尾部;收起聊天框时显示展开提示。
  • 原版字体按保守字宽排列,中文长按钮提前换行,保留颜色、悬停与点击事件。长标题和状态会截为摘要,完整内容保留在悬停。
  • 正文超出容量时分页,分组尽量整组留在同一页。
ChatPanelView 方法 作用
subtitle(text) 标题后追加 › 副标题
group(title) 开始一个分组,标题显示为 名称 ···
buttons(...) 一行多个按钮,按数量上限(默认 4)与行宽预算同时换行
line(text, buttons...) 一行文字后接按钮
text(text) / blank() 纯文本行 / 空行
footer(buttons...) 追加到导航行

按钮与稳定地址

按钮命令为 /<命令> <页面 id> <按钮 id>,不含一次性令牌。点击时框架按实时状态重建该页面再查找按钮,聊天记录里的旧按钮同样可用。

工厂 行为
action(id, label, callback) 调用业务回调
command(label, text) 以玩家权限执行命令后刷新面板
suggest(label, text) 预填聊天框
copy(label, text) 复制到剪贴板;旧版客户端不支持时改用 suggest
  • 按钮渲染为 [名称 当前值 (R)]:value(...) 显示当前值,reset(callback) 添加 (R) 重置,hover(...) 设置悬停说明。
  • 页面 id 与按钮 id 只能包含字母、数字和 _ . : + -。同一会话中同 id 的页面视为同一页面,后打开的定义替换先前的定义。
  • id 应由目标决定,如 lore.3.edit。列表内容可能变化时,把内容摘要放进 id,避免旧按钮作用到移位后的新内容。
  • 旧按钮的处理:
情况 结果
页面和按钮都在 正常执行,并切换到该页面
按钮在实时页面中已不存在 状态行提示,重画当前页
页面已不在会话中,或一次性页面已使用 状态行提示,重画当前页
缺少按钮权限 状态行提示,不执行
页面权限或 guard 不满足 聊天提示;若是当前页则关闭会话
会话已关闭或空闲过期 聊天提示重新打开
  • 每次点击都重新检查页面权限、guard 与按钮权限;无权限按钮不显示。所有丢弃都会提示玩家,并在 chat-panel 调试模块记录。
  • Java 与 Kotlin 的确认框均使用 ChatPanel.Builder.once():每次打开都是新地址,首个动作执行后整页失效;动作未跳转时自动返回上一页。

状态行、会话与返回

  • session.status(...) 把结果写入状态行,直到下一次操作;不要在动作里直接 sendMessage,否则会被随后的整页刷新顶掉。
  • 动作之外的业务代码可调用 panels.notice(player, text):玩家开着面板时写入状态行,否则直接发送。
  • session.open(panel) 进入子页面并记录返回路径,导航行显示 [返回];session.back() 返回上一页。
  • panels.open(player, panel) 从外部打开:复用会话、清空返回路径并立即发送;权限或 guard 不满足时提示玩家并返回空。
  • [关闭] 或 session.dispose() 发送一屏空行和“面板已关闭”,把面板文字顶走。
  • 会话按空闲时间过期(默认 15 分钟,每次操作续期),由 ChatPanelOptions.builder().idleMillis(...) 调整。每名玩家只有一个会话。

面板内输入

在动作中调用 session.input(...);面板保持显示,状态行显示提示、[预填当前值] 和 [取消输入]:

session.input(ChatPanelInput.text(64)
                .hint(RichText.plain("输入新的名称"))
                .current(currentName),
        value -> {
            rename(value);
            session.status("已修改名称");
        });
  • 提交、取消或超时后自动回到发起输入的页面;回调里再次 input(...) 或 open(...) 时按新的状态显示。
  • 取消或超时在状态行提示;需要额外处理时使用带 cancelled 回调的重载。
  • 解析器在异步聊天线程调用,只做纯解析;回调回到所属 Scope 的主线程,执行前重新检查页面 guard 和发起按钮的权限。
  • 点击面板上的其他按钮会放弃进行中的输入。关闭面板只取消自己启动的提示。

分组命令管理页

已有管理命令可用 ChatCommandMenu 接入。参数显示为可修改的字段,选择页提供实时选项、筛选和手动输入;返回时保留已填内容。

import java.util.Arrays;
import me.kzheart.klib.lang.RichText;
import me.kzheart.klib.ui.chat.ChatCommandMenu;
import me.kzheart.klib.ui.chat.ChatCommandMenu.Action;
import me.kzheart.klib.ui.chat.ChatCommandMenu.Choice;
import me.kzheart.klib.ui.chat.ChatCommandMenu.Field;

ChatCommandMenu menu = new ChatCommandMenu(panels);
menu.open(player, "tools", RichText.plain("工具管理"), "myplugin.admin",
        Arrays.asList(Action.of("give", "管理操作", "发放物品", "myplugin give", "myplugin.give")
                .fields(Field.text("物品").choices(viewer -> Arrays.asList(
                                Choice.of("铁锭", "iron"), Choice.of("金锭", "gold"))),
                        Field.integer("数量", 1, 64, 1))
                .confirm()));
接口 行为
Action.of(id, group, label, command, permission) 分组入口;命令不加 /,仍以实际玩家身份执行
Action.fields(Field...) 按字段顺序拼接参数,不要求一次输入整串参数
Action.confirm() 修改/发放操作使用一次性确认页,确认内容是打开时的快照
Field.text(label) / Field.integer(...) 文本或范围内整数,校验失败不提交
Field.initial(value/provider) 初始值与 (R) 重置值
Field.choices(provider) 在主线程实时查询选项;筛选、翻页和旧按钮点击都重新读取
Field.validate(predicate) 纯文本校验,同时检查手动输入与所选值;不能访问 Bukkit 状态
Field.sign(false) 长文本固定使用聊天输入
  • 字段输入只改草稿;点击执行才调用业务命令。草稿只存在该玩家当前菜单会话,不持久化。
  • 选择值与校验规则由插件提供,不在公共 UI 中复制物品库、玩家或世界规则。
  • 界面使用金色可编辑项、白色当前值、灰色说明、绿色已选/执行与红色重置/错误;颜色之外同时提供文字状态。

保留原业务输出

调用 session.output(() -> player.performCommand("myplugin info")) 暂停整页刷新,让原业务输出保持可见,并在命令主线程派发之后追加 [返回继续操作],避免命令自行清屏顶掉返回入口。 返回后恢复原页和字段草稿,仍检查页面权限与 guard。

  • 输出期间 panels.session(player) 返回空,避免已暂停的聊天页阻挡物品栏选择流程;内部会话仍受退出、关闭和空闲超时管理。
  • 此时 panels.notice(...) 直接发送业务结果,不重画覆盖输出;业务主动打开新的聊天页时恢复正常显示。
  • ChatCommandMenu 自动使用这一流程;异步命令的结果由原业务发送,界面不把“命令已投递”当作操作成功。

告示牌输入

  • ChatPanelOptions.builder().preferSignInput(true) 让短文本/数字默认使用告示牌;长字段使用 ChatPanelInput.sign(false),当前值超出三行或包含换行时自动使用聊天。
  • 服务端提供 Paper 虚拟告示牌 API 时,导航行显示 [输入:聊天] / [输入:告示牌] 切换按钮,偏好按玩家记住到插件关闭;输入中切换会替换当前提示,并重新检查原按钮权限。
  • 选择告示牌时,当前值按原版字体每行 90px 的保守字宽预填前三行,提交时拼接前三行;第四行为提示。
  • 当前值超出前三行容量或包含换行时自动改用聊天输入。多行或很长的内容用 ChatPanelInput.sign(false) 固定使用聊天。
  • 告示牌前三行输入取消词(默认 cancel)可放弃修改;第四行提示不参与提交。
  • 告示牌只发送给该玩家,不修改世界方块。可通过 ChatPanelOptions.builder().signInput(false) 关闭,或传入自定义 ChatPanelSignInput。

选项、快捷键与错误

ChatPanelOptions.Builder 默认 说明
lines(int) 20 每页总行数,尾部补空行
width(int) 300px 原版字体行宽预算,窄聊天框或资源包字体可调小
idleMillis(long) 15 分钟 空闲过期时间
sender(...) Lang 富文本 自定义发送
signInput(...) 自动检测 关闭或替换告示牌输入
preferSignInput(boolean) false 短文本默认优先告示牌,支持切换与长内容自动使用聊天
text(ChatPanelText, RichText) 中文默认文案 替换提示和内置按钮文字
  • hotkeys(onSave, onCancel) 显式启用 F 保存、潜行+F 取消;默认不接管副手交换。
  • 到期、退出或 Scope 关闭只撤销面板及提示,不自动保存业务数据。
  • 动作抛出异常时记录日志并在状态行显示失败提示;ChatPanel.Builder.errorHandler(...) 可改为业务提示。
  • 所有公开面板操作及释放都要求主线程。作用域关闭后,注册的命令立即逻辑停用。

Paper 富文本标题

展开:用 MenuInventoryFactory 支持 MiniMessage 组件标题

需要组件标题时,可为 MenuRenderer 提供 MenuInventoryFactory,不替换框架的点击保护和会话管理。BukkitAdventure 来自 Lang 模块,使用 Paper 自带的 Adventure 类加载器,避免把内嵌的组件类型传给 Paper。

import me.kzheart.klib.lang.BukkitAdventure;
import me.kzheart.klib.ui.MenuRenderer;

MenuRenderer menus = MenuRenderer.install(
        context().scope(), this, BukkitAdventure::inventory,
        (player, model, click, failure) -> lang.send(player, "menu.failed"));
  • 此时 YAML 的 title 可以包含 MiniMessage 字体、颜色等标签。资源包专属的偏移语法由应用先展开成字体标签。
  • 自定义工厂必须返回具有指定 holder 和尺寸的物品栏。返回 null、替换 holder 或尺寸不符都会失败并清理会话。
  • 已有的安装重载继续使用 Bukkit 字符串标题。
  • 工厂在主线程调用。

Kotlin 层级菜单

import me.kzheart.klib.ui.*
import me.kzheart.klib.item.item
import org.bukkit.Material

val model = menu("工具", 1) {
    layout("x        ")
    character('x') {
        item(item(Material.STONE) { amount = 1 })
        onClick { /* 使用现有 MenuClick / session API */ }
    }
}

slot(index) { ... } 也接受同样的条目块。编译仍走 MenuTemplate 与 MenuCompiler,保留重复槽位、行数与布局校验;传入与读出的物品均为防御性副本。

Java 与 Kotlin 共用的会话处理

  • 注解菜单、Java Builder 与 Kotlin DSL 生成相同模型,统一编译、渲染与点击分发。
  • ReactiveMenuSession 把订阅、状态代际、过期异步结果与合并刷新收在会话中;业务只声明状态与动作。
  • 聊天面板、投放区和输入流程分别管理自己的身份校验、取消与关闭归还,不由业务重复安装全局监听器。

这些流程由 Kotlin 实现。Java 菜单注解、动作/错误/输入 SAM 和外部工厂保留原契约,Java 调用方无需使用 DSL 或挂起函数。

线程与生命周期

  • MenuRenderer 和 BukkitChatPrompts 都必须安装到 Scope。作用域关闭时,监听器、打开的菜单、刷新任务和未完成提示统一清理。
  • 菜单创建、打开、渲染和玩家背包操作必须在 Bukkit 主线程进行。
  • MenuRenderer.open(...)、openReactive(...)、MenuRenderer.render(...)、MenuHolder.refresh() 及状态读写/绑定/导航在入口断言主线程,从异步线程调用直接抛 IllegalStateException。异步结果用 scope.submitSync(...) 或 AsyncTasks.thenSync(...) 切回主线程;只有状态菜单的 invalidate() 可直接跨线程调用。
  • MenuSession 关闭后自动从安装它的父作用域摘除,长生命周期作用域中反复开关菜单不会累积已结束的会话。
  • 聊天解析器由异步聊天事件调用,只做纯解析;不要在里面访问世界、背包或其他主线程状态。

常见坑

  • 点击动作抛出普通异常时,渲染器会记录错误并调用 MenuErrorHandler。可以在安装时提供统一的玩家提示,但错误处理器本身也应保持轻量。
  • 普通聊天提示回调仍需检查玩家/导航流程是否有效;状态菜单的 readLatest 已检查原 Player 和本次 Inventory 身份。不要仅凭相同 UUID 或 isOnline() 恢复一个旧流程。

标准命令面板安装

CommandPanels(plugin, scope) 在启动时安装命令模块、聊天输入与字段面板,统一读取业务插件自带的 command-help.yml。 帮助样式随文件修改重新读取;解析失败保留原样式并记录告警。业务命令仍负责权限与校验。

val panels = CommandPanels(this, context().scope())
panels.open(player, "管理", "example.admin", listOf(
    CommandPanels.Control("管理", "查询玩家", "example inspect", "example.admin", false,
        CommandPanels.player(false))))

prompts()、panels() 供业务复用输入入口;player、world、options 提供常用字段。 实例必须在声明业务命令之前创建,同一作用域不再重复安装命令模块或聊天面板。

相关页面

  • 组件与注解:@Menu 等注解的完整规则
  • Item:菜单物品构建
  • Command:聊天面板按钮命令
  • Lang:富文本与 BukkitAdventure
  • Core:作用域与线程切换

Clone this wiki locally