Files
mtgodot-poc/project/ui/chat_ui.gd
T

377 lines
12 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 喊话 · 其它 普通。
# 消费 M2Clientchat / 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)