377 lines
12 KiB
GDScript
377 lines
12 KiB
GDScript
# 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)
|