# chat_test —— P3:聊天窗路由 + 前缀解析 + 气泡 headless 自检。 # godot --headless --path project --script chat_test.gd extends SceneTree const ChatUI = preload("res://ui/chat_ui.gd") const NetWorld = preload("res://net_world.gd") class FakeClient extends Node: signal chat(type: int, vid: int, text: String) # 注:GDScript 不允许 signal 和 func 同名;真 M2Client 是 GDExtension,两者可共存。 # 这里 whisper 用作方法(chat_ui 调 client.whisper(...));入站 whisper 直接调 chat._on_whisper。 signal item_picked_up(vnum: int, count: int, from: String) signal fishing_event(subheader: int, info: int, dir: int) # net_world 也连这些: signal entity_spawned(entity: Dictionary) signal entity_despawned(vid: int) signal entity_moved(vid: int) signal entity_main_set(vid: int) signal entity_dead(vid: int) signal vitals_changed(vid: int) signal damage(vid: int, amount: int, flag: int) var ents := {1000: {"vid": 1000, "name": "Me"}, 2000: {"vid": 2000, "name": "Bob"}} var said := [] var whispered := [] func get_entity(vid: int) -> Dictionary: return ents.get(vid, {}) func get_entities() -> Array: return ents.values() func say(t, s) -> bool: said.append([t, s]); return true func whisper(to, s) -> bool: whispered.append([to, s]); return true func get_guild_name(gid: int) -> String: return "Camelot" if gid != 0 else "" class FakeProto extends Node: func item(vnum: int) -> Dictionary: if vnum == 30001: return {"vnum": vnum, "type": 12, "locale_name": "Trout"} return {} var _fail := 0 func _ck(c: bool, m: String) -> void: if not c: _fail += 1 printerr("FAIL: " + m) func _init() -> void: _run() if _fail == 0: print("PASS: chat_test (routing + prefixes + bubble)") quit(0) else: printerr("%d check(s) failed" % _fail) quit(1) func _last(rt: RichTextLabel) -> String: return rt.get_parsed_text().strip_edges().split("\n")[-1] func _run() -> void: var canvas := CanvasLayer.new() get_root().add_child(canvas) var fc := FakeClient.new() get_root().add_child(fc) var chat: Node = ChatUI.new() get_root().add_child(chat) chat.setup(fc, canvas, AssetRoot.path()) var proto := FakeProto.new() get_root().add_child(proto) chat.set_proto(proto) _ck(chat._log.size() == 4, "4 tabs built") var tAll: RichTextLabel = chat._log[0] var tWhisper: RichTextLabel = chat._log[1] var tSys: RichTextLabel = chat._log[2] var tBattle: RichTextLabel = chat._log[3] # 普通聊天 -> 全部标签,带名字 fc.chat.emit(0, 2000, "hi all") _ck(_last(tAll).contains("Bob: hi all"), "talking -> All tab with name") _ck(chat._buffers[2].size() == 0, "talking not in System tab") # info -> 系统 + 全部 fc.chat.emit(1, 0, "you leveled up") _ck(_last(tSys).contains("you leveled up"), "info -> System tab") _ck(_last(tAll).contains("you leveled up"), "info -> All tab too") # whisper -> 私聊 + 全部(入站直接调 handler,见 FakeClient 注释) chat._on_whisper(0, "Alice", "secret") _ck(_last(tWhisper).contains("Alice: secret"), "whisper -> Whisper tab") _ck(_last(tAll).contains("Alice: secret"), "whisper -> All tab") chat._on_whisper(1, "", "server notice") _ck(_last(tWhisper).contains("[系统]: server notice"), "system whisper -> [系统]") # 拾取 -> 系统 + 战斗 fc.item_picked_up.emit(27, 3, "") _ck(_last(tSys).contains("拾取 #27 x3"), "pickup -> System") _ck(_last(tBattle).contains("拾取 #27 x3"), "pickup -> Battle") # 钓鱼服务端事件:SUCCESS 只切动画;FISH 按 IsFishing() + item type # 走 ClientVS22 的 notify/success locale 分支。 fc.fishing_event.emit(0, 1000, 0) fc.fishing_event.emit(5, 30001, 0) _ck(_last(tBattle).contains("Trout") and _last(tBattle).contains("hooked"), "fishing notify fish -> Battle locale") fc.fishing_event.emit(3, 1000, 0) fc.fishing_event.emit(5, 30001, 0) _ck(_last(tSys).contains("You captured Trout!"), "fishing success fish -> System locale") chat.on_fishing_feedback("FISHING_WRONG_PLACE") _ck(_last(tSys).contains("You cannot go fishing here."), "wrong place -> locale") # --- 前缀解析 --- chat._on_submit("hello world") _ck(fc.said.size() == 1 and fc.said[0] == [0, "hello world"], "plain -> say(TALKING)") chat._on_submit("/g guild msg") _ck(fc.said.size() == 2 and fc.said[1] == [4, "guild msg"], "/g -> say(GUILD)") chat._on_submit("/s shout!") _ck(fc.said[2] == [6, "shout!"], "/s -> say(SHOUT)") chat._on_submit("/w Bob psst hey") _ck(fc.whispered.size() == 1 and fc.whispered[0] == ["Bob", "psst hey"], "/w Bob ... -> whisper('Bob','psst hey')") _ck(_last(tWhisper).contains("→ Bob: psst hey"), "/w -> local echo in Whisper tab") # game.py OpenWhisperDialog:目标框独立于公共聊天输入行,回车发送后关闭。 chat.start_whisper("Carol") _ck(chat.is_whisper_open() and chat.whisper_target() == "Carol", "start_whisper -> 独立私聊窗口打开并锁定目标") chat._on_whisper_submit("private window") _ck(fc.whispered.size() == 2 and fc.whispered[1] == ["Carol", "private window"], "独立私聊窗口发送 -> whisper('Carol', msg)") _ck(not chat.is_whisper_open(), "私聊发送后关闭独立窗口") # --- 气泡 --- var nw: Node = NetWorld.new() get_root().add_child(nw) var mount := Node3D.new() get_root().add_child(mount) nw.setup(fc, mount) fc.entity_spawned.emit({"vid": 2000, "name": "Bob", "pos": Vector3.ZERO, "func": 0, "angle_deg": 0.0}) await process_frame var got_main := [""] nw.main_bubble.connect(func(t): got_main[0] = t) nw._bubble(2000, "hi there") var bob: Node3D = nw.node_for(2000) _ck(bob != null and bob.get_node_or_null("bubble") != null, "entity 2000 got a bubble node") # §8.8 seam ⑨(UpdateShowingTextTail :110-114):有「聊天」气泡时名字尾标顶到气泡上方 17px。 var bob_name := bob.get_node_or_null("Label3D") as Label3D var bob_bubble := bob.get_node_or_null("bubble") as Label3D _ck(bob_name != null and bob_bubble != null, "entity 2000 has Label3D + bubble") _ck(absf(bob_name.position.y - (bob_bubble.position.y + 17.0 * bob_name.pixel_size)) < 0.0001, "聊天气泡在 -> 名字 y = 气泡 y + 17*pixel_size") _ck(bob_name.position.y > bob_bubble.position.y, "名字尾标顶到气泡上方") # §8.8 seam ⑦ 收口(增量 119 / ArrangeTextTail :228/:258/:283):公会名 / 称号 / 等级 # 子标签锚在名字尾标上,名字被气泡顶起时三者整体平移同一段。 var g_tag := bob.get_node_or_null("GuildTag") as Label3D var t_tag := bob.get_node_or_null("TitleTag") as Label3D var l_tag := bob.get_node_or_null("LevelTag") as Label3D _ck(g_tag != null and t_tag != null and l_tag != null, "entity 2000 has 公会/称号/等级子标签") var lift := bob_name.position.y - 2.1 _ck(lift > 0.0, "名字尾标确有上移量") _ck(absf(g_tag.position.y - (2.34 + lift)) < 0.0001, "GuildTag 随名字上移同一段") _ck(absf(t_tag.position.y - (1.86 + lift)) < 0.0001, "TitleTag 随名字上移同一段") _ck(absf(l_tag.position.y - (1.74 + lift)) < 0.0001, "LevelTag 随名字上移同一段") # 信息尾标(bNameFlag FALSE)不顶名字 —— 复用同一张 _chat_tails,覆盖成 info 后名字回默认。 nw.register_info_tail(2000, "skill fizzled") _ck(absf(bob_name.position.y - 2.1) < 0.0001, "信息尾标覆盖 -> 名字回默认局部 y 2.1") _ck(absf(g_tag.position.y - 2.34) < 0.0001, "信息尾标覆盖 -> GuildTag 回默认 2.34") _ck(absf(t_tag.position.y - 1.86) < 0.0001, "信息尾标覆盖 -> TitleTag 回默认 1.86") _ck(absf(l_tag.position.y - 1.74) < 0.0001, "信息尾标覆盖 -> LevelTag 回默认 1.74") # --- §8.8 seam ⑦ 水平排版(增量 121 / ArrangeTextTail :211-320 非 EUROPE 分支)------ # charlie 有公会 + 善恶度(称号) + 等级 -> 三子标签沿名字锚点水平排开: # 等级在最左、称号居中偏左、名字本身右移 fxAdd(=8);公会名(无徽记)留 x = 0。 fc.ents[3000] = {"vid": 3000, "name": "Charlie", "guild": 7, "alignment": 15000, "level": 42} fc.entity_spawned.emit({"vid": 3000, "name": "Charlie", "pos": Vector3(3, 0, 0), "func": 0, "angle_deg": 0.0}) await process_frame var charlie: Node3D = nw.node_for(3000) _ck(charlie != null, "entity 3000 spawned") nw._apply_text_tail(3000) var c_name := charlie.get_node_or_null("Label3D") as Label3D var c_g := charlie.get_node_or_null("GuildTag") as Label3D var c_t := charlie.get_node_or_null("TitleTag") as Label3D var c_l := charlie.get_node_or_null("LevelTag") as Label3D _ck(c_g.text == "Camelot", "GuildTag 文本经 get_guild_name 解出") _ck(c_t.text != "", "TitleTag 文本非空(alignment 15000 -> grade 0 -> 有称号)") _ck(c_l.text != "", "LevelTag 文本非空(level 42)") # 名字本身右移 fxAdd —— 有称号 -> 8px 折世界。 _ck(absf(c_name.position.x - 8.0 * c_name.pixel_size) < 0.0001, "名字 x = name_x_shift(有称号)=8 * pixel_size") # 称号 / 等级在名字左侧(x < 0),等级比称号更靠左。 _ck(c_t.position.x < 0.0, "称号在名字锚点左侧") _ck(c_l.position.x < c_t.position.x, "等级比称号更靠左(-nameW/2 - 8 - titleW)") # 公会名无徽记 -> 留在名字中线正上方。 _ck(absf(c_g.position.x) < 0.0001, "GuildTag x = 0(poc 无公会徽记)") # 精确值:nameW = len("Charlie")*7 = 49,称号 x 偏移 = -(49/2) - 8 = -32.5。 _ck(absf(c_t.position.x - (-(7.0 * 7.0 / 2.0) - 8.0) * c_t.pixel_size) < 0.0001, "称号 x = (-nameW/2 - 8) * pixel_size 精确") # 撤销称号(alignment -> 0,grade 4 = TITLE_NONE)后称号 x 归零、名字改右移 4(只剩等级)。 fc.ents[3000]["alignment"] = 0 nw._apply_text_tail(3000) _ck(c_t.text == "" and absf(c_t.position.x) < 0.0001, "称号消失 -> TitleTag x 归零") _ck(absf(c_name.position.x - 4.0 * c_name.pixel_size) < 0.0001, "只剩等级 -> 名字 x = name_x_shift(仅等级)=4 * pixel_size") # 精确值(无称号):等级 x 偏移 = -(nameW/2) - 4 = -(49/2) - 4 = -28.5。 _ck(absf(c_l.position.x - (-(7.0 * 7.0 / 2.0) - 4.0) * c_l.pixel_size) < 0.0001, "只剩等级:等级 x = (-nameW/2 - 4) * pixel_size 精确") nw.set_local_vid(1000) nw._bubble(1000, "my line") _ck(got_main[0] == "my line", "local vid bubble -> main_bubble signal")