202 lines
10 KiB
GDScript
202 lines
10 KiB
GDScript
# 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")
|