队伍与同步流程

event/ 包 4 个类 + cache/TaskNHClientCache.java。TaskNH 是队伍协作型模组:任务属于队伍,编辑由任一队员发起,全队在线成员立刻看到。

登录:PlayerLoginHandler

监听 PlayerEvent.PlayerLoggedInEvent,注册在 FML 总线(源码注释:PlayerLoggedInEvent fires on FML's bus, not MinecraftForge.EVENT_BUS)。顺序严格:

  1. 先发 WorldIdPacket —— 注释明确「ahead of the team check and the sync: the client needs the world before it reads or cleans pins.」即使该玩家没有队伍也会发。
  2. TeamManager.getTeamByPlayer(...),为 null 直接 return —— 无队伍玩家收不到任何任务同步
  3. 发 SyncAllTasksPacket(本队任务)
  4. 发 SyncTeamMembersPacket(buildTeamMembersPacket(team))
  5. sendLoginNotifications(...)

成员名解析(buildTeamMembersPacket):先查在线玩家名表;查不到则用 UsernameCache.getLastKnownUsername(playerId);再查不到就用 UUID 前 8 位。所以离线队友在负责人列表里可能显示成一串十六进制。SyncTeamMembersPacket 里名字长度上限 64。

「新任务」提醒:拿存档里的 playerLastSeen(playerId) 与每个任务中 AssignedPlayer.assignedAt() 比较,只列出 assignedAt > lastSeen 的任务。多于一条时先发一条汇总 tasknh.chat.login.new_tasks,然后逐条发 tasknh.chat.login.task——每条都带一个可点击的 [Open] 链接,ClickEvent.Action.RUN_COMMAND,命令是 /tasknh open <完整 UUID>。同一个 buildTaskLink 方法也被提醒功能复用。

登出:PlayerLogoutHandler

监听 PlayerEvent.PlayerLoggedOutEvent(同样在 FML 总线):

  1. TaskNHWorldData.get().setPlayerLastSeen(playerId, System.currentTimeMillis()) —— 这就是「上次见过」时间戳的写入点
  2. ItemTrackHandler.forget(player) —— 移除物品栏监听器与待检查项

登出时间戳是登录提醒的锚点,因此它必须写进存档(setPlayerLastSeen 会 markDirty())。

队伍合并:TeamMergeListener

监听 GTNHLib 的 TeamEvents.TeamMergeEvent,注册在 Forge 总线。两件事:

  1. data.mergeTasks(consumed.getTeamId(), surviving.getTeamId()) —— 被吞并队伍的任务整体搬进幸存队伍
  2. 给幸存队伍发两条包:SyncAllTasksPacket(新任务列表)+ buildTeamMembersPacket(新成员表)

源码注释说明了第二条的必要性:「Accepting an invite as a solo player merges teams, so the assignee lists sent at login are stale now.」——单飞玩家接受入队邀请也会触发队伍合并,所以登录时发的成员表此刻已经过期。

客户端缓存:TaskNHClientCache

cache/TaskNHClientCache.java,全部静态,只在客户端线程访问(ClientProxy 的注释说明这一点,因此断线事件只置一个 volatile boolean 标志,由下一 tick 处理)。

字段 类型
tasks Map<UUID, Task>(LinkedHashMap)
pinConfig 单例 PinnedTasksConfig
teamMembers List<PlayerEntry>
pendingEdit Task,本地已应用待服务端确认的编辑
pendingDelete UUID,本地已删待确认

乐观更新与 pendingEdit

putLocal(Task) 把改动立刻写进缓存(并记为 pendingEdit),这样 GUI 不必等服务端回包。类注释解释了为什么需要 pendingEdit:「由更早的编辑引发的同步带着早于这次编辑的服务端状态,否则会把它撤销。」

update(Collection<Task> incoming) 的逻辑:

tasks.clear(); for (t : incoming) tasks.put(t.id, t);
if (TaskNHGui.isWithinSelfEditWindow()) {
    if (pendingEdit != null) tasks.put(pendingEdit.id, pendingEdit);
    if (pendingDelete != null) removeLocal(pendingDelete);
} else {
    pendingEdit = null; pendingDelete = null;   // Confirmed by now, server state wins again.
}
pinConfig.removeStale(tasks.keySet());
if (isModLoaded("navigator")) TaskLayerManager.INSTANCE.refreshFromCache(tasks.values());

每次同步都用服务端状态整体替换缓存(不是合并),只有落在 1 秒自同步窗口内的那一次才把本地编辑重新贴回去。之后服务端状态无条件获胜。

update 之后固定做两件事:清理失效图钉(批量,一次 save())与刷新 Navigator 地图标记(后者用 Loader.isModLoaded("navigator") 内联判断,因此这个缓存类本身不直接依赖 Navigator)。

断线清空

ClientProxy.onDisconnect(FMLNetworkEvent.ClientDisconnectionFromServerEvent)只置 disconnected = true;下一个 onClientTick 消费该标志并调 TaskNHClientCache.clear()。注释说明原因:「The cache is only safe on the client thread, so the event just leaves a flag for the tick above.」

图钉排序

getPinnedTasks() 决定 HUD 顺序与数量上限的实际口径:

  • 过滤掉 DONE 任务 —— 类注释:「Done tasks stay pinned but are left out, so a reopened task comes back to the HUD.」即已完成的任务仍留在图钉配置里,只是不显示
  • 排序键为 hudRank(status) 再 order:IN_PROGRESS → 0,OPEN → 1,DONE → 2
  • 同一状态内按 order(各标签页独立编号,见 任务存档)

canPin() 是 getPinnedTasks().size() < pinConfig.getMaxPinnedTasks()——因为已过滤掉 DONE,所以已图钉的已完成任务不占名额。这正是 PinnedTasksConfig.pin() 把上限检查推给调用方的原因。

三个事件处理器的总线归属

类 事件 总线
PlayerLoginHandler PlayerEvent.PlayerLoggedInEvent FML
PlayerLogoutHandler PlayerEvent.PlayerLoggedOutEvent FML
ItemTrackHandler EntityJoinWorldEvent Forge
ItemTrackHandler TickEvent.ServerTickEvent FML
TeamMergeListener TeamEvents.TeamMergeEvent Forge
HudRenderer RenderGameOverlayEvent.Post Forge
ClientProxy TickEvent.ClientTickEvent FML
ClientProxy FMLNetworkEvent.ClientDisconnectionFromServerEvent FML
ClientProxy ModularUI ReloadThemeEvent.Post Forge