Repository navigation
构建物品栏菜单、分页、物品投放区、聊天控制面板和聊天输入流程。点击、拖拽、数字键、双击、关闭归还和异步聊天事件由统一监听器处理,业务代码只描述模型和动作。
| 模块名 | 制品 | 自动带入 |
|---|---|---|
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: backval 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(...)可改为业务提示。 - 所有公开面板操作及释放都要求主线程。作用域关闭后,注册的命令立即逻辑停用。
展开:用 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 字符串标题。
- 工厂在主线程调用。
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 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 提供常用字段。
实例必须在声明业务命令之前创建,同一作用域不再重复安装命令模块或聊天面板。