# ChatUI (P3) —— 聊天窗:多标签 + 输入行 + 频道前缀 + 系统/战斗日志。 # # var chat := preload("res://ui/chat_ui.gd").new() # add_child(chat) # chat.setup(m2client, canvas_parent) # canvas_parent: 一个 CanvasLayer/Control # chat.focus_input() # Enter 键调 # # 输入行前缀: /w <名> <话> 私聊 · /g 公会 · /p 队伍 · /s 喊话 · 其它 普通。 # 消费 M2Client:chat / whisper / item_picked_up。按类型分到 全部 / 私聊 / 系统 / 战斗 标签。 extends Node const Locale = preload("res://locale.gd") # EChatType (wire.h) const T_TALKING := 0 const T_INFO := 1 const T_NOTICE := 2 const T_PARTY := 3 const T_GUILD := 4 const T_SHOUT := 6 const T_WHISPER := 7 const FISHING_SUCCESS := 3 const FISHING_FAIL := 4 const FISHING_FISH := 5 const TABS := ["全部", "私聊", "系统", "战斗"] const COLOR := { 0: Color(0.92, 0.92, 0.92), # talking 1: Color(0.55, 0.85, 1.0), # info 2: Color(1.0, 0.85, 0.3), # notice 3: Color(0.6, 0.85, 1.0), # party 4: Color(0.5, 1.0, 0.6), # guild 6: Color(1.0, 0.7, 0.4), # shout 7: Color(1.0, 0.6, 0.95), # whisper } signal line_added(tab: int, bbcode: String) var client: Node var _root: Control var _log := {} # tab_index -> RichTextLabel var _input: LineEdit var _tab := 0 var _tab_btns := [] var _max_lines := 200 var _buffers := {} # tab -> Array[String] var _locale: RefCounted var _proto: Node var _fishing_active := false var _whisper_dialog: Panel var _whisper_target: LineEdit var _whisper_message: LineEdit func setup(m2client: Node, parent: Node, assets_root := "") -> void: client = m2client if assets_root != "": _locale = Locale.new() _locale.setup(assets_root, "en") _build(parent) if client: if client.has_signal("chat"): client.chat.connect(_on_chat) if client.has_signal("whisper_received"): client.whisper_received.connect(_on_whisper) if client.has_signal("item_picked_up"): client.item_picked_up.connect(_on_pickup) if client.has_signal("fishing_event"): client.fishing_event.connect(_on_fishing_event) func set_proto(item_proto: Node) -> void: _proto = item_proto func _loc(key: String, fallback: String, args: Array = []) -> String: if _locale and _locale.has(key): return _locale.t(key, args) return fallback % args if not args.is_empty() else fallback func on_fishing_feedback(code: String) -> void: if code == "FISHING_WRONG_PLACE": var s := _loc("FISHING_WRONG_PLACE", "You cannot go fishing here.") _append(2, T_INFO, s) _append(3, T_INFO, s) func focus_input() -> void: if _input: _input.grab_focus() # 预填 "/w <名> " 并聚焦(好友列表点名字时调)。 func start_whisper(name: String) -> void: if _whisper_dialog == null or name.strip_edges() == "": return _whisper_target.text = name.strip_edges() _whisper_message.clear() _whisper_dialog.visible = true if _whisper_message.is_inside_tree(): _whisper_message.grab_focus() func is_whisper_open() -> bool: return _whisper_dialog != null and _whisper_dialog.visible func whisper_target() -> String: return _whisper_target.text if _whisper_target != null else "" func is_typing() -> bool: return _input != null and _input.has_focus() # game.py ToggleChatLogWindow hides/shows the complete chat log while keeping # the accumulated channel buffers. Enter can still focus the input afterward. func toggle_log() -> void: if _root: _root.visible = not _root.visible func is_log_visible() -> bool: return _root != null and _root.visible # --- 建 UI -------------------------------------------------------------- func _build(parent: Node) -> void: _root = Control.new() _root.set_anchors_preset(Control.PRESET_BOTTOM_LEFT) _root.position = Vector2(12, -232) _root.size = Vector2(460, 220) parent.add_child(_root) var panel := Panel.new() panel.set_anchors_preset(Control.PRESET_FULL_RECT) var sb := StyleBoxFlat.new() sb.bg_color = Color(0.05, 0.06, 0.08, 0.72) sb.set_corner_radius_all(4) panel.add_theme_stylebox_override("panel", sb) _root.add_child(panel) var tabrow := HBoxContainer.new() tabrow.position = Vector2(6, 4) _root.add_child(tabrow) for i in TABS.size(): var b := Button.new() b.text = TABS[i] b.toggle_mode = true b.button_pressed = (i == 0) b.add_theme_font_size_override("font_size", 11) var idx := i b.pressed.connect(func(): _select_tab(idx)) tabrow.add_child(b) _tab_btns.append(b) for i in TABS.size(): var rt := RichTextLabel.new() rt.bbcode_enabled = true rt.scroll_following = true rt.selection_enabled = true rt.position = Vector2(6, 30) rt.size = Vector2(448, 158) rt.visible = (i == 0) rt.add_theme_font_size_override("normal_font_size", 12) _root.add_child(rt) _log[i] = rt _buffers[i] = [] _input = LineEdit.new() _input.placeholder_text = "回车发送 · /w 名 私聊 · /g 公会 · /p 队伍 · /s 喊话" _input.position = Vector2(6, 192) _input.size = Vector2(448, 24) _input.add_theme_font_size_override("font_size", 12) _input.text_submitted.connect(_on_submit) _root.add_child(_input) _build_whisper_dialog(parent) func _build_whisper_dialog(parent: Node) -> void: # root/game.py 的 OpenWhisperDialog 是独立窗口,不应把私聊目标写回公共 # 聊天输入行。这里用 UiManager 同级的独立 Panel,保留自己的焦点和发送键。 _whisper_dialog = Panel.new() _whisper_dialog.name = "WhisperDialog" _whisper_dialog.set_anchors_preset(Control.PRESET_CENTER) _whisper_dialog.position = Vector2(-170, -82) _whisper_dialog.size = Vector2(340, 164) _whisper_dialog.visible = false _whisper_dialog.mouse_filter = Control.MOUSE_FILTER_STOP var bg := StyleBoxFlat.new() bg.bg_color = Color(0.04, 0.05, 0.08, 0.97) bg.border_color = Color(0.62, 0.36, 0.72, 0.95) bg.set_border_width_all(1) bg.set_corner_radius_all(5) _whisper_dialog.add_theme_stylebox_override("panel", bg) parent.add_child(_whisper_dialog) var title := Label.new() title.text = "私聊" title.position = Vector2(14, 10) title.add_theme_font_size_override("font_size", 16) _whisper_dialog.add_child(title) var target_label := Label.new() target_label.text = "对象" target_label.position = Vector2(14, 45) _whisper_dialog.add_child(target_label) _whisper_target = LineEdit.new() _whisper_target.position = Vector2(66, 41) _whisper_target.size = Vector2(256, 26) _whisper_target.editable = false _whisper_target.mouse_filter = Control.MOUSE_FILTER_IGNORE _whisper_dialog.add_child(_whisper_target) var message_label := Label.new() message_label.text = "内容" message_label.position = Vector2(14, 80) _whisper_dialog.add_child(message_label) _whisper_message = LineEdit.new() _whisper_message.position = Vector2(66, 76) _whisper_message.size = Vector2(256, 26) _whisper_message.placeholder_text = "输入私聊内容" _whisper_message.text_submitted.connect(_on_whisper_submit) _whisper_dialog.add_child(_whisper_message) var cancel := Button.new() cancel.text = "取消" cancel.position = Vector2(170, 119) cancel.size = Vector2(70, 28) cancel.pressed.connect(_close_whisper) _whisper_dialog.add_child(cancel) var send := Button.new() send.text = "发送" send.position = Vector2(248, 119) send.size = Vector2(74, 28) send.pressed.connect(func(): _on_whisper_submit(_whisper_message.text)) _whisper_dialog.add_child(send) func _close_whisper() -> void: if _whisper_dialog: _whisper_dialog.visible = false if _whisper_message and _whisper_message.is_inside_tree(): _whisper_message.release_focus() func _on_whisper_submit(text: String) -> void: var to := _whisper_target.text.strip_edges() if _whisper_target else "" var msg := text.strip_edges() if to == "" or msg == "" or client == null: return client.whisper(to, msg) _append(1, T_WHISPER, "→ %s: %s" % [to, msg]) _append(0, T_WHISPER, "→ %s: %s" % [to, msg]) _close_whisper() func _unhandled_input(event: InputEvent) -> void: if not is_whisper_open() or not (event is InputEventKey): return var key := event as InputEventKey if not key.pressed: return if key.keycode == KEY_ESCAPE: _close_whisper() get_viewport().set_input_as_handled() elif key.keycode not in [KEY_ENTER, KEY_KP_ENTER]: # 独立私聊窗口打开时,WASD / 快捷栏键不应穿透到世界。 get_viewport().set_input_as_handled() func _select_tab(i: int) -> void: _tab = i for t in _log: _log[t].visible = (t == i) for k in _tab_btns.size(): _tab_btns[k].button_pressed = (k == i) # --- 输入 ------------------------------------------------------------- func _on_submit(text: String) -> void: _input.clear() if _input.is_inside_tree(): _input.release_focus() var s := text.strip_edges() if s == "" or client == null: return if s.begins_with("/w ") or s.begins_with("/whisper "): var rest := s.substr(s.find(" ") + 1).strip_edges() var sp := rest.find(" ") if sp > 0: var to := rest.substr(0, sp) var msg := rest.substr(sp + 1).strip_edges() client.whisper(to, msg) _append(1, T_WHISPER, "→ %s: %s" % [to, msg]) return var map := {"/g ": T_GUILD, "/p ": T_PARTY, "/s ": T_SHOUT} for pre in map: if s.begins_with(pre): client.say(map[pre], s.substr(3).strip_edges()) return client.say(T_TALKING, s) # --- 收消息 -------------------------------------------------------------- func _on_chat(type: int, vid: int, text: String) -> void: var who := "" if client and client.has_method("get_entity"): var e: Dictionary = client.get_entity(vid) if not e.is_empty(): who = String(e.get("name", "")) var body := ("%s: %s" % [who, text]) if who != "" else text var tab := 0 if type == T_INFO or type == T_NOTICE: tab = 2 _append(tab, type, body) if tab != 0: _append(0, type, body) # 全部标签也收一份 func _on_whisper(sub: int, from: String, text: String) -> void: var tag := from if sub == 1: tag = "[系统]" elif sub == 2: tag = "[GM] " + from _append(1, T_WHISPER, "%s: %s" % [tag, text]) _append(0, T_WHISPER, "%s: %s" % [tag, text]) func _on_pickup(vnum: int, count: int, from: String) -> void: var s := "拾取 #%d x%d" % [vnum, count] if from != "": s += "(来自 %s)" % from _append(2, T_INFO, s) _append(3, T_INFO, s) func _on_fishing_event(subheader: int, info: int, _dir: int) -> void: # RecvFishing only calls a UI callback for FAIL and FISH. SUCCESS is an # animation transition; the later FISH event decides notify vs success # from the actor's IsFishing() state. if subheader == 0: # FISHING_START _fishing_active = true return if subheader == 2: # FISHING_REACT _fishing_active = true return if subheader == FISHING_FAIL: _fishing_active = false var fail := _loc("FISHING_FAILURE", "You lost the Bait.") _append(2, T_INFO, fail) _append(3, T_INFO, fail) return if subheader != FISHING_FISH: if subheader == FISHING_SUCCESS: _fishing_active = false elif subheader == 1: # FISHING_STOP _fishing_active = false return if info == 0: var unknown := _loc("FISHING_UNKNOWN", "Something has taken the bait but you can't see what it is.") _append(2, T_INFO, unknown) _append(3, T_INFO, unknown) return var item: Dictionary = _proto.item(info) if _proto and _proto.has_method("item") else {} # ClientVS22 silently ignores a fish event when item_proto has no record. if item.is_empty(): return var name := String(item.get("locale_name", item.get("name", ""))) if name == "": name = "#%d" % info var is_fish := int(item.get("type", -1)) == 12 # CItemData::ITEM_TYPE_FISH var key := "" var fallback := "" if _fishing_active: key = "FISHING_NOTIFY1" if is_fish else "FISHING_NOTIFY2" fallback = "It looks like %s is hooked." if is_fish else "It looks like %s is on the hook." else: key = "FISHING_SUCCESS1" if is_fish else "FISHING_SUCCESS2" fallback = "You captured %s!" if is_fish else "You have pulled %s out of the water!" var s := _loc(key, fallback, [name]) _append(2, T_INFO, s) _append(3, T_INFO, s) # --- 追加行 -------------------------------------------------------------- func _append(tab: int, type: int, body: String) -> void: var c: Color = COLOR.get(type, COLOR[0]) var line := "[color=#%s]%s[/color]" % [c.to_html(false), body] var buf: Array = _buffers[tab] buf.append(line) if buf.size() > _max_lines: buf.pop_front() var rt: RichTextLabel = _log[tab] rt.text = "\n".join(buf) line_added.emit(tab, line)