forked from xiangwang25/whale-town-front-v2
370 lines
14 KiB
GDScript
370 lines
14 KiB
GDScript
extends PanelContainer
|
||
|
||
# ============================================================================
|
||
# ChatMessage.gd - 聊天消息气泡组件
|
||
# ============================================================================
|
||
# 显示单条聊天消息的 UI 组件
|
||
#
|
||
# 核心职责:
|
||
# - 显示消息发送者、内容、时间戳
|
||
# - 区分自己和他人的消息样式
|
||
# - 自动格式化时间戳
|
||
#
|
||
# 使用方式:
|
||
# var message := chat_message_scene.instantiate()
|
||
# message.set_message("PlayerName", "Hello!", timestamp, false)
|
||
#
|
||
# 注意事项:
|
||
# - 使用 @onready 缓存节点引用
|
||
# - 最大宽度限制为 400 像素
|
||
# ============================================================================
|
||
|
||
class_name ChatMessage
|
||
|
||
# ============================================================================
|
||
# 导出参数
|
||
# ============================================================================
|
||
|
||
# 最大宽度(像素)
|
||
@export var max_width: int = 400
|
||
|
||
const MIN_BUBBLE_TEXT_WIDTH: float = 86.0
|
||
const BUBBLE_HORIZONTAL_MARGIN: float = 28.0
|
||
const HEADER_GAP_WIDTH: float = 8.0
|
||
const AVATAR_SIZE: float = 40.0
|
||
const AVATAR_CORNER_RADIUS: int = 12
|
||
|
||
# ============================================================================
|
||
# 节点引用
|
||
# ============================================================================
|
||
|
||
# 用户名标签
|
||
var username_label: Label
|
||
|
||
# 时间戳标签
|
||
var timestamp_label: Label
|
||
|
||
# 内容标签
|
||
var content_label: RichTextLabel
|
||
|
||
# 用户信息容器
|
||
var user_info_container: HBoxContainer
|
||
|
||
# 头像节点
|
||
var left_avatar_panel: PanelContainer
|
||
var right_avatar_panel: PanelContainer
|
||
var left_avatar_label: Label
|
||
var right_avatar_label: Label
|
||
var message_row: HBoxContainer
|
||
var bubble_panel: PanelContainer
|
||
var text_container: VBoxContainer
|
||
|
||
# ============================================================================
|
||
# 生命周期方法
|
||
# ============================================================================
|
||
|
||
func _ready() -> void:
|
||
_cache_node_refs()
|
||
|
||
func _cache_node_refs() -> void:
|
||
if not username_label:
|
||
username_label = get_node_or_null("MessageRow/BubblePanel/TextContainer/HeaderRow/UsernameLabel")
|
||
if not timestamp_label:
|
||
timestamp_label = get_node_or_null("MessageRow/BubblePanel/TextContainer/HeaderRow/TimestampLabel")
|
||
if not content_label:
|
||
content_label = get_node_or_null("MessageRow/BubblePanel/TextContainer/ContentLabel")
|
||
if not user_info_container:
|
||
user_info_container = get_node_or_null("MessageRow/BubblePanel/TextContainer/HeaderRow")
|
||
if not left_avatar_panel:
|
||
left_avatar_panel = get_node_or_null("MessageRow/LeftAvatarPanel")
|
||
if not right_avatar_panel:
|
||
right_avatar_panel = get_node_or_null("MessageRow/RightAvatarPanel")
|
||
if not left_avatar_label:
|
||
left_avatar_label = get_node_or_null("MessageRow/LeftAvatarPanel/AvatarLabel")
|
||
if not right_avatar_label:
|
||
right_avatar_label = get_node_or_null("MessageRow/RightAvatarPanel/AvatarLabel")
|
||
if not message_row:
|
||
message_row = get_node_or_null("MessageRow")
|
||
if not bubble_panel:
|
||
bubble_panel = get_node_or_null("MessageRow/BubblePanel")
|
||
if not text_container:
|
||
text_container = get_node_or_null("MessageRow/BubblePanel/TextContainer")
|
||
|
||
_configure_avatar(left_avatar_panel, left_avatar_label)
|
||
_configure_avatar(right_avatar_panel, right_avatar_label)
|
||
|
||
# 内容换行与自适应高度
|
||
if content_label:
|
||
content_label.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||
content_label.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||
content_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
content_label.fit_content = true
|
||
content_label.scroll_active = false
|
||
|
||
# ============================================================================
|
||
# 成员变量
|
||
# ============================================================================
|
||
|
||
# 是否为自己发送的消息
|
||
var _is_self: bool = false
|
||
|
||
# ============================================================================
|
||
# 公共 API
|
||
# ============================================================================
|
||
|
||
# 设置消息内容
|
||
#
|
||
# 参数:
|
||
# from_user: String - 发送者用户名
|
||
# content: String - 消息内容
|
||
# timestamp: float - Unix 时间戳
|
||
# is_self: bool - 是否为自己发送的消息(默认 false)
|
||
#
|
||
# 使用示例:
|
||
# message.set_message("Alice", "Hello!", 1703500800.0, false)
|
||
func set_message(from_user: String, content: String, timestamp: float, is_self: bool = false) -> void:
|
||
_is_self = is_self
|
||
_cache_node_refs()
|
||
|
||
var safe_from_user := from_user
|
||
if safe_from_user.strip_edges().is_empty():
|
||
safe_from_user = "我" if is_self else "玩家"
|
||
|
||
# 设置用户名(带空值检查)
|
||
if username_label:
|
||
username_label.text = safe_from_user
|
||
else:
|
||
push_error("ChatMessage: username_label is null!")
|
||
return
|
||
|
||
var avatar_text := _get_avatar_text(safe_from_user, is_self)
|
||
if left_avatar_label:
|
||
left_avatar_label.text = avatar_text
|
||
if right_avatar_label:
|
||
right_avatar_label.text = avatar_text
|
||
|
||
# 设置内容
|
||
if content_label:
|
||
content_label.clear() # 清除默认文本和所有内容
|
||
content_label.append_text(content) # 作为纯文本追加,避免 BBCode 解析导致内容不显示
|
||
else:
|
||
push_error("ChatMessage: content_label is null!")
|
||
return
|
||
|
||
# 设置时间戳
|
||
if timestamp_label:
|
||
timestamp_label.text = _format_timestamp(timestamp)
|
||
else:
|
||
push_error("ChatMessage: timestamp_label is null!")
|
||
return
|
||
|
||
_update_bubble_width(safe_from_user, content)
|
||
|
||
# 应用样式
|
||
_apply_style()
|
||
|
||
# ============================================================================
|
||
# 内部方法 - 样式处理
|
||
# ============================================================================
|
||
|
||
# 应用样式(自己和他人的消息不同)
|
||
func _apply_style() -> void:
|
||
if not username_label or not timestamp_label or not user_info_container:
|
||
return
|
||
|
||
# 重要:设置垂直 size flags 让 Panel 适应内容高度
|
||
size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||
size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
|
||
if _is_self:
|
||
if message_row:
|
||
message_row.alignment = BoxContainer.ALIGNMENT_END
|
||
user_info_container.alignment = BoxContainer.ALIGNMENT_END
|
||
_set_avatar_side(true)
|
||
|
||
# 设置面板样式
|
||
if bubble_panel:
|
||
bubble_panel.add_theme_stylebox_override("panel", _get_self_style())
|
||
if right_avatar_panel:
|
||
right_avatar_panel.add_theme_stylebox_override("panel", _get_avatar_style(true))
|
||
_apply_selected_avatar(right_avatar_panel, right_avatar_label, username_label.text)
|
||
|
||
# 设置文字颜色 - ID使用金色 #FFD700
|
||
username_label.add_theme_color_override("font_color", Color(0.258824, 0.627451, 0.913725))
|
||
timestamp_label.add_theme_color_override("font_color", Color(0.560784, 0.639216, 0.733333))
|
||
if right_avatar_label:
|
||
right_avatar_label.add_theme_color_override("font_color", Color(1, 1, 1))
|
||
if content_label:
|
||
content_label.add_theme_color_override("default_color", Color(0.188, 0.294, 0.424))
|
||
else:
|
||
if message_row:
|
||
message_row.alignment = BoxContainer.ALIGNMENT_BEGIN
|
||
user_info_container.alignment = BoxContainer.ALIGNMENT_BEGIN
|
||
_set_avatar_side(false)
|
||
|
||
# 设置面板样式
|
||
if bubble_panel:
|
||
bubble_panel.add_theme_stylebox_override("panel", _get_other_style())
|
||
if left_avatar_panel:
|
||
left_avatar_panel.add_theme_stylebox_override("panel", _get_avatar_style(false))
|
||
|
||
# 设置文字颜色 - ID使用蓝色 #69c0ff
|
||
username_label.add_theme_color_override("font_color", Color(0.207843, 0.427451, 0.686275))
|
||
timestamp_label.add_theme_color_override("font_color", Color(0.560784, 0.639216, 0.733333))
|
||
if left_avatar_label:
|
||
left_avatar_label.add_theme_color_override("font_color", Color(1, 1, 1))
|
||
if content_label:
|
||
content_label.add_theme_color_override("default_color", Color(0.27451, 0.407843, 0.541176))
|
||
|
||
func _set_avatar_side(is_self: bool) -> void:
|
||
if left_avatar_panel:
|
||
left_avatar_panel.visible = not is_self
|
||
if right_avatar_panel:
|
||
right_avatar_panel.visible = is_self
|
||
|
||
func _configure_avatar(panel: PanelContainer, label: Label) -> void:
|
||
if panel:
|
||
panel.custom_minimum_size = Vector2(AVATAR_SIZE, AVATAR_SIZE)
|
||
panel.size = Vector2(AVATAR_SIZE, AVATAR_SIZE)
|
||
panel.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||
panel.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||
panel.clip_contents = true
|
||
if label:
|
||
label.custom_minimum_size = Vector2(AVATAR_SIZE, AVATAR_SIZE)
|
||
label.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||
label.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
|
||
func _update_bubble_width(from_user: String, content: String) -> void:
|
||
var maxTextWidth := float(max_width) - BUBBLE_HORIZONTAL_MARGIN
|
||
var contentWidth := _measure_label_text_width(content_label, content, 14.0)
|
||
var headerWidth := _measure_label_text_width(username_label, from_user, 16.0) + _measure_label_text_width(timestamp_label, timestamp_label.text, 13.0) + HEADER_GAP_WIDTH
|
||
var textWidth: float = clamp(max(contentWidth, headerWidth, MIN_BUBBLE_TEXT_WIDTH), MIN_BUBBLE_TEXT_WIDTH, maxTextWidth)
|
||
|
||
if username_label:
|
||
username_label.custom_minimum_size.x = min(_measure_label_text_width(username_label, from_user, 16.0), textWidth)
|
||
if timestamp_label:
|
||
timestamp_label.custom_minimum_size.x = _measure_label_text_width(timestamp_label, timestamp_label.text, 13.0)
|
||
if content_label:
|
||
content_label.custom_minimum_size.x = textWidth
|
||
if bubble_panel:
|
||
bubble_panel.custom_minimum_size.x = textWidth + BUBBLE_HORIZONTAL_MARGIN
|
||
|
||
func _measure_label_text_width(label: Control, text: String, fallback_width: float) -> float:
|
||
if label != null:
|
||
var font := label.get_theme_font("font")
|
||
if font != null:
|
||
var fontSize := label.get_theme_font_size("font_size")
|
||
if fontSize <= 0:
|
||
fontSize = int(fallback_width)
|
||
var measured := font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1.0, fontSize).x
|
||
if measured > 0.0:
|
||
return measured + 2.0
|
||
|
||
var width := 0.0
|
||
for index in range(text.length()):
|
||
var code := text.unicode_at(index)
|
||
if code <= 0x7f:
|
||
width += fallback_width * 0.58
|
||
else:
|
||
width += fallback_width
|
||
return width + 2.0
|
||
|
||
# 获取自己消息的样式
|
||
func _get_self_style() -> StyleBoxFlat:
|
||
var style := StyleBoxFlat.new()
|
||
style.bg_color = Color(0.878431, 0.94902, 1.0, 0.96)
|
||
style.border_width_left = 1
|
||
style.border_width_top = 1
|
||
style.border_width_right = 1
|
||
style.border_width_bottom = 1
|
||
style.border_color = Color(0.64, 0.82, 0.95, 0.78)
|
||
style.corner_radius_top_left = 14
|
||
style.corner_radius_top_right = 8
|
||
style.corner_radius_bottom_left = 14
|
||
style.corner_radius_bottom_right = 14
|
||
style.content_margin_left = 14
|
||
style.content_margin_right = 14
|
||
style.content_margin_top = 9
|
||
style.content_margin_bottom = 9
|
||
return style
|
||
|
||
# 获取他人消息的样式
|
||
func _get_other_style() -> StyleBoxFlat:
|
||
var style := StyleBoxFlat.new()
|
||
style.bg_color = Color(1, 1, 1, 0.96)
|
||
style.border_width_left = 1
|
||
style.border_width_top = 1
|
||
style.border_width_right = 1
|
||
style.border_width_bottom = 1
|
||
style.border_color = Color(0.78, 0.85, 0.92, 0.72)
|
||
style.corner_radius_top_left = 8
|
||
style.corner_radius_top_right = 14
|
||
style.corner_radius_bottom_left = 14
|
||
style.corner_radius_bottom_right = 14
|
||
style.content_margin_left = 14
|
||
style.content_margin_right = 14
|
||
style.content_margin_top = 9
|
||
style.content_margin_bottom = 9
|
||
return style
|
||
|
||
func _get_avatar_style(is_self: bool) -> StyleBoxFlat:
|
||
if is_self:
|
||
var appearanceManager := get_node_or_null("/root/AppearanceManager")
|
||
if appearanceManager != null and appearanceManager.has_method("create_avatar_style") and appearanceManager.has_method("get_selected_avatar"):
|
||
var avatar: Variant = appearanceManager.call("get_selected_avatar")
|
||
if avatar is Dictionary:
|
||
return appearanceManager.call("create_avatar_style", avatar as Dictionary, AVATAR_CORNER_RADIUS)
|
||
|
||
var style := StyleBoxFlat.new()
|
||
style.bg_color = Color(0.361, 0.690, 0.914) if is_self else Color(0.62, 0.75, 0.86)
|
||
style.corner_radius_top_left = AVATAR_CORNER_RADIUS
|
||
style.corner_radius_top_right = AVATAR_CORNER_RADIUS
|
||
style.corner_radius_bottom_left = AVATAR_CORNER_RADIUS
|
||
style.corner_radius_bottom_right = AVATAR_CORNER_RADIUS
|
||
style.border_width_left = 1
|
||
style.border_width_top = 1
|
||
style.border_width_right = 1
|
||
style.border_width_bottom = 1
|
||
style.border_color = Color(1, 1, 1, 0.82)
|
||
return style
|
||
|
||
func _get_avatar_text(from_user: String, is_self: bool) -> String:
|
||
if is_self:
|
||
var appearanceManager := get_node_or_null("/root/AppearanceManager")
|
||
if appearanceManager != null and appearanceManager.has_method("has_custom_avatar") and bool(appearanceManager.call("has_custom_avatar")):
|
||
return ""
|
||
if appearanceManager != null and appearanceManager.has_method("get_avatar_label"):
|
||
return str(appearanceManager.call("get_avatar_label"))
|
||
return "我"
|
||
var trimmed := from_user.strip_edges()
|
||
if trimmed.is_empty():
|
||
return "鲸"
|
||
return trimmed.substr(0, 1)
|
||
|
||
func _apply_selected_avatar(panel: PanelContainer, label: Label, fallbackText: String) -> void:
|
||
var appearanceManager := get_node_or_null("/root/AppearanceManager")
|
||
if appearanceManager != null and appearanceManager.has_method("apply_avatar_to_panel"):
|
||
appearanceManager.call("apply_avatar_to_panel", panel, label, "", fallbackText)
|
||
|
||
# ============================================================================
|
||
# 内部方法 - 工具函数
|
||
# ============================================================================
|
||
|
||
# 格式化时间戳
|
||
#
|
||
# 参数:
|
||
# timestamp: float - Unix 时间戳
|
||
#
|
||
# 返回值:
|
||
# String - 格式化的时间字符串
|
||
func _format_timestamp(timestamp: float) -> String:
|
||
if timestamp == 0:
|
||
return ""
|
||
|
||
var datetime := Time.get_datetime_dict_from_unix_time(timestamp)
|
||
|
||
# 格式化为 HH:MM
|
||
return "%02d:%02d" % [datetime.hour, datetime.minute]
|