Initial WhaleTown V2 frontend

This commit is contained in:
2026-07-19 22:42:20 +08:00
commit 435c578dde
1421 changed files with 54486 additions and 0 deletions

View File

@@ -0,0 +1,369 @@
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]

View File

@@ -0,0 +1 @@
uid://cyftwn8xuo8so

View File

@@ -0,0 +1,82 @@
[gd_scene load_steps=3 format=3 uid="uid://dqx8k3n8yqjvu"]
[ext_resource type="Script" path="res://scenes/prefabs/ui/ChatMessage.gd" id="1"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_panel"]
[node name="ChatMessage" type="PanelContainer"]
custom_minimum_size = Vector2(0, 58)
layout_mode = 2
size_flags_horizontal = 3
script = ExtResource("1")
theme_override_styles/panel = SubResource("StyleBoxEmpty_panel")
[node name="MessageRow" type="HBoxContainer" parent="."]
layout_mode = 2
theme_override_constants/separation = 10
alignment = 0
[node name="LeftAvatarPanel" type="PanelContainer" parent="MessageRow"]
custom_minimum_size = Vector2(40, 40)
layout_mode = 2
size_flags_vertical = 0
[node name="AvatarLabel" type="Label" parent="MessageRow/LeftAvatarPanel"]
layout_mode = 2
custom_minimum_size = Vector2(40, 40)
theme_override_colors/font_color = Color(1, 1, 1, 1)
theme_override_font_sizes/font_size = 16
text = "鲸"
horizontal_alignment = 1
vertical_alignment = 1
[node name="BubblePanel" type="PanelContainer" parent="MessageRow"]
layout_mode = 2
size_flags_horizontal = 0
[node name="TextContainer" type="VBoxContainer" parent="MessageRow/BubblePanel"]
layout_mode = 2
theme_override_constants/separation = 2
[node name="HeaderRow" type="HBoxContainer" parent="MessageRow/BubblePanel/TextContainer"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="UsernameLabel" type="Label" parent="MessageRow/BubblePanel/TextContainer/HeaderRow"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.207843, 0.427451, 0.686275, 1)
theme_override_font_sizes/font_size = 16
text = "鲸鱼居民"
text_overrun_behavior = 3
[node name="TimestampLabel" type="Label" parent="MessageRow/BubblePanel/TextContainer/HeaderRow"]
layout_mode = 2
theme_override_colors/font_color = Color(0.560784, 0.639216, 0.733333, 1)
theme_override_font_sizes/font_size = 13
text = "12:34"
[node name="ContentLabel" type="RichTextLabel" parent="MessageRow/BubblePanel/TextContainer"]
custom_minimum_size = Vector2(0, 26)
layout_mode = 2
size_flags_horizontal = 3
fit_content = true
scroll_active = false
autowrap_mode = 3
bbcode_enabled = false
theme_override_colors/default_color = Color(0.388235, 0.478431, 0.584314, 1)
theme_override_font_sizes/normal_font_size = 14
[node name="RightAvatarPanel" type="PanelContainer" parent="MessageRow"]
custom_minimum_size = Vector2(40, 40)
layout_mode = 2
size_flags_vertical = 0
[node name="AvatarLabel" type="Label" parent="MessageRow/RightAvatarPanel"]
layout_mode = 2
custom_minimum_size = Vector2(40, 40)
theme_override_colors/font_color = Color(1, 1, 1, 1)
theme_override_font_sizes/font_size = 16
text = "我"
horizontal_alignment = 1
vertical_alignment = 1