主界面 GUI
gui/ 包,基于 ModularUI 2(com.cleanroommc.modularui.*)构建。gui/TaskNHGui.java 只有 151 行,是入口与状态容器;真正的界面在 gui/widget/ 下的 11 个类里。
没有容器,没有 GUI ID
TaskNH 不注册任何 Container,也不实现 IGuiHandler/IGuiHandlerServer。 整个 GUI 是纯客户端构造的 ModularUI 界面,界面的数据源是客户端缓存 TaskNHClientCache,与服务器之间靠 网络封包 的应用层同步维持。
后果是:没有 GUI ID 映射表,也没有槽位同步。所有「编辑」都是先把改动写进本地缓存、乐观上屏,再发封包给服务端等服务端回一次全量同步。OpenGuiPacket 存在的唯一目的就是让服务端(或热键)通知客户端「现在开界面」。
尺寸
| 常量 | 值 |
|---|---|
WIDTH |
380 |
LEFT_WIDTH |
380 |
PADDING |
6 |
getHeight() |
Math.min(580, (int)(ScaledHeight * 0.9))——上限 580 像素,按 90% 屏高自适应 |
宽度是固定常量,高度才自适应。
打开方式
| 触发 | 路径 |
|---|---|
| 热键 | ClientProxy.onClientTick 检测 KEY_OPEN_GUI.isPressed() → TaskNHGui.open(),见 打开界面热键 |
/tasknh gui |
服务端单发 OpenGuiPacket()(无 taskId)→ TaskNHGui.open() |
/tasknh open <taskId> |
服务端单发 OpenGuiPacket(taskId) → 客户端 selectTask(taskId) 后 open(data) |
| 点击地图标记 | TaskLayerManager.onClick 构造 TaskNHGuiData 并选中该任务,见 Navigator 地图图层 |
open() 不立即建界面,只把 TaskNHGuiData 存进 volatile pendingOpen。onClientTick 里随后调 TaskNHGui.tick() 才真正消费。tick() 中的源码注释说明了原因之一:同步到达时不能覆盖用户自己触发的打开。
主题
两个 ModularUI 主题 ID:tasknh_dark / tasknh_light,默认深色。toggleTheme() 来回切换,currentTheme 是静态字段,只在本会话内保持,不写盘。ClientProxy.onThemeReload 监听 ModularUI 的 ReloadThemeEvent.Post 并调 TaskNHGui.notifyThemeReloaded()——Forge 总线注册 ClientProxy 自己的注释写明「theme reloads are posted on the Forge bus」。资源侧见 主题与资源。
自同步回声抑制
每次编辑后服务端都会回一次 SyncAllTasksPacket,而重建界面会杀掉控件焦点并让列表跳动。因此:
SELF_SYNC_WINDOW_MS = 1000- 发包前调
expectSelfSync()记下时间戳 notifySyncReceived()里if (isWithinSelfEditWindow()) return;直接跳过重建
源码注释说明用时间窗而非计次回声:若丢包或漏掉回应,计数法会让界面永久失去对同步的响应;用时间窗则队友在窗口内的编辑会随下一次同步一起显示,只是不会立刻出现。
界面状态:TaskNHGuiData
gui/TaskNHGuiData.java,89 行,纯数据容器:
| 字段 | 说明 |
|---|---|
activeTab |
当前标签页,类型 TaskStatus,初值 OPEN(三个标签页就是三个状态) |
selectedTaskId |
选中的任务,未选为 null |
createMode / draft |
新建模式与其草稿任务 |
searchQuery / searchExpanded |
搜索词与搜索框展开态 |
trackCountExpanded |
追踪数量输入框展开态 |
checklistCountExpanded |
追踪数量展开的清单项 UUID |
listScroll / detailScroll / parentScroll |
三个 ScrollMemoryList.Memory 滚动记忆 |
parentScrollOwner |
父任务滚动记忆的归属 UUID |
pageController |
PagedWidget.Controller 分页控制器 |
clear() 有一处专门的重置:源码注释「Back to the list: a subtask opened from there next must not reuse the parent’s old scroll.」——从子任务返回列表时会重置父任务滚动记忆。
11 个 widget
| 类 | 行数 | 职责 |
|---|---|---|
TaskListWidget |
288 | 左侧列表容器,Flow 纵向布局 |
TaskDetailWidget |
884 | 右侧详情面板(标题/描述/状态/负责人/坐标/清单/图标/追踪物品/数量/地图开关/父任务) |
TaskRowWidget |
354 | 列表中的单行 |
TaskBlockItem |
126 | 可拖拽的「一个根任务 + 其子任务」块 |
SortableTaskList |
49 | 支持拖拽排序的列表,继承 SortableListWidget<Task> |
ScrollMemoryList |
75 | 能跨重建保住滚动偏移的 ListWidget |
IconSlotWidget |
143 | 接收 NEI 拖放的幽灵槽位 |
AssigneePickerWidget |
96 | 负责人选择器,Flow |
PlainTextField |
62 | 去掉右键清空的 TextFieldWidget |
PlayerHeadWidget |
58 | 纯装饰的玩家头颅 |
PlayerSkinCache |
29 | 按名字缓存皮肤 ResourceLocation |
三个值得注意的设计
TaskBlockItem 与搜索的交互(类注释):块本身永远不被搜索禁用,因为拖拽功能用的是同一个 enabled 标志,搜索过滤器会在每个 tick 把它切回来。过滤器只作用在块内部的行上,于是「无匹配的块高度归零」。
PlainTextField.DEFAULT_MAX_LENGTH = 256:注释写明「匹配任务封包读回的最短字符串上限,所以没有任何输入框能构造出无法发送的封包」。rightClickClears() 默认关闭——否则一次误点会清空标题或描述。
PlayerHeadWidget.canHover() 覆写为 false:纯装饰,不能吞掉所在行的悬停高亮。
槽位交互约定
IconSlotWidget 的类注释给出统一约定(图标槽、任务追踪物品槽、清单追踪槽三者一致):
- 左键 —— 打开该物品的配方(经 NEI 集成)
- 中键 —— 设置所需数量
- 右键 —— 清空槽位
- NEI 拖放 —— 从 NEI 拖物品进来设置
另有 tasknh.detail.set_icon=Set to held,用玩家手持物设图标。