diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 917c8d4..0fbadee 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -294,6 +294,8 @@ Keep a [TabPresenceListener](../src/main/java/pit12/runtime/player/TabPresenceLi Subscribe to name changes with `addListener(Runnable)` in `start()`, then read the current names. In `stop()`, pass the same listener to `removeListener(Runnable)`. +Use `bindDisplayNameObserver(UUID, Consumer)` to watch one current Tab entry. Each entry has one observer, called on the client thread after its display name changes. Name queries can also refresh an entry and call its observer. The name keeps formatting codes and may be `null`. Pass `null` as the observer to detach it. The subscription ends when the entry leaves Tab or the connection changes. + [SidebarCache](../src/main/java/pit12/runtime/scoreboard/SidebarCache.java) provides `lines()` for the server sidebar. It returns an immutable snapshot of up to 15 lines in display order. Lines include footer text and keep formatting codes. The objective title is excluded. Each [SidebarLine](../src/main/java/pit12/runtime/scoreboard/SidebarLine.java) provides the entry ID, text, and sorting score through `entry()`, `text()`, and `score()`. `formattedText(minecraft.fontRendererObj)` removes formatting codes and characters with a width of zero in the Minecraft font. Call it on the client thread. It computes the text on the first call and caches it for that line. Score changes retain the cache. Text changes create a new line with an empty cache. diff --git a/docs/zh-CN/IMPLEMENTATION.md b/docs/zh-CN/IMPLEMENTATION.md index 81a8854..3b56852 100644 --- a/docs/zh-CN/IMPLEMENTATION.md +++ b/docs/zh-CN/IMPLEMENTATION.md @@ -1,4 +1,4 @@ - + # 12pit 实现 @@ -296,6 +296,8 @@ Bootstrap 为功能提供查询契约。其实时查询和订阅需要客户端 在 `start()` 中通过 `addListener(Runnable)` 订阅名称变化,再读取当前名称。在 `stop()` 中通过 `removeListener(Runnable)` 注销同一个监听器。 +通过 `bindDisplayNameObserver(UUID, Consumer)` 监听一个当前 Tab 条目。每个条目只能绑定一个监听器,显示名变化后在客户端线程上调用。名称查询也可能刷新条目并调用监听器。名称保留格式化代码,也可能为 `null`。将监听器设为 `null` 可取消绑定。条目离开 Tab 或连接变化时,订阅结束。 + [SidebarCache](../../src/main/java/pit12/runtime/scoreboard/SidebarCache.java) 通过 `lines()` 查询服务器侧栏。它返回按显示顺序排列的不可变快照,最多包含 15 行。内容保留底部文字和格式化代码,不包含目标标题。每个 [SidebarLine](../../src/main/java/pit12/runtime/scoreboard/SidebarLine.java) 通过 `entry()`、`text()` 和 `score()` 提供条目标识、文字和排序分数。 `formattedText(minecraft.fontRendererObj)` 会去掉格式化代码,以及 Minecraft 字体中宽度为 0 的字符。请在客户端线程调用。它在首次调用时计算结果,并为这一行缓存结果。分数变化时保留缓存。文本变化时创建新行,并清空缓存。 diff --git a/src/main/java/pit12/feature/denick/DenickFeature.java b/src/main/java/pit12/feature/denick/DenickFeature.java index 36c8863..82c7623 100644 --- a/src/main/java/pit12/feature/denick/DenickFeature.java +++ b/src/main/java/pit12/feature/denick/DenickFeature.java @@ -195,7 +195,12 @@ public void stop() { session.removeListener(sessionChanged); equipment.removeListener(this); presence.removeListener(this); - targets.values().forEach(Target::cancel); + for (Target target : targets.values()) { + target.cancel(); + if (target.nick) { + names.bindDisplayNameObserver(target.id, null); + } + } targets.clear(); checkedPlayers.clear(); if (queryWorker != null) { @@ -274,11 +279,12 @@ private boolean isNick(UUID id) { return false; } String display = names.displayName(id); - if (display == null) { - return false; - } + return display != null && !isNpc(display); + } + + private boolean isNpc(String display) { String plain = EnumChatFormatting.getTextWithoutFormattingCodes(display).trim(); - return !plain.contains("[NPC]") && !NPC_TAB_NAME.matcher(display).lookingAt(); + return plain.contains("[NPC]") || NPC_TAB_NAME.matcher(display).lookingAt(); } @Override @@ -289,6 +295,15 @@ public void onPlayerSeen(UUID playerId, String name, boolean joined) { if (isNick(playerId)) { Target target = targets.computeIfAbsent(playerId, id -> new Target(id, name)); target.nick = true; + names.bindDisplayNameObserver(playerId, display -> { + if (display != null && isNpc(display)) { + // A repeated ADD packet must not mark this entry as Nick again. + Target removed = targets.remove(playerId); + removed.cancel(); + names.bindDisplayNameObserver(playerId, null); + changed(); + } + }); if (config.enabled() && config.automatic.get()) { begin(target, null, false); } @@ -302,6 +317,9 @@ public void onPlayerLeft(UUID playerId) { Target target = targets.remove(playerId); if (target != null) { target.cancel(); + if (target.nick) { + names.bindDisplayNameObserver(playerId, null); + } changed(); } } @@ -474,11 +492,12 @@ private void query(Target target) { cachedDisplayName = api.displayName(cached, cancelled); String name = cachedDisplayName; client.execute(() -> { + String display = displayName(target); if (valid(target, requestGeneration, request)) { target.realDisplayName = name; target.owner = cached; - reply(search.sender, Tone.SUCCESS, language.format( - "{0} §c-> {1} §c(cached)", displayName(target), name)); + reply(search.sender, Tone.SUCCESS, + language.format("{0} §c-> {1} §c(cached)", display, name)); changed(); } }); @@ -490,8 +509,9 @@ private void query(Target target) { DenickQuery result = DenickQuery.run(api, items, search.force ? null : cached, cachedDisplayName, cancelled); client.execute(() -> { + String display = displayName(target); if (valid(target, requestGeneration, request)) { - complete(target, search, items, cached, result); + complete(target, search, items, cached, result, display); } }); } catch (InterruptedException interrupted) { @@ -506,7 +526,7 @@ private boolean valid(Target target, long requestGeneration, long request) { } private void complete(Target target, Search search, List items, String cached, - DenickQuery result) { + DenickQuery result, String display) { boolean first = search.initial; search.initial = false; search.busy = false; @@ -523,11 +543,9 @@ private void complete(Target target, Search search, List items, Stri if (search.force || !Objects.equals(previousName, result.displayName) || !Objects.equals(previousOwner, result.owner)) { boolean corrected = cached != null && !Objects.equals(cached, result.owner); - reply(search.sender, Tone.SUCCESS, - language.format( - corrected ? source("{0} §c-> {1} §c(updated)") - : source("{0} §c-> {1}"), - displayName(target), result.displayName)); + reply(search.sender, Tone.SUCCESS, language.format( + corrected ? source("{0} §c-> {1} §c(updated)") : source("{0} §c-> {1}"), + display, result.displayName)); changed(); } if (!Objects.equals(mappings.put(target.name.toLowerCase(Locale.ROOT), result.owner), @@ -588,6 +606,7 @@ private void reply(ICommandSender sender, Tone tone, String message) { } private String displayName(Target target) { + // Refreshing the name can remove an NPC target before a query reply. String displayName = names.displayName(target.id); return displayName == null ? target.name : displayName; } diff --git a/src/main/java/pit12/runtime/player/PlayerNameCache.java b/src/main/java/pit12/runtime/player/PlayerNameCache.java index 0e6468d..2ac07ba 100644 --- a/src/main/java/pit12/runtime/player/PlayerNameCache.java +++ b/src/main/java/pit12/runtime/player/PlayerNameCache.java @@ -26,6 +26,7 @@ import java.util.Objects; import java.util.Set; import java.util.UUID; +import java.util.function.Consumer; import net.minecraft.client.Minecraft; import net.minecraft.client.multiplayer.WorldClient; import net.minecraft.client.network.NetHandlerPlayClient; @@ -126,6 +127,18 @@ public void removeListener(Runnable listener) { listeners.remove(listener); } + /** + * One observer per current Tab entry. Null clears it; absent entries are ignored. Name queries can refresh an entry and + * call its observer. + */ + public void bindDisplayNameObserver(UUID playerId, Consumer observer) { + session.checkThread(); + NameEntry entry = entries.get(playerId); + if (entry != null) { + entry.displayNameObserver = observer; + } + } + @Override public void onPlayerSeen(UUID playerId, String name, boolean joined) { NetHandlerPlayClient handler = session.connection(); @@ -266,6 +279,9 @@ private void updateName(UUID playerId) { if (!Objects.equals(entry.displayName, name)) { entry.displayName = name; entry.shortName = null; + if (entry.displayNameObserver != null) { + entry.displayNameObserver.accept(name); + } Listeners.notify(listeners, Runnable::run); } } @@ -283,6 +299,7 @@ private static final class NameEntry { private NetworkPlayerInfo info; private String displayName; private String shortName; + private Consumer displayNameObserver; private NameEntry(NetworkPlayerInfo info) { this.info = info;