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

2373
scenes/ui/AuthScene.gd Normal file

File diff suppressed because it is too large Load Diff

View File

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

12
scenes/ui/AuthScene.tscn Normal file
View File

@@ -0,0 +1,12 @@
[gd_scene load_steps=2 format=3 uid="uid://dkmpv7bgmot2e"]
[ext_resource type="Script" path="res://scenes/ui/AuthScene.gd" id="1_auth"]
[node name="AuthScene" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_auth")

View File

@@ -0,0 +1,63 @@
extends Button
# 世界频道气泡发送按钮,线条风格与顶部 HUD 快捷入口保持一致。
const LINE_COLOR: Color = Color(0.592157, 0.72549, 0.835294, 0.88)
const HOVER_COLOR: Color = Color(0.392157, 0.717647, 0.94902, 0.96)
const LINE_WIDTH: float = 0.95
const DETAIL_WIDTH: float = 0.82
const BASE_SIZE: float = 27.0
var _iconScale: float = 1.0
var _iconOffset: Vector2 = Vector2.ZERO
func _ready() -> void:
text = ""
queue_redraw()
func _notification(what: int) -> void:
if what == NOTIFICATION_THEME_CHANGED:
queue_redraw()
func _draw() -> void:
_iconScale = min(size.x, size.y) / BASE_SIZE
_iconOffset = (size - Vector2(BASE_SIZE, BASE_SIZE) * _iconScale) * 0.5
var lineColor := HOVER_COLOR if is_hovered() else LINE_COLOR
_draw_round_rect(Rect2(6.0, 7.0, 15.0, 11.0), 3.2, lineColor, LINE_WIDTH)
_draw_polyline([
Vector2(10.2, 18.0),
Vector2(8.2, 21.2),
Vector2(14.4, 18.0),
], lineColor, LINE_WIDTH)
_draw_line(Vector2(9.2, 11.7), Vector2(17.6, 11.7), lineColor, DETAIL_WIDTH)
_draw_line(Vector2(9.2, 14.6), Vector2(14.8, 14.6), lineColor, DETAIL_WIDTH)
func _draw_round_rect(rect: Rect2, radius: float, color: Color, width: float) -> void:
var left := rect.position.x
var top := rect.position.y
var right := rect.end.x
var bottom := rect.end.y
_draw_line(Vector2(left + radius, top), Vector2(right - radius, top), color, width)
_draw_line(Vector2(right, top + radius), Vector2(right, bottom - radius), color, width)
_draw_line(Vector2(right - radius, bottom), Vector2(left + radius, bottom), color, width)
_draw_line(Vector2(left, bottom - radius), Vector2(left, top + radius), color, width)
_draw_arc(Vector2(left + radius, top + radius), radius, PI, PI * 1.5, 8, color, width)
_draw_arc(Vector2(right - radius, top + radius), radius, PI * 1.5, TAU, 8, color, width)
_draw_arc(Vector2(right - radius, bottom - radius), radius, 0.0, PI * 0.5, 8, color, width)
_draw_arc(Vector2(left + radius, bottom - radius), radius, PI * 0.5, PI, 8, color, width)
func _draw_polyline(points: Array[Vector2], color: Color, width: float) -> void:
var packed: PackedVector2Array = []
for point in points:
packed.append(_p(point))
draw_polyline(packed, color, width, true)
func _draw_line(from: Vector2, to: Vector2, color: Color, width: float) -> void:
draw_line(_p(from), _p(to), color, width, true)
func _draw_arc(center: Vector2, radius: float, startAngle: float, endAngle: float, pointCount: int, color: Color, width: float) -> void:
draw_arc(_p(center), radius * _iconScale, startAngle, endAngle, pointCount, color, width, true)
func _p(point: Vector2) -> Vector2:
return _iconOffset + point * _iconScale

View File

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

View File

@@ -0,0 +1,495 @@
extends Control
# ============================================================================
# CafeCompanionPanel.gd - 咖啡店陪伴机器人面板
# ============================================================================
# 点击咖啡店陪伴机器人后,先展示付费时长选择;购买成功后才进入聊天。
# ============================================================================
@onready var chatFrame: PanelContainer = %CafeCompanionFrame
@onready var titleLabel: Label = %TitleLabel
@onready var subtitleLabel: Label = %SubtitleLabel
@onready var productList: VBoxContainer = %ProductList
@onready var purchaseButton: Button = %PurchaseButton
@onready var statusLabel: Label = %StatusLabel
@onready var chatHistory: ScrollContainer = %ChatHistory
@onready var messageList: VBoxContainer = %MessageList
@onready var chatInput: LineEdit = %ChatInput
@onready var sendButton: Button = %SendButton
@onready var closeButton: Button = %CloseButton
var _currentSessionId: String = ""
var _currentTarget: Dictionary = {}
var _products: Array = []
var _selectedMinutes: int = 0
var _waitingForReply: bool = false
var _resignTarget: Dictionary = {}
var _resignDialog: Control
var _resignLabel: Label
var _resignConfirmButton: Button
var _resignCancelButton: Button
func _ready() -> void:
chatFrame.visible = false
purchaseButton.pressed.connect(_on_purchase_pressed)
sendButton.pressed.connect(_on_send_pressed)
closeButton.pressed.connect(_on_close_pressed)
chatInput.text_submitted.connect(_on_input_submitted)
_build_resign_dialog()
_connect_events()
_set_chat_enabled(false)
func _exit_tree() -> void:
var eventSystem := get_node_or_null("/root/EventSystem")
if eventSystem != null:
eventSystem.call("disconnect_event", EventNames.CAFE_COMPANION_SELECTED, _on_cafe_companion_selected, self)
eventSystem.call("disconnect_event", EventNames.CAFE_COMPANION_SELF_SELECTED, _on_cafe_companion_self_selected, self)
eventSystem.call("disconnect_event", EventNames.CAFE_COMPANION_PRODUCTS_READY, _on_products_ready, self)
eventSystem.call("disconnect_event", EventNames.CAFE_COMPANION_CHAT_TIME_PURCHASED, _on_chat_time_purchased, self)
eventSystem.call("disconnect_event", EventNames.CAFE_COMPANION_MESSAGE_RECEIVED, _on_message_received, self)
eventSystem.call("disconnect_event", EventNames.CAFE_COMPANION_EMPLOYMENT_RESIGNED, _on_employment_resigned, self)
eventSystem.call("disconnect_event", EventNames.CAFE_COMPANION_ERROR_OCCURRED, _on_companion_error, self)
func _connect_events() -> void:
var eventSystem := get_node_or_null("/root/EventSystem")
if eventSystem == null:
push_warning("CafeCompanionPanel: EventSystem autoload is not available.")
return
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_SELECTED, _on_cafe_companion_selected, self)
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_SELF_SELECTED, _on_cafe_companion_self_selected, self)
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_PRODUCTS_READY, _on_products_ready, self)
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_CHAT_TIME_PURCHASED, _on_chat_time_purchased, self)
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_MESSAGE_RECEIVED, _on_message_received, self)
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_EMPLOYMENT_RESIGNED, _on_employment_resigned, self)
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_ERROR_OCCURRED, _on_companion_error, self)
func _on_cafe_companion_selected(data: Dictionary) -> void:
_currentTarget = data.duplicate(true)
_currentSessionId = ""
_waitingForReply = false
_selectedMinutes = 0
_clear_messages()
titleLabel.text = _format_target_title(data)
subtitleLabel.text = _format_target_subtitle(data)
statusLabel.text = "请选择陪聊时长"
purchaseButton.text = "购买聊天时间"
purchaseButton.disabled = true
chatFrame.visible = true
_set_chat_enabled(false)
var manager := _get_cafe_companion_manager()
if manager == null or not manager.has_method("get_chat_time_products"):
_set_error("CafeCompanionManager 未加载")
return
manager.call("get_chat_time_products")
func _on_cafe_companion_self_selected(data: Dictionary) -> void:
_resignTarget = data.duplicate(true)
_resignLabel.text = _format_resign_message(data)
_resignDialog.visible = true
_resignDialog.modulate.a = 0.0
var tween := create_tween()
tween.tween_property(_resignDialog, "modulate:a", 1.0, 0.12)
func _on_employment_resigned(data: Dictionary) -> void:
_hide_resign_dialog()
_set_error(_format_resign_result(data))
chatFrame.visible = false
func _on_products_ready(data: Dictionary) -> void:
if not chatFrame.visible:
return
var productsVariant: Variant = data.get("products", [])
_products = productsVariant if productsVariant is Array else []
_render_products()
if _products.is_empty():
statusLabel.text = "暂时没有可购买的陪聊时长"
else:
statusLabel.text = "点击时长后购买,购买成功才会进入聊天"
func _on_chat_time_purchased(data: Dictionary) -> void:
var sessionData: Dictionary = data
var sessionVariant: Variant = data.get("session", {})
if sessionVariant is Dictionary:
sessionData = sessionVariant
var servicePointId := str(sessionData.get("service_point_id", ""))
if not _currentTarget.is_empty() and servicePointId != str(_currentTarget.get("service_point_id", "")):
return
_currentSessionId = str(sessionData.get("session_id", ""))
_waitingForReply = false
_set_chat_enabled(true)
statusLabel.text = _format_purchase_status(data, sessionData)
var companionVariant: Variant = sessionData.get("companion", {})
if companionVariant is Dictionary:
var companion: Dictionary = companionVariant
titleLabel.text = str(companion.get("persona_name", titleLabel.text)).strip_edges()
_clear_messages()
var messagesVariant: Variant = sessionData.get("messages", [])
if messagesVariant is Array and not messagesVariant.is_empty():
for messageVariant in messagesVariant:
if messageVariant is Dictionary:
var message: Dictionary = messageVariant
_add_message(str(message.get("role", "assistant")), str(message.get("content", "")))
else:
var welcomeMessage := str(sessionData.get("welcome_message", "")).strip_edges()
if not welcomeMessage.is_empty():
_add_message("assistant", welcomeMessage)
chatInput.grab_focus()
func _on_message_received(data: Dictionary) -> void:
if str(data.get("session_id", "")) != _currentSessionId:
return
_waitingForReply = false
_set_chat_enabled(true)
statusLabel.text = _format_remaining_status(data)
var messageVariant: Variant = data.get("assistant_message", {})
if messageVariant is Dictionary:
var message: Dictionary = messageVariant
_add_message("assistant", str(message.get("content", "")))
func _on_companion_error(data: Dictionary) -> void:
if not chatFrame.visible and not (_resignDialog != null and _resignDialog.visible):
return
if _resignDialog != null and _resignDialog.visible and is_instance_valid(_resignLabel):
_resignLabel.text = str(data.get("message", "咖啡店陪聊暂时不可用"))
_resignConfirmButton.disabled = false
return
_set_error(str(data.get("message", "咖啡店陪聊暂时不可用")))
func _render_products() -> void:
for child in productList.get_children():
child.queue_free()
for productVariant in _products:
if not (productVariant is Dictionary):
continue
var product: Dictionary = productVariant
var minutes := int(product.get("minutes", 0))
if minutes <= 0:
continue
var button := Button.new()
button.toggle_mode = true
button.focus_mode = Control.FOCUS_NONE
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button.text = _format_product_label(product)
button.pressed.connect(func() -> void:
_select_product(minutes)
)
productList.add_child(button)
func _select_product(minutes: int) -> void:
_selectedMinutes = minutes
for child in productList.get_children():
var button := child as Button
if button != null:
button.button_pressed = button.text.begins_with("%d分钟" % minutes)
purchaseButton.disabled = false
purchaseButton.text = "购买 %d 分钟" % minutes
func _on_purchase_pressed() -> void:
if _selectedMinutes <= 0:
statusLabel.text = "请先选择陪聊时长"
return
var manager := _get_cafe_companion_manager()
if manager == null or not manager.has_method("purchase_chat_time"):
_set_error("CafeCompanionManager 未加载")
return
purchaseButton.disabled = true
statusLabel.text = "正在购买陪聊时长..."
manager.call("purchase_chat_time", _currentTarget, _selectedMinutes)
func _on_send_pressed() -> void:
_send_current_message()
func _on_input_submitted(_text: String) -> void:
_send_current_message()
func _send_current_message() -> void:
if _waitingForReply:
return
var content := chatInput.text.strip_edges()
if content.is_empty():
return
if _currentSessionId.is_empty():
_set_error("请先购买陪聊时长")
return
chatInput.clear()
_add_message("user", content)
_waitingForReply = true
_set_chat_enabled(false)
statusLabel.text = "对方正在回复..."
var manager := _get_cafe_companion_manager()
if manager == null or not manager.has_method("send_chat_message"):
_set_error("CafeCompanionManager 未加载")
return
manager.call("send_chat_message", _currentSessionId, content)
func _on_close_pressed() -> void:
chatFrame.visible = false
_waitingForReply = false
chatInput.release_focus()
func _confirm_resign() -> void:
var servicePointId := str(_resignTarget.get("service_point_id", "")).strip_edges()
if servicePointId.is_empty():
_set_error("离职目标不存在")
return
var manager := _get_cafe_companion_manager()
if manager == null or not manager.has_method("resign_employment"):
_set_error("CafeCompanionManager 未加载")
return
_resignConfirmButton.disabled = true
_resignLabel.text = "正在办理离职..."
manager.call("resign_employment", servicePointId)
func _hide_resign_dialog() -> void:
if is_instance_valid(_resignDialog):
_resignDialog.visible = false
if is_instance_valid(_resignConfirmButton):
_resignConfirmButton.disabled = false
func _set_error(message: String) -> void:
_waitingForReply = false
_set_chat_enabled(not _currentSessionId.is_empty())
purchaseButton.disabled = _selectedMinutes <= 0
statusLabel.text = message
func _set_chat_enabled(enabled: bool) -> void:
chatInput.editable = enabled
sendButton.disabled = not enabled
func _clear_messages() -> void:
for child in messageList.get_children():
child.queue_free()
func _add_message(role: String, content: String) -> void:
var normalizedContent := content.strip_edges()
if normalizedContent.is_empty():
return
var row := HBoxContainer.new()
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.alignment = BoxContainer.ALIGNMENT_END if role == "user" else BoxContainer.ALIGNMENT_BEGIN
var bubble := PanelContainer.new()
bubble.custom_minimum_size = Vector2(120, 0)
bubble.size_flags_horizontal = 0
bubble.add_theme_stylebox_override("panel", _make_bubble_style(role))
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 14)
margin.add_theme_constant_override("margin_top", 8)
margin.add_theme_constant_override("margin_right", 14)
margin.add_theme_constant_override("margin_bottom", 8)
var label := Label.new()
label.text = normalizedContent
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
label.custom_minimum_size = Vector2(300, 0)
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
label.add_theme_color_override("font_color", Color(0.16, 0.20, 0.25, 1))
label.add_theme_font_size_override("font_size", 16)
margin.add_child(label)
bubble.add_child(margin)
row.add_child(bubble)
messageList.add_child(row)
call_deferred("_scroll_to_bottom")
func _make_bubble_style(role: String) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(0.80, 0.92, 0.98, 1) if role == "user" else Color(1, 1, 1, 0.96)
style.border_color = Color(0.55, 0.72, 0.82, 0.35)
style.set_border_width_all(1)
style.set_corner_radius_all(10)
return style
func _scroll_to_bottom() -> void:
if is_instance_valid(chatHistory):
chatHistory.scroll_vertical = int(chatHistory.get_v_scroll_bar().max_value)
func _get_cafe_companion_manager() -> Node:
return get_node_or_null("/root/CafeCompanionManager")
func _build_resign_dialog() -> void:
_resignDialog = Control.new()
_resignDialog.name = "CafeEmploymentResignDialog"
_resignDialog.set_anchors_preset(Control.PRESET_CENTER)
_resignDialog.custom_minimum_size = Vector2(460, 240)
_resignDialog.offset_left = -230
_resignDialog.offset_top = -120
_resignDialog.offset_right = 230
_resignDialog.offset_bottom = 120
_resignDialog.visible = false
_resignDialog.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(_resignDialog)
var bg := PanelContainer.new()
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
bg.add_theme_stylebox_override("panel", _make_dialog_style())
bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
_resignDialog.add_child(bg)
var margin := MarginContainer.new()
margin.set_anchors_preset(Control.PRESET_FULL_RECT)
margin.add_theme_constant_override("margin_left", 28)
margin.add_theme_constant_override("margin_top", 28)
margin.add_theme_constant_override("margin_right", 28)
margin.add_theme_constant_override("margin_bottom", 24)
_resignDialog.add_child(margin)
var box := VBoxContainer.new()
box.alignment = BoxContainer.ALIGNMENT_CENTER
box.add_theme_constant_override("separation", 18)
margin.add_child(box)
_resignLabel = Label.new()
_resignLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_resignLabel.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_resignLabel.add_theme_font_size_override("font_size", 18)
_resignLabel.add_theme_color_override("font_color", Color(0.16, 0.22, 0.28, 1))
box.add_child(_resignLabel)
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_CENTER
row.add_theme_constant_override("separation", 12)
box.add_child(row)
_resignCancelButton = _make_dialog_button("继续打工", false)
_resignCancelButton.pressed.connect(_hide_resign_dialog)
row.add_child(_resignCancelButton)
_resignConfirmButton = _make_dialog_button("确认离职", true)
_resignConfirmButton.pressed.connect(_confirm_resign)
row.add_child(_resignConfirmButton)
func _make_dialog_button(textValue: String, primary: bool) -> Button:
var button := Button.new()
button.text = textValue
button.custom_minimum_size = Vector2(128, 42)
button.focus_mode = Control.FOCUS_NONE
button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
button.add_theme_font_size_override("font_size", 16)
button.add_theme_color_override("font_color", Color.WHITE if primary else Color(0.16, 0.22, 0.28, 1))
if not primary:
button.add_theme_color_override("font_hover_color", Color(0.258824, 0.627451, 0.913725))
button.add_theme_color_override("font_pressed_color", Color(0.060, 0.270, 0.540, 1.0))
button.add_theme_color_override("font_disabled_color", Color(0.520, 0.600, 0.680, 0.55))
button.add_theme_stylebox_override("normal", _make_dialog_button_style(primary, false))
button.add_theme_stylebox_override("hover", _make_dialog_button_style(primary, true))
button.add_theme_stylebox_override("pressed", _make_dialog_button_style(primary, false))
button.add_theme_stylebox_override("focus", StyleBoxEmpty.new())
return button
func _make_dialog_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(0.982, 0.995, 1.0, 0.98)
style.border_color = Color(0.46, 0.62, 0.70, 0.28)
style.set_border_width_all(1)
style.set_corner_radius_all(16)
style.shadow_color = Color(0.06, 0.09, 0.12, 0.22)
style.shadow_size = 12
style.shadow_offset = Vector2(0, 5)
return style
func _make_dialog_button_style(primary: bool, hover: bool) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
if primary:
style.bg_color = Color(0.78, 0.26, 0.22, 1.0) if not hover else Color(0.88, 0.32, 0.26, 1.0)
else:
style.bg_color = Color(0.90, 0.96, 0.98, 1.0) if not hover else Color(0.84, 0.93, 0.97, 1.0)
style.set_corner_radius_all(10)
return style
func _format_resign_message(data: Dictionary) -> String:
var personaName := str(data.get("persona_name", "陪伴机器人")).strip_edges()
var endsAt := str(data.get("employment_ends_at", "")).strip_edges()
var message := "确认结束「%s」的咖啡店打工?" % personaName
if not endsAt.is_empty():
message += "\n原定结束时间:%s" % endsAt
message += "\n提前离职会按剩余工时扣回部分已获得收益。"
return message
func _format_resign_result(data: Dictionary) -> String:
var penaltyVariant: Variant = data.get("penalty", {})
if penaltyVariant is Dictionary:
var penalty: Dictionary = penaltyVariant
var amount := int(penalty.get("amount", 0))
if amount > 0:
return "已离职,扣回 %d 鲸币收益" % amount
return "已结束咖啡店雇佣"
func _format_target_title(data: Dictionary) -> String:
var personaName := str(data.get("persona_name", "陪伴机器人")).strip_edges()
return personaName if not personaName.is_empty() else "陪伴机器人"
func _format_target_subtitle(data: Dictionary) -> String:
var servicePointLabel := _format_service_point_label(str(data.get("service_point_id", "")))
return "鲸鱼咖啡馆 · %s" % servicePointLabel if not servicePointLabel.is_empty() else "鲸鱼咖啡馆"
func _format_service_point_label(servicePointId: String) -> String:
if servicePointId.begins_with("ServiceIdlePoint"):
var pointNumber := _parse_point_number(servicePointId)
if pointNumber > 0:
return "%s号陪伴位" % _format_chinese_number(pointNumber)
return ""
func _format_product_label(product: Dictionary) -> String:
var minutes := int(product.get("minutes", 0))
var price := int(product.get("price", 0))
return "%d分钟 · %d鲸币" % [minutes, price]
func _format_purchase_status(data: Dictionary, sessionData: Dictionary) -> String:
var price := 0
var purchaseVariant: Variant = data.get("purchase", {})
if purchaseVariant is Dictionary:
price = int((purchaseVariant as Dictionary).get("price", 0))
elif data.has("product") and data.get("product") is Dictionary:
price = int((data.get("product") as Dictionary).get("price", 0))
var remaining := _format_remaining_status(sessionData)
if price > 0:
return "已购买,花费%d鲸币,%s" % [price, remaining]
return "已购买,%s" % remaining
func _format_remaining_status(data: Dictionary) -> String:
var seconds := int(data.get("remaining_seconds", 0))
if seconds <= 0:
var expiresText := str(data.get("expires_at", "")).strip_edges()
return "陪聊时间已开启" if expiresText.is_empty() else "陪聊结束时间 %s" % expiresText
var minutes := int(ceil(seconds / 60.0))
return "剩余约 %d 分钟" % minutes
func _parse_point_number(value: String) -> int:
var digits := ""
for index in range(value.length()):
var character := value.substr(index, 1)
if character >= "0" and character <= "9":
digits += character
return int(digits) if not digits.is_empty() else 0
func _format_chinese_number(value: int) -> String:
const CHINESE_DIGITS: Array[String] = [
"", "", "", "", "", "", "", "", "", ""
]
if value <= 0:
return str(value)
if value < 10:
return CHINESE_DIGITS[value]
if value == 10:
return ""
if value < 20:
return "%s" % CHINESE_DIGITS[value - 10]
if value < 100:
var tens := value / 10
var ones := value % 10
return "%s%s" % [CHINESE_DIGITS[tens], CHINESE_DIGITS[ones]] if ones > 0 else "%s" % CHINESE_DIGITS[tens]
return str(value)

View File

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

View File

@@ -0,0 +1,219 @@
[gd_scene load_steps=9 format=3]
[ext_resource type="Script" path="res://scenes/ui/CafeCompanionPanel.gd" id="1_script"]
[sub_resource type="StyleBoxFlat" id="Style_Panel"]
bg_color = Color(0.96, 0.975, 0.982, 0.97)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.46, 0.62, 0.70, 0.28)
corner_radius_top_left = 12
corner_radius_top_right = 12
corner_radius_bottom_right = 12
corner_radius_bottom_left = 12
shadow_color = Color(0.06, 0.09, 0.12, 0.22)
shadow_size = 12
shadow_offset = Vector2(0, 5)
[sub_resource type="StyleBoxFlat" id="Style_Input"]
bg_color = Color(1, 1, 1, 0.96)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.55, 0.68, 0.76, 0.36)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
content_margin_left = 12
content_margin_top = 4
content_margin_right = 12
content_margin_bottom = 4
[sub_resource type="StyleBoxEmpty" id="Style_InputLine"]
[sub_resource type="StyleBoxFlat" id="Style_ButtonNormal"]
bg_color = Color(0.22, 0.56, 0.78, 1)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="Style_ButtonHover"]
bg_color = Color(0.29, 0.64, 0.84, 1)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="Style_ButtonPressed"]
bg_color = Color(0.16, 0.45, 0.66, 1)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="Style_Close"]
bg_color = Color(0.86, 0.91, 0.94, 0.85)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxEmpty" id="Style_Empty"]
[node name="CafeCompanionPanel" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
script = ExtResource("1_script")
[node name="CafeCompanionFrame" type="PanelContainer" parent="."]
unique_name_in_owner = true
visible = false
layout_mode = 1
anchor_left = 1.0
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = -560.0
offset_top = -442.0
offset_right = -28.0
offset_bottom = -28.0
grow_horizontal = 0
grow_vertical = 0
mouse_filter = 0
theme_override_styles/panel = SubResource("Style_Panel")
[node name="PanelMargin" type="MarginContainer" parent="CafeCompanionFrame"]
layout_mode = 2
theme_override_constants/margin_left = 18
theme_override_constants/margin_top = 16
theme_override_constants/margin_right = 18
theme_override_constants/margin_bottom = 16
[node name="ContentVBox" type="VBoxContainer" parent="CafeCompanionFrame/PanelMargin"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="HeaderRow" type="HBoxContainer" parent="CafeCompanionFrame/PanelMargin/ContentVBox"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="TitleStack" type="VBoxContainer" parent="CafeCompanionFrame/PanelMargin/ContentVBox/HeaderRow"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 2
[node name="TitleLabel" type="Label" parent="CafeCompanionFrame/PanelMargin/ContentVBox/HeaderRow/TitleStack"]
unique_name_in_owner = true
layout_mode = 2
theme_override_colors/font_color = Color(0.13, 0.21, 0.27, 1)
theme_override_font_sizes/font_size = 20
text = "陪伴机器人"
text_overrun_behavior = 3
[node name="SubtitleLabel" type="Label" parent="CafeCompanionFrame/PanelMargin/ContentVBox/HeaderRow/TitleStack"]
unique_name_in_owner = true
layout_mode = 2
theme_override_colors/font_color = Color(0.40, 0.50, 0.57, 1)
theme_override_font_sizes/font_size = 13
text = "鲸鱼咖啡馆"
text_overrun_behavior = 3
[node name="CloseButton" type="Button" parent="CafeCompanionFrame/PanelMargin/ContentVBox/HeaderRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(34, 34)
layout_mode = 2
focus_mode = 0
mouse_default_cursor_shape = 2
theme_override_colors/font_color = Color(0.20, 0.30, 0.36, 1)
theme_override_font_sizes/font_size = 18
theme_override_styles/normal = SubResource("Style_Close")
theme_override_styles/hover = SubResource("Style_Close")
theme_override_styles/pressed = SubResource("Style_Close")
theme_override_styles/focus = SubResource("Style_Empty")
text = "x"
[node name="ProductList" type="VBoxContainer" parent="CafeCompanionFrame/PanelMargin/ContentVBox"]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 6
[node name="PurchaseButton" type="Button" parent="CafeCompanionFrame/PanelMargin/ContentVBox"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 38)
layout_mode = 2
focus_mode = 0
mouse_default_cursor_shape = 2
theme_override_colors/font_color = Color(1, 1, 1, 1)
theme_override_font_sizes/font_size = 16
theme_override_styles/normal = SubResource("Style_ButtonNormal")
theme_override_styles/hover = SubResource("Style_ButtonHover")
theme_override_styles/pressed = SubResource("Style_ButtonPressed")
theme_override_styles/focus = SubResource("Style_Empty")
text = "购买聊天时间"
[node name="ChatHistory" type="ScrollContainer" parent="CafeCompanionFrame/PanelMargin/ContentVBox"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 270)
layout_mode = 2
size_flags_vertical = 3
horizontal_scroll_mode = 0
[node name="MessageList" type="VBoxContainer" parent="CafeCompanionFrame/PanelMargin/ContentVBox/ChatHistory"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 1
theme_override_constants/separation = 8
[node name="StatusLabel" type="Label" parent="CafeCompanionFrame/PanelMargin/ContentVBox"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 18)
layout_mode = 2
theme_override_colors/font_color = Color(0.48, 0.38, 0.22, 1)
theme_override_font_sizes/font_size = 13
text = ""
text_overrun_behavior = 3
[node name="InputRow" type="HBoxContainer" parent="CafeCompanionFrame/PanelMargin/ContentVBox"]
custom_minimum_size = Vector2(0, 42)
layout_mode = 2
theme_override_constants/separation = 10
[node name="InputShell" type="PanelContainer" parent="CafeCompanionFrame/PanelMargin/ContentVBox/InputRow"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_styles/panel = SubResource("Style_Input")
[node name="ChatInput" type="LineEdit" parent="CafeCompanionFrame/PanelMargin/ContentVBox/InputRow/InputShell"]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 2
placeholder_text = "购买后输入消息..."
theme_override_colors/font_color = Color(0.16, 0.21, 0.27, 1)
theme_override_colors/font_placeholder_color = Color(0.48, 0.56, 0.62, 0.78)
theme_override_font_sizes/font_size = 16
theme_override_styles/normal = SubResource("Style_InputLine")
[node name="SendButton" type="Button" parent="CafeCompanionFrame/PanelMargin/ContentVBox/InputRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(72, 40)
layout_mode = 2
focus_mode = 0
mouse_default_cursor_shape = 2
theme_override_colors/font_color = Color(1, 1, 1, 1)
theme_override_font_sizes/font_size = 16
theme_override_styles/normal = SubResource("Style_ButtonNormal")
theme_override_styles/hover = SubResource("Style_ButtonHover")
theme_override_styles/pressed = SubResource("Style_ButtonPressed")
theme_override_styles/focus = SubResource("Style_Empty")
text = "发送"

View File

@@ -0,0 +1,439 @@
extends Control
# ============================================================================
# CafeCompanionRecruitmentPanel.gd - 咖啡店陪伴机器人招聘登记面板
# ============================================================================
# 点击咖啡馆吧台鲸鱼标识后打开,玩家提交陪伴 Agent 代理配置,
# 后端登记为咖啡店陪伴服务点上的被雇佣角色。
# ============================================================================
@onready var recruitmentFrame: PanelContainer = %CafeRecruitmentFrame
@onready var titleLabel: Label = %TitleLabel
@onready var subtitleLabel: Label = %SubtitleLabel
@onready var closeButton: Button = %CloseButton
@onready var servicePointOption: OptionButton = %ServicePointOption
@onready var employmentDurationOption: OptionButton = %EmploymentDurationOption
@onready var personaNameInput: LineEdit = %PersonaNameInput
@onready var protocolOption: OptionButton = %ProtocolOption
@onready var baseUrlLabel: Label = %BaseUrlLabel
@onready var baseUrlInput: LineEdit = %BaseUrlInput
@onready var tokenInput: LineEdit = %TokenInput
@onready var modelInput: LineEdit = %ModelInput
@onready var fetchModelsButton: Button = %FetchModelsButton
@onready var modelOption: OptionButton = %ModelOption
@onready var personaPromptInput: TextEdit = %PersonaPromptInput
@onready var welcomeMessageInput: LineEdit = %WelcomeMessageInput
@onready var enabledCheckBox: CheckBox = %EnabledCheckBox
@onready var submitButton: Button = %SubmitButton
@onready var statusLabel: Label = %StatusLabel
var _servicePoints: Array = []
var _isSubmitting: bool = false
var _isFetchingModels: bool = false
func _ready() -> void:
recruitmentFrame.visible = false
_setup_protocol_options()
_setup_employment_duration_options()
closeButton.pressed.connect(_on_close_pressed)
submitButton.pressed.connect(_on_submit_pressed)
fetchModelsButton.pressed.connect(_on_fetch_models_pressed)
protocolOption.item_selected.connect(_on_protocol_selected)
modelOption.item_selected.connect(_on_model_option_selected)
_connect_events()
func _exit_tree() -> void:
var eventSystem := get_node_or_null("/root/EventSystem")
if eventSystem == null:
return
eventSystem.call("disconnect_event", EventNames.CAFE_COMPANION_RECRUITMENT_SELECTED, _on_recruitment_selected, self)
eventSystem.call("disconnect_event", EventNames.CAFE_COMPANION_SERVICE_POINTS_READY, _on_service_points_ready, self)
eventSystem.call("disconnect_event", EventNames.CAFE_COMPANION_AGENT_REGISTERED, _on_agent_registered, self)
eventSystem.call("disconnect_event", EventNames.CAFE_COMPANION_MODELS_READY, _on_models_ready, self)
eventSystem.call("disconnect_event", EventNames.CAFE_COMPANION_ERROR_OCCURRED, _on_companion_error, self)
func _connect_events() -> void:
var eventSystem := get_node_or_null("/root/EventSystem")
if eventSystem == null:
push_warning("CafeCompanionRecruitmentPanel: EventSystem autoload is not available.")
return
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_RECRUITMENT_SELECTED, _on_recruitment_selected, self)
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_SERVICE_POINTS_READY, _on_service_points_ready, self)
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_AGENT_REGISTERED, _on_agent_registered, self)
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_MODELS_READY, _on_models_ready, self)
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_ERROR_OCCURRED, _on_companion_error, self)
func _on_recruitment_selected(data: Dictionary) -> void:
titleLabel.text = str(data.get("title", "咖啡店陪伴招聘"))
subtitleLabel.text = "鲸鱼咖啡馆 · 吧台招聘终端"
recruitmentFrame.visible = true
_isSubmitting = false
_isFetchingModels = false
submitButton.disabled = false
fetchModelsButton.disabled = false
_apply_protocol_defaults(true)
_set_status("正在读取陪伴位...", false)
_request_service_points()
func _request_service_points() -> void:
var manager := _get_cafe_companion_manager()
if manager == null or not manager.has_method("get_service_points"):
_set_status("CafeCompanionManager 未加载", true)
return
manager.call("get_service_points", true)
func _on_service_points_ready(data: Dictionary) -> void:
if not recruitmentFrame.visible:
return
var servicePointsVariant: Variant = data.get("service_points", [])
_servicePoints = servicePointsVariant if servicePointsVariant is Array else []
_render_service_points()
func _on_agent_registered(data: Dictionary) -> void:
if not recruitmentFrame.visible:
return
_isSubmitting = false
submitButton.disabled = false
fetchModelsButton.disabled = false
tokenInput.clear()
var companionVariant: Variant = data.get("companion", {})
if companionVariant is Dictionary:
var companion: Dictionary = companionVariant
_set_status("%s 已登记到 %s" % [
str(companion.get("persona_name", "陪伴机器人")),
_format_service_point_label(str(companion.get("service_point_id", ""))),
], false)
else:
_set_status("咖啡店陪伴机器人登记成功", false)
recruitmentFrame.visible = false
release_focus()
func _on_companion_error(data: Dictionary) -> void:
if not recruitmentFrame.visible:
return
_isSubmitting = false
_isFetchingModels = false
submitButton.disabled = false
fetchModelsButton.disabled = false
_set_status(str(data.get("message", "咖啡店招聘暂时不可用")), true)
func _render_service_points() -> void:
servicePointOption.clear()
var selectedIndex := -1
for pointVariant in _servicePoints:
if not (pointVariant is Dictionary):
continue
var point: Dictionary = pointVariant
var pointId := str(point.get("id", "")).strip_edges()
if pointId.is_empty():
continue
var index := servicePointOption.get_item_count()
servicePointOption.add_item(_format_service_point_option(point))
servicePointOption.set_item_metadata(index, pointId)
if selectedIndex < 0 and not _point_has_companion(point):
selectedIndex = index
if servicePointOption.get_item_count() <= 0:
_set_status("当前没有开放的咖啡店陪伴位", true)
submitButton.disabled = true
return
servicePointOption.select(selectedIndex if selectedIndex >= 0 else 0)
submitButton.disabled = _isSubmitting
fetchModelsButton.disabled = _isSubmitting
_set_status("填写人设和代理配置后可登记", false)
func _format_service_point_option(point: Dictionary) -> String:
var pointId := str(point.get("id", "")).strip_edges()
var label := _format_service_point_label(pointId)
var companionVariant: Variant = point.get("companion", null)
if companionVariant is Dictionary:
var companion: Dictionary = companionVariant
var personaName := str(companion.get("persona_name", "")).strip_edges()
if not personaName.is_empty():
return "%s · 当前:%s" % [label, personaName]
return "%s · 空位" % label
func _point_has_companion(point: Dictionary) -> bool:
var companionVariant: Variant = point.get("companion", null)
return companionVariant is Dictionary and not (companionVariant as Dictionary).is_empty()
func _on_submit_pressed() -> void:
if _isSubmitting:
return
var payload := _build_payload()
if payload.is_empty():
return
var manager := _get_cafe_companion_manager()
if manager == null or not manager.has_method("register_employment_agent"):
_set_status("CafeCompanionManager 未加载", true)
return
_isSubmitting = true
submitButton.disabled = true
fetchModelsButton.disabled = true
_set_status("正在验证代理并提交雇佣登记...", false)
manager.call("register_employment_agent", payload)
func _on_fetch_models_pressed() -> void:
if _isFetchingModels or _isSubmitting:
return
var protocol := _selected_protocol()
var baseUrl := baseUrlInput.text.strip_edges()
var token := tokenInput.text.strip_edges()
if not (baseUrl.begins_with("http://") or baseUrl.begins_with("https://")):
_set_status("接口 URL 需要以 http:// 或 https:// 开头", true)
baseUrlInput.grab_focus()
return
if token.is_empty():
_set_status("请先填写接口 Token", true)
tokenInput.grab_focus()
return
var manager := _get_cafe_companion_manager()
if manager == null or not manager.has_method("get_employment_models"):
_set_status("CafeCompanionManager 未加载", true)
return
_isFetchingModels = true
fetchModelsButton.disabled = true
_set_status("正在获取接口可用模型...", false)
manager.call("get_employment_models", protocol, baseUrl, token)
func _on_models_ready(data: Dictionary) -> void:
if not recruitmentFrame.visible:
return
_isFetchingModels = false
fetchModelsButton.disabled = _isSubmitting
var modelsVariant: Variant = data.get("models", [])
var models: Array = modelsVariant if modelsVariant is Array else []
_render_models(models)
func _build_payload() -> Dictionary:
var servicePointId := _selected_service_point_id()
var personaName := personaNameInput.text.strip_edges()
var employmentMinutes := _selected_employment_minutes()
var protocol := _selected_protocol()
var baseUrl := baseUrlInput.text.strip_edges()
var token := tokenInput.text.strip_edges()
var model := _selected_model_id()
var personaPrompt := personaPromptInput.text.strip_edges()
var welcomeMessage := welcomeMessageInput.text.strip_edges()
if servicePointId.is_empty():
_set_status("请选择陪伴位", true)
return {}
if personaName.is_empty():
_set_status("请填写人设名称", true)
personaNameInput.grab_focus()
return {}
if employmentMinutes <= 0:
_set_status("请选择打工时间", true)
return {}
if not (baseUrl.begins_with("http://") or baseUrl.begins_with("https://")):
_set_status("接口 URL 需要以 http:// 或 https:// 开头", true)
baseUrlInput.grab_focus()
return {}
if token.is_empty():
_set_status("请填写接口 Token", true)
tokenInput.grab_focus()
return {}
if model.is_empty():
_set_status("请填写模型名称", true)
modelInput.grab_focus()
return {}
if personaPrompt.is_empty():
_set_status("请填写人设指令", true)
personaPromptInput.grab_focus()
return {}
var payload := {
"service_point_id": servicePointId,
"persona_name": personaName,
"employment_minutes": employmentMinutes,
"protocol": protocol,
"base_url": baseUrl,
"token": token,
"model": model,
"persona_prompt": personaPrompt,
"enabled": enabledCheckBox.button_pressed,
}
if not welcomeMessage.is_empty():
payload["welcome_message"] = welcomeMessage
return payload
func _setup_employment_duration_options() -> void:
employmentDurationOption.clear()
var durations := [
{"label": "30分钟", "minutes": 30},
{"label": "1小时", "minutes": 60},
{"label": "2小时", "minutes": 120},
{"label": "4小时", "minutes": 240},
{"label": "8小时", "minutes": 480},
]
for duration in durations:
var index := employmentDurationOption.get_item_count()
employmentDurationOption.add_item(str(duration.get("label", "")))
employmentDurationOption.set_item_metadata(index, int(duration.get("minutes", 0)))
employmentDurationOption.select(1)
func _selected_employment_minutes() -> int:
var selectedIndex := employmentDurationOption.selected
if selectedIndex < 0:
return 0
return int(employmentDurationOption.get_item_metadata(selectedIndex))
func _selected_service_point_id() -> String:
var selectedIndex := servicePointOption.selected
if selectedIndex < 0:
return ""
return str(servicePointOption.get_item_metadata(selectedIndex)).strip_edges()
func _setup_protocol_options() -> void:
protocolOption.clear()
protocolOption.add_item("OpenAI", 0)
protocolOption.set_item_metadata(0, "openai")
protocolOption.add_item("Anthropic", 1)
protocolOption.set_item_metadata(1, "anthropic")
protocolOption.select(0)
_apply_protocol_defaults(true)
func _on_protocol_selected(_index: int) -> void:
_apply_protocol_defaults(true)
_set_status("%s 协议已选择,可填写接口并获取模型" % _selected_protocol_label(), false)
func _selected_protocol() -> String:
var selectedIndex := protocolOption.selected
if selectedIndex < 0:
return "openai"
var protocol := str(protocolOption.get_item_metadata(selectedIndex)).strip_edges()
return protocol if not protocol.is_empty() else "openai"
func _selected_protocol_label() -> String:
return "Anthropic" if _selected_protocol() == "anthropic" else "OpenAI"
func _apply_protocol_defaults(clearModelList: bool) -> void:
var protocol := _selected_protocol()
if protocol == "anthropic":
baseUrlLabel.text = "Anthropic URL"
baseUrlInput.placeholder_text = "https://api.anthropic.com"
modelInput.placeholder_text = "claude-sonnet-4-5-20250929"
if modelInput.text.strip_edges().is_empty() or modelInput.text.strip_edges() == "gpt-4o-mini":
modelInput.text = "claude-sonnet-4-5-20250929"
else:
baseUrlLabel.text = "OpenAI-compatible URL"
baseUrlInput.placeholder_text = "https://api.example.com/v1"
modelInput.placeholder_text = "gpt-4o-mini"
if modelInput.text.strip_edges().is_empty() or modelInput.text.strip_edges().begins_with("claude-"):
modelInput.text = "gpt-4o-mini"
if clearModelList:
modelOption.clear()
modelOption.visible = false
func _render_models(models: Array) -> void:
modelOption.clear()
var currentModel := modelInput.text.strip_edges()
var selectedIndex := -1
for modelVariant in models:
var modelId := _model_id_from_variant(modelVariant)
if modelId.is_empty():
continue
var index := modelOption.get_item_count()
modelOption.add_item(_model_label_from_variant(modelVariant, modelId))
modelOption.set_item_metadata(index, modelId)
if modelId == currentModel:
selectedIndex = index
if modelOption.get_item_count() <= 0:
modelOption.visible = false
_set_status("接口没有返回可用模型,可继续手动填写模型", true)
return
modelOption.visible = true
modelOption.select(selectedIndex if selectedIndex >= 0 else 0)
if currentModel.is_empty() or selectedIndex < 0:
modelInput.text = str(modelOption.get_item_metadata(modelOption.selected))
_set_status("已获取 %d 个可用模型" % modelOption.get_item_count(), false)
func _on_model_option_selected(index: int) -> void:
if index < 0:
return
modelInput.text = str(modelOption.get_item_metadata(index)).strip_edges()
func _selected_model_id() -> String:
var model := modelInput.text.strip_edges()
if not model.is_empty():
return model
if modelOption.visible and modelOption.selected >= 0:
return str(modelOption.get_item_metadata(modelOption.selected)).strip_edges()
return ""
func _model_id_from_variant(modelVariant: Variant) -> String:
if modelVariant is String:
return str(modelVariant).strip_edges()
if modelVariant is Dictionary:
var model: Dictionary = modelVariant
return str(model.get("id", "")).strip_edges()
return ""
func _model_label_from_variant(modelVariant: Variant, modelId: String) -> String:
if not (modelVariant is Dictionary):
return modelId
var model: Dictionary = modelVariant
var label := str(model.get("label", modelId)).strip_edges()
var ownedBy := str(model.get("owned_by", "")).strip_edges()
if not ownedBy.is_empty():
return "%s · %s" % [label, ownedBy]
return label
func _on_close_pressed() -> void:
recruitmentFrame.visible = false
_isSubmitting = false
_isFetchingModels = false
submitButton.disabled = false
fetchModelsButton.disabled = false
tokenInput.clear()
func _set_status(message: String, isError: bool) -> void:
statusLabel.text = message
statusLabel.add_theme_color_override("font_color", Color(0.68, 0.22, 0.18, 1) if isError else Color(0.32, 0.42, 0.48, 1))
func _get_cafe_companion_manager() -> Node:
return get_node_or_null("/root/CafeCompanionManager")
func _format_service_point_label(servicePointId: String) -> String:
if servicePointId.begins_with("ServiceIdlePoint"):
var pointNumber := _parse_point_number(servicePointId)
if pointNumber > 0:
return "%s号陪伴位" % _format_chinese_number(pointNumber)
return servicePointId
func _parse_point_number(value: String) -> int:
var digits := ""
for index in range(value.length()):
var character := value.substr(index, 1)
if character >= "0" and character <= "9":
digits += character
return int(digits) if not digits.is_empty() else 0
func _format_chinese_number(value: int) -> String:
const CHINESE_DIGITS: Array[String] = [
"", "", "", "", "", "", "", "", "", ""
]
if value <= 0:
return str(value)
if value < 10:
return CHINESE_DIGITS[value]
if value == 10:
return ""
if value < 20:
return "%s" % CHINESE_DIGITS[value - 10]
if value < 100:
var tens := value / 10
var ones := value % 10
return "%s%s" % [CHINESE_DIGITS[tens], CHINESE_DIGITS[ones]] if ones > 0 else "%s" % CHINESE_DIGITS[tens]
return str(value)

View File

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

View File

@@ -0,0 +1,357 @@
[gd_scene load_steps=9 format=3]
[ext_resource type="Script" path="res://scenes/ui/CafeCompanionRecruitmentPanel.gd" id="1_script"]
[sub_resource type="StyleBoxFlat" id="Style_Panel"]
bg_color = Color(0.96, 0.975, 0.982, 0.98)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.46, 0.62, 0.70, 0.28)
corner_radius_top_left = 12
corner_radius_top_right = 12
corner_radius_bottom_right = 12
corner_radius_bottom_left = 12
shadow_color = Color(0.06, 0.09, 0.12, 0.22)
shadow_size = 12
shadow_offset = Vector2(0, 5)
[sub_resource type="StyleBoxFlat" id="Style_Input"]
bg_color = Color(1, 1, 1, 0.96)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.55, 0.68, 0.76, 0.36)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
content_margin_left = 12
content_margin_top = 6
content_margin_right = 12
content_margin_bottom = 6
[sub_resource type="StyleBoxEmpty" id="Style_InputLine"]
[sub_resource type="StyleBoxFlat" id="Style_ButtonNormal"]
bg_color = Color(0.22, 0.56, 0.78, 1)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="Style_ButtonHover"]
bg_color = Color(0.29, 0.64, 0.84, 1)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="Style_ButtonPressed"]
bg_color = Color(0.16, 0.45, 0.66, 1)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="Style_Close"]
bg_color = Color(0.86, 0.91, 0.94, 0.85)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxEmpty" id="Style_Empty"]
[node name="CafeCompanionRecruitmentPanel" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
script = ExtResource("1_script")
[node name="CafeRecruitmentFrame" type="PanelContainer" parent="."]
unique_name_in_owner = true
visible = false
layout_mode = 1
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -320.0
offset_top = -360.0
offset_right = 320.0
offset_bottom = 360.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 0
theme_override_styles/panel = SubResource("Style_Panel")
[node name="PanelMargin" type="MarginContainer" parent="CafeRecruitmentFrame"]
layout_mode = 2
theme_override_constants/margin_left = 20
theme_override_constants/margin_top = 14
theme_override_constants/margin_right = 20
theme_override_constants/margin_bottom = 14
[node name="ContentVBox" type="VBoxContainer" parent="CafeRecruitmentFrame/PanelMargin"]
layout_mode = 2
theme_override_constants/separation = 6
[node name="HeaderRow" type="HBoxContainer" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="TitleStack" type="VBoxContainer" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox/HeaderRow"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 2
[node name="TitleLabel" type="Label" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox/HeaderRow/TitleStack"]
unique_name_in_owner = true
layout_mode = 2
theme_override_colors/font_color = Color(0.13, 0.21, 0.27, 1)
theme_override_font_sizes/font_size = 20
text = "咖啡店陪伴招聘"
text_overrun_behavior = 3
[node name="SubtitleLabel" type="Label" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox/HeaderRow/TitleStack"]
unique_name_in_owner = true
layout_mode = 2
theme_override_colors/font_color = Color(0.40, 0.50, 0.57, 1)
theme_override_font_sizes/font_size = 13
text = "鲸鱼咖啡馆 · 吧台招聘终端"
text_overrun_behavior = 3
[node name="CloseButton" type="Button" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox/HeaderRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(34, 34)
layout_mode = 2
focus_mode = 0
mouse_default_cursor_shape = 2
theme_override_colors/font_color = Color(0.20, 0.30, 0.36, 1)
theme_override_font_sizes/font_size = 18
theme_override_styles/normal = SubResource("Style_Close")
theme_override_styles/hover = SubResource("Style_Close")
theme_override_styles/pressed = SubResource("Style_Close")
theme_override_styles/focus = SubResource("Style_Empty")
text = "x"
[node name="ServicePointLabel" type="Label" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
layout_mode = 2
theme_override_colors/font_color = Color(0.18, 0.26, 0.32, 1)
theme_override_font_sizes/font_size = 13
text = "陪伴位"
[node name="ServicePointOption" type="OptionButton" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 34)
layout_mode = 2
focus_mode = 0
mouse_default_cursor_shape = 2
theme_override_font_sizes/font_size = 15
[node name="EmploymentDurationLabel" type="Label" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
layout_mode = 2
theme_override_colors/font_color = Color(0.18, 0.26, 0.32, 1)
theme_override_font_sizes/font_size = 13
text = "打工时间"
[node name="EmploymentDurationOption" type="OptionButton" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 34)
layout_mode = 2
focus_mode = 0
mouse_default_cursor_shape = 2
theme_override_font_sizes/font_size = 15
[node name="PersonaNameLabel" type="Label" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
layout_mode = 2
theme_override_colors/font_color = Color(0.18, 0.26, 0.32, 1)
theme_override_font_sizes/font_size = 13
text = "人设名称"
[node name="PersonaNameInput" type="LineEdit" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 34)
layout_mode = 2
placeholder_text = "例如:蓝莓摩卡"
caret_blink = true
theme_override_colors/font_color = Color(0.16, 0.21, 0.27, 1)
theme_override_colors/font_placeholder_color = Color(0.48, 0.56, 0.62, 0.78)
theme_override_font_sizes/font_size = 15
theme_override_styles/normal = SubResource("Style_Input")
theme_override_styles/focus = SubResource("Style_Input")
[node name="ProtocolLabel" type="Label" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
layout_mode = 2
theme_override_colors/font_color = Color(0.18, 0.26, 0.32, 1)
theme_override_font_sizes/font_size = 13
text = "协议"
[node name="ProtocolOption" type="OptionButton" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 34)
layout_mode = 2
focus_mode = 0
mouse_default_cursor_shape = 2
theme_override_font_sizes/font_size = 15
[node name="BaseUrlLabel" type="Label" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
unique_name_in_owner = true
layout_mode = 2
theme_override_colors/font_color = Color(0.18, 0.26, 0.32, 1)
theme_override_font_sizes/font_size = 13
text = "OpenAI-compatible URL"
[node name="BaseUrlInput" type="LineEdit" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 34)
layout_mode = 2
placeholder_text = "https://api.example.com/v1"
caret_blink = true
theme_override_colors/font_color = Color(0.16, 0.21, 0.27, 1)
theme_override_colors/font_placeholder_color = Color(0.48, 0.56, 0.62, 0.78)
theme_override_font_sizes/font_size = 15
theme_override_styles/normal = SubResource("Style_Input")
theme_override_styles/focus = SubResource("Style_Input")
[node name="TokenLabel" type="Label" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
layout_mode = 2
theme_override_colors/font_color = Color(0.18, 0.26, 0.32, 1)
theme_override_font_sizes/font_size = 13
text = "Token"
[node name="TokenInput" type="LineEdit" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 34)
layout_mode = 2
placeholder_text = "只提交给后端保存"
secret = true
caret_blink = true
theme_override_colors/font_color = Color(0.16, 0.21, 0.27, 1)
theme_override_colors/font_placeholder_color = Color(0.48, 0.56, 0.62, 0.78)
theme_override_font_sizes/font_size = 15
theme_override_styles/normal = SubResource("Style_Input")
theme_override_styles/focus = SubResource("Style_Input")
[node name="ModelLabel" type="Label" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
layout_mode = 2
theme_override_colors/font_color = Color(0.18, 0.26, 0.32, 1)
theme_override_font_sizes/font_size = 13
text = "模型"
[node name="ModelRow" type="HBoxContainer" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="ModelInput" type="LineEdit" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox/ModelRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 34)
layout_mode = 2
size_flags_horizontal = 3
text = "gpt-4o-mini"
placeholder_text = "gpt-4o-mini"
caret_blink = true
theme_override_colors/font_color = Color(0.16, 0.21, 0.27, 1)
theme_override_colors/font_placeholder_color = Color(0.48, 0.56, 0.62, 0.78)
theme_override_font_sizes/font_size = 15
theme_override_styles/normal = SubResource("Style_Input")
theme_override_styles/focus = SubResource("Style_Input")
[node name="FetchModelsButton" type="Button" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox/ModelRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(104, 34)
layout_mode = 2
focus_mode = 0
mouse_default_cursor_shape = 2
theme_override_colors/font_color = Color(1, 1, 1, 1)
theme_override_font_sizes/font_size = 14
theme_override_styles/normal = SubResource("Style_ButtonNormal")
theme_override_styles/hover = SubResource("Style_ButtonHover")
theme_override_styles/pressed = SubResource("Style_ButtonPressed")
theme_override_styles/focus = SubResource("Style_Empty")
text = "获取模型"
[node name="ModelOption" type="OptionButton" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
unique_name_in_owner = true
visible = false
custom_minimum_size = Vector2(0, 34)
layout_mode = 2
focus_mode = 0
mouse_default_cursor_shape = 2
theme_override_font_sizes/font_size = 14
[node name="PersonaPromptLabel" type="Label" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
layout_mode = 2
theme_override_colors/font_color = Color(0.18, 0.26, 0.32, 1)
theme_override_font_sizes/font_size = 13
text = "人设指令"
[node name="PersonaPromptInput" type="TextEdit" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 76)
layout_mode = 2
size_flags_vertical = 3
placeholder_text = "写清楚角色性格、语气、边界和陪伴方式"
wrap_mode = 1
caret_blink = true
theme_override_colors/font_color = Color(0.16, 0.21, 0.27, 1)
theme_override_colors/font_placeholder_color = Color(0.48, 0.56, 0.62, 0.78)
theme_override_font_sizes/font_size = 15
theme_override_styles/normal = SubResource("Style_Input")
theme_override_styles/focus = SubResource("Style_Input")
[node name="WelcomeMessageLabel" type="Label" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
layout_mode = 2
theme_override_colors/font_color = Color(0.18, 0.26, 0.32, 1)
theme_override_font_sizes/font_size = 13
text = "欢迎语"
[node name="WelcomeMessageInput" type="LineEdit" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 34)
layout_mode = 2
placeholder_text = "可选"
caret_blink = true
theme_override_colors/font_color = Color(0.16, 0.21, 0.27, 1)
theme_override_colors/font_placeholder_color = Color(0.48, 0.56, 0.62, 0.78)
theme_override_font_sizes/font_size = 15
theme_override_styles/normal = SubResource("Style_Input")
theme_override_styles/focus = SubResource("Style_Input")
[node name="EnabledCheckBox" type="CheckBox" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
unique_name_in_owner = true
layout_mode = 2
button_pressed = true
theme_override_colors/font_color = Color(0.18, 0.26, 0.32, 1)
theme_override_font_sizes/font_size = 14
text = "立即开始接待"
[node name="SubmitButton" type="Button" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 38)
layout_mode = 2
focus_mode = 0
mouse_default_cursor_shape = 2
theme_override_colors/font_color = Color(1, 1, 1, 1)
theme_override_font_sizes/font_size = 16
theme_override_styles/normal = SubResource("Style_ButtonNormal")
theme_override_styles/hover = SubResource("Style_ButtonHover")
theme_override_styles/pressed = SubResource("Style_ButtonPressed")
theme_override_styles/focus = SubResource("Style_Empty")
text = "提交雇佣登记"
[node name="StatusLabel" type="Label" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 18)
layout_mode = 2
theme_override_colors/font_color = Color(0.32, 0.42, 0.48, 1)
theme_override_font_sizes/font_size = 13
text = ""
text_overrun_behavior = 3

130
scenes/ui/ChatBubble.gd Normal file
View File

@@ -0,0 +1,130 @@
extends Control
const DEFAULT_DURATION: float = 5.0
const TARGET_OFFSET: Vector2 = Vector2(0, -84)
const TARGET_TOP_GAP: float = 10.0
const FALLBACK_TARGET_HEIGHT: float = 64.0
const MIN_TEXT_WIDTH: float = 96.0
const MAX_TEXT_WIDTH: float = 230.0
const TEXT_FONT_SIZE: float = 18.0
const LINE_HEIGHT: float = 24.0
@onready var label: Label = $PanelContainer/Label
@onready var panel: PanelContainer = $PanelContainer
var _targetNode: Node2D
var _targetOffset: Vector2 = TARGET_OFFSET
var _originalText: String = ""
func _process(_delta: float) -> void:
if _targetNode == null or not is_instance_valid(_targetNode):
return
_update_position()
func set_text(text: String, targetNode: Node2D = null, targetOffset: Vector2 = TARGET_OFFSET) -> void:
_originalText = text
_targetNode = targetNode
_targetOffset = targetOffset
_update_size()
if _targetNode != null:
_update_position()
await get_tree().create_timer(DEFAULT_DURATION).timeout
queue_free()
func _update_size() -> void:
if panel == null:
return
var metrics := _get_text_metrics(_originalText)
label.text = str(metrics.get("display_text", _originalText))
label.autowrap_mode = TextServer.AUTOWRAP_OFF
label.custom_minimum_size = Vector2(float(metrics.get("width", MIN_TEXT_WIDTH)), float(metrics.get("height", LINE_HEIGHT)))
label.size = label.custom_minimum_size
panel.reset_size()
reset_size()
panel.size = panel.get_combined_minimum_size()
size = panel.size
func _update_position() -> void:
var anchor := _get_target_top_screen_position()
var bubbleSize := _get_bubble_size()
global_position = Vector2(
anchor.x - bubbleSize.x * 0.5 + _targetOffset.x,
anchor.y - bubbleSize.y - TARGET_TOP_GAP
).round()
func _get_bubble_size() -> Vector2:
if panel != null:
var combined := panel.get_combined_minimum_size()
if combined.x > 0.0 and combined.y > 0.0:
return combined
if size.x > 0.0 and size.y > 0.0:
return size
return Vector2(280.0, 52.0)
func _get_text_metrics(text: String) -> Dictionary:
var lines: Array[String] = []
var currentLine := ""
var currentWidth := 0.0
var maxLineWidth := 0.0
for index in range(text.length()):
var character := text.substr(index, 1)
var characterWidth := _measure_text_width(character)
if not currentLine.is_empty() and currentWidth + characterWidth > MAX_TEXT_WIDTH:
lines.append(currentLine)
maxLineWidth = max(maxLineWidth, currentWidth)
currentLine = character
currentWidth = characterWidth
else:
currentLine += character
currentWidth += characterWidth
if not currentLine.is_empty() or lines.is_empty():
lines.append(currentLine)
maxLineWidth = max(maxLineWidth, currentWidth)
return {
"display_text": "\n".join(lines),
"width": clamp(maxLineWidth + 4.0, MIN_TEXT_WIDTH, MAX_TEXT_WIDTH),
"height": LINE_HEIGHT * float(lines.size())
}
func _measure_text_width(text: String) -> 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(TEXT_FONT_SIZE)
var measured := font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1.0, fontSize).x
if measured > 0.0:
return measured
var estimated := 0.0
for index in range(text.length()):
var code := text.unicode_at(index)
estimated += TEXT_FONT_SIZE * 0.56 if code <= 0x7f else TEXT_FONT_SIZE
return estimated
func _get_target_top_screen_position() -> Vector2:
var targetScreenPosition: Vector2 = _targetNode.get_global_transform_with_canvas().origin
var visualTopOffset := _get_target_visual_top_offset()
return targetScreenPosition + visualTopOffset
func _get_target_visual_top_offset() -> Vector2:
var topOffset := Vector2(0.0, -FALLBACK_TARGET_HEIGHT)
if _targetNode == null:
return topOffset
var sprite := _targetNode.get_node_or_null("Sprite2D") as Sprite2D
if sprite == null:
return topOffset
var rect := sprite.get_rect()
var localTop := sprite.position + Vector2(0.0, rect.position.y * sprite.scale.y)
var nodeToCanvas := _targetNode.get_global_transform_with_canvas()
var topScreenPosition := nodeToCanvas * localTop
var targetScreenPosition: Vector2 = nodeToCanvas.origin
return topScreenPosition - targetScreenPosition

View File

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

41
scenes/ui/ChatBubble.tscn Normal file
View File

@@ -0,0 +1,41 @@
[gd_scene load_steps=4 format=3]
[ext_resource type="Script" path="res://scenes/ui/ChatBubble.gd" id="1_script"]
[ext_resource type="Theme" path="res://assets/ui/world_text_theme.tres" id="2_theme"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_bubble_modern"]
bg_color = Color(1, 1, 1, 0.94)
corner_radius_top_left = 6
corner_radius_top_right = 6
corner_radius_bottom_right = 6
corner_radius_bottom_left = 6
content_margin_left = 14.0
content_margin_top = 9.0
content_margin_right = 14.0
content_margin_bottom = 9.0
shadow_color = Color(0, 0, 0, 0.2)
shadow_size = 2
[node name="ChatBubble" type="Control"]
layout_mode = 3
anchors_preset = 0
grow_horizontal = 2
grow_vertical = 0
script = ExtResource("1_script")
theme = ExtResource("2_theme")
mouse_filter = 2
[node name="PanelContainer" type="PanelContainer" parent="."]
layout_mode = 0
theme_override_styles/panel = SubResource("StyleBoxFlat_bubble_modern")
mouse_filter = 2
[node name="Label" type="Label" parent="PanelContainer"]
layout_mode = 2
theme_override_colors/font_color = Color(0.1, 0.1, 0.1, 1)
theme_override_font_sizes/font_size = 18
text = "..."
horizontal_alignment = 1
vertical_alignment = 1
autowrap_mode = 3
mouse_filter = 2

1071
scenes/ui/ChatUI.gd Normal file

File diff suppressed because it is too large Load Diff

1
scenes/ui/ChatUI.gd.uid Normal file
View File

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

350
scenes/ui/ChatUI.tscn Normal file
View File

@@ -0,0 +1,350 @@
[gd_scene load_steps=15 format=3 uid="uid://bv7k2nan4xj8q"]
[ext_resource type="Script" path="res://scenes/ui/ChatUI.gd" id="1"]
[ext_resource type="Script" path="res://scenes/ui/BubbleSendButton.gd" id="2"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_panel"]
bg_color = Color(1, 0.996, 0.984, 0.97)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.78, 0.86, 0.93, 0.42)
corner_radius_top_left = 26
corner_radius_top_right = 26
corner_radius_bottom_right = 26
corner_radius_bottom_left = 26
shadow_color = Color(0.12549, 0.282353, 0.407843, 0.18)
shadow_size = 18
shadow_offset = Vector2(0, 7)
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_tab"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_input"]
bg_color = Color(1, 1, 1, 0.9)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.76, 0.85, 0.93, 0.9)
corner_radius_top_left = 18
corner_radius_top_right = 18
corner_radius_bottom_right = 18
corner_radius_bottom_left = 18
content_margin_left = 16
content_margin_top = 6
content_margin_right = 16
content_margin_bottom = 6
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_input_line"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_friend_button"]
bg_color = Color(0.918, 0.961, 0.992, 0.96)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.73, 0.84, 0.93, 0.72)
corner_radius_top_left = 14
corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_bubble_normal"]
bg_color = Color(1, 1, 1, 0.94)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.72, 0.84, 0.93, 0.86)
corner_radius_top_left = 16
corner_radius_top_right = 16
corner_radius_bottom_right = 16
corner_radius_bottom_left = 16
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_bubble_hover"]
bg_color = Color(0.918, 0.961, 0.992, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.392, 0.718, 0.949, 0.8)
corner_radius_top_left = 16
corner_radius_top_right = 16
corner_radius_bottom_right = 16
corner_radius_bottom_left = 16
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_bubble_pressed"]
bg_color = Color(0.858, 0.925, 0.98, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.31, 0.64, 0.89, 0.86)
corner_radius_top_left = 16
corner_radius_top_right = 16
corner_radius_bottom_right = 16
corner_radius_bottom_left = 16
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_send_normal"]
bg_color = Color(0.258824, 0.627451, 0.913725, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.56, 0.8, 0.98, 0.72)
corner_radius_top_left = 16
corner_radius_top_right = 16
corner_radius_bottom_right = 16
corner_radius_bottom_left = 16
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_send_hover"]
bg_color = Color(0.345, 0.69, 0.945, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.68, 0.87, 1, 0.9)
corner_radius_top_left = 16
corner_radius_top_right = 16
corner_radius_bottom_right = 16
corner_radius_bottom_left = 16
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_send_pressed"]
bg_color = Color(0.196, 0.52, 0.82, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.46, 0.72, 0.94, 0.9)
corner_radius_top_left = 16
corner_radius_top_right = 16
corner_radius_bottom_right = 16
corner_radius_bottom_left = 16
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_send_focus"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_friend_header"]
bg_color = Color(0.925, 0.965, 1, 0.96)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.77, 0.87, 0.95, 0.7)
corner_radius_top_left = 16
corner_radius_top_right = 16
corner_radius_bottom_right = 16
corner_radius_bottom_left = 16
[node name="ChatUI" type="Control"]
unique_name_in_owner = true
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
script = ExtResource("1")
[node name="ChatPanel" type="PanelContainer" parent="."]
unique_name_in_owner = true
visible = false
layout_mode = 1
anchor_left = 0.024
anchor_top = 1.0
anchor_right = 0.024
anchor_bottom = 1.0
offset_top = -448.0
offset_right = 548.0
offset_bottom = -32.0
grow_vertical = 0
theme_override_styles/panel = SubResource("StyleBoxFlat_panel")
[node name="PanelMargin" type="MarginContainer" parent="ChatPanel"]
layout_mode = 2
theme_override_constants/margin_left = 20
theme_override_constants/margin_top = 18
theme_override_constants/margin_right = 20
theme_override_constants/margin_bottom = 18
[node name="ContentVBox" type="VBoxContainer" parent="ChatPanel/PanelMargin"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="Tabs" type="HBoxContainer" parent="ChatPanel/PanelMargin/ContentVBox"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="PopularTab" type="VBoxContainer" parent="ChatPanel/PanelMargin/ContentVBox/Tabs"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 4
[node name="PopularTabButton" type="Button" parent="ChatPanel/PanelMargin/ContentVBox/Tabs/PopularTab"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 38)
layout_mode = 2
focus_mode = 0
mouse_default_cursor_shape = 2
theme_override_colors/font_color = Color(0.258824, 0.627451, 0.913725, 1)
theme_override_font_sizes/font_size = 15
theme_override_styles/normal = SubResource("StyleBoxEmpty_tab")
theme_override_styles/hover = SubResource("StyleBoxEmpty_tab")
theme_override_styles/pressed = SubResource("StyleBoxEmpty_tab")
theme_override_styles/focus = SubResource("StyleBoxEmpty_tab")
text = "世界频道"
[node name="Underline" type="ColorRect" parent="ChatPanel/PanelMargin/ContentVBox/Tabs/PopularTab"]
custom_minimum_size = Vector2(0, 2)
layout_mode = 2
color = Color(0.258824, 0.627451, 0.913725, 1)
[node name="RecentTab" type="VBoxContainer" parent="ChatPanel/PanelMargin/ContentVBox/Tabs"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 4
[node name="RecentTabButton" type="Button" parent="ChatPanel/PanelMargin/ContentVBox/Tabs/RecentTab"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 38)
layout_mode = 2
focus_mode = 0
mouse_default_cursor_shape = 2
theme_override_colors/font_color = Color(0.560784, 0.639216, 0.733333, 1)
theme_override_font_sizes/font_size = 15
theme_override_styles/normal = SubResource("StyleBoxEmpty_tab")
theme_override_styles/hover = SubResource("StyleBoxEmpty_tab")
theme_override_styles/pressed = SubResource("StyleBoxEmpty_tab")
theme_override_styles/focus = SubResource("StyleBoxEmpty_tab")
text = "悄悄话"
[node name="Underline" type="ColorRect" parent="ChatPanel/PanelMargin/ContentVBox/Tabs/RecentTab"]
custom_minimum_size = Vector2(0, 2)
layout_mode = 2
color = Color(1, 1, 1, 0)
[node name="FriendsTab" type="VBoxContainer" parent="ChatPanel/PanelMargin/ContentVBox/Tabs"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 4
[node name="FriendsTabButton" type="Button" parent="ChatPanel/PanelMargin/ContentVBox/Tabs/FriendsTab"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 38)
layout_mode = 2
focus_mode = 0
mouse_default_cursor_shape = 2
theme_override_colors/font_color = Color(0.560784, 0.639216, 0.733333, 1)
theme_override_font_sizes/font_size = 15
theme_override_styles/normal = SubResource("StyleBoxEmpty_tab")
theme_override_styles/hover = SubResource("StyleBoxEmpty_tab")
theme_override_styles/pressed = SubResource("StyleBoxEmpty_tab")
theme_override_styles/focus = SubResource("StyleBoxEmpty_tab")
text = "好友"
[node name="Underline" type="ColorRect" parent="ChatPanel/PanelMargin/ContentVBox/Tabs/FriendsTab"]
custom_minimum_size = Vector2(0, 2)
layout_mode = 2
color = Color(1, 1, 1, 0)
[node name="ChatHistory" type="ScrollContainer" parent="ChatPanel/PanelMargin/ContentVBox"]
unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 3
horizontal_scroll_mode = 0
[node name="MessageList" type="VBoxContainer" parent="ChatPanel/PanelMargin/ContentVBox/ChatHistory"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 1
theme_override_constants/separation = 10
[node name="FriendsListSurface" type="PanelContainer" parent="ChatPanel/PanelMargin/ContentVBox"]
unique_name_in_owner = true
visible = false
custom_minimum_size = Vector2(0, 50)
layout_mode = 2
theme_override_styles/panel = SubResource("StyleBoxFlat_friend_header")
[node name="FriendsList" type="HBoxContainer" parent="ChatPanel/PanelMargin/ContentVBox/FriendsListSurface"]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 10
[node name="AddFriendRow" type="HBoxContainer" parent="ChatPanel/PanelMargin/ContentVBox"]
unique_name_in_owner = true
visible = false
custom_minimum_size = Vector2(0, 36)
layout_mode = 2
theme_override_constants/separation = 8
alignment = 2
[node name="AddFriendButton" type="Button" parent="ChatPanel/PanelMargin/ContentVBox/AddFriendRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(34, 34)
layout_mode = 2
focus_mode = 0
tooltip_text = "添加好友"
theme_override_colors/font_color = Color(0.258824, 0.627451, 0.913725, 1)
theme_override_font_sizes/font_size = 17
theme_override_styles/normal = SubResource("StyleBoxFlat_friend_button")
theme_override_styles/hover = SubResource("StyleBoxFlat_friend_button")
theme_override_styles/pressed = SubResource("StyleBoxFlat_friend_button")
theme_override_styles/focus = SubResource("StyleBoxEmpty_send_focus")
text = "+"
[node name="InputRow" type="HBoxContainer" parent="ChatPanel/PanelMargin/ContentVBox"]
custom_minimum_size = Vector2(0, 50)
layout_mode = 2
theme_override_constants/separation = 8
alignment = 1
[node name="InputShell" type="PanelContainer" parent="ChatPanel/PanelMargin/ContentVBox/InputRow"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
mouse_filter = 1
theme_override_styles/panel = SubResource("StyleBoxFlat_input")
[node name="ChatInput" type="LineEdit" parent="ChatPanel/PanelMargin/ContentVBox/InputRow/InputShell"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
focus_mode = 2
placeholder_text = "输入消息..."
localize_numeral_system = false
theme_override_colors/font_color = Color(0.188, 0.294, 0.424, 1)
theme_override_colors/font_placeholder_color = Color(0.560784, 0.639216, 0.733333, 0.88)
theme_override_font_sizes/font_size = 16
theme_override_styles/normal = SubResource("StyleBoxEmpty_input_line")
theme_override_styles/read_only = SubResource("StyleBoxEmpty_input_line")
theme_override_styles/focus = SubResource("StyleBoxEmpty_input_line")
[node name="BubbleSendButton" type="Button" parent="ChatPanel/PanelMargin/ContentVBox/InputRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(48, 48)
layout_mode = 2
focus_mode = 0
tooltip_text = "发送并弹出气泡"
theme_override_colors/font_color = Color(0.207843, 0.427451, 0.686275, 1)
theme_override_font_sizes/font_size = 18
theme_override_styles/normal = SubResource("StyleBoxFlat_bubble_normal")
theme_override_styles/hover = SubResource("StyleBoxFlat_bubble_hover")
theme_override_styles/pressed = SubResource("StyleBoxFlat_bubble_pressed")
theme_override_styles/focus = SubResource("StyleBoxEmpty_send_focus")
script = ExtResource("2")
[node name="SendButton" type="Button" parent="ChatPanel/PanelMargin/ContentVBox/InputRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(48, 48)
layout_mode = 2
focus_mode = 0
theme_override_colors/font_color = Color(1, 1, 1, 1)
theme_override_font_sizes/font_size = 22
theme_override_styles/normal = SubResource("StyleBoxFlat_send_normal")
theme_override_styles/hover = SubResource("StyleBoxFlat_send_hover")
theme_override_styles/pressed = SubResource("StyleBoxFlat_send_pressed")
theme_override_styles/focus = SubResource("StyleBoxEmpty_send_focus")
text = "➤"

View File

@@ -0,0 +1,516 @@
class_name CourseBoardPanel
extends Control
# ============================================================================
# CourseBoardPanel.gd - Datawhale 课程看板
# ============================================================================
# 展示 Datawhale 学习中心课程列表,由打工区 VirtualWhaleRecruitmentBoard 打开。
# ============================================================================
const MOVEMENT_ACTIONS: Array[String] = ["move_left", "move_right", "move_up", "move_down"]
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
const API_PATH: String = "/course-resources/datawhale"
const PANEL_SIZE: Vector2 = Vector2(1500, 1020)
const MIN_MARGIN: Vector2 = Vector2(24, 24)
const CARD_SIZE: Vector2 = Vector2(320, 348)
const COVER_HEIGHT: float = 171.0
const TEXT_COLOR: Color = Color(0.098, 0.140, 0.190)
const MUTED_COLOR: Color = Color(0.380, 0.450, 0.540)
const ACCENT_COLOR: Color = Color(0.000, 0.322, 0.851)
const SOFT_BLUE: Color = Color(0.925, 0.965, 1.0, 1.0)
const IMAGE_PLACEHOLDER: Color = Color(1.0, 0.965, 0.870, 1.0)
var _overlay: ColorRect
var _panel: Control
var _grid: GridContainer
var _statusLabel: Label
var _request: HTTPRequest
var _isOpen: bool = false
var _transitionTween: Tween
func _ready() -> void:
visible = false
mouse_filter = Control.MOUSE_FILTER_IGNORE
set_process(false)
_buildUi()
func _notification(what: int) -> void:
if what == NOTIFICATION_RESIZED and is_instance_valid(_panel):
_positionPanel()
func _input(event: InputEvent) -> void:
if not _isOpen:
return
if event is InputEventKey:
var keyEvent := event as InputEventKey
if keyEvent.pressed and not keyEvent.echo and keyEvent.keycode == KEY_ESCAPE:
hide_panel()
get_viewport().set_input_as_handled()
func _process(_delta: float) -> void:
if _isOpen:
_releaseMovementActions()
func show_panel() -> void:
if _isOpen:
return
_isOpen = true
visible = true
mouse_filter = Control.MOUSE_FILTER_STOP
set_process(true)
_releaseMovementInputState()
_fetchCourses()
_animatePanel(true)
func hide_panel() -> void:
if not _isOpen:
return
_isOpen = false
_releaseMovementInputState()
_animatePanel(false)
func is_panel_open() -> bool:
return _isOpen
func _buildUi() -> void:
_overlay = ColorRect.new()
_overlay.name = "courseBoardDimOverlay"
_overlay.set_anchors_preset(Control.PRESET_FULL_RECT)
_overlay.color = Color(0.020, 0.036, 0.060, 0.64)
_overlay.mouse_filter = Control.MOUSE_FILTER_STOP
_overlay.gui_input.connect(_onOverlayGuiInput)
add_child(_overlay)
_panel = Control.new()
_panel.name = "courseBoardPanel"
_panel.custom_minimum_size = PANEL_SIZE
_panel.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(_panel)
_positionPanel()
var frame := PanelContainer.new()
frame.set_anchors_preset(Control.PRESET_FULL_RECT)
frame.add_theme_stylebox_override("panel", _createPanelStyle(Color(0.972, 0.988, 1.0, 1.0), 26, true))
frame.mouse_filter = Control.MOUSE_FILTER_IGNORE
_panel.add_child(frame)
var margin := MarginContainer.new()
margin.set_anchors_preset(Control.PRESET_FULL_RECT)
margin.add_theme_constant_override("margin_left", 34)
margin.add_theme_constant_override("margin_top", 28)
margin.add_theme_constant_override("margin_right", 34)
margin.add_theme_constant_override("margin_bottom", 28)
_panel.add_child(margin)
var root := VBoxContainer.new()
root.add_theme_constant_override("separation", 18)
margin.add_child(root)
root.add_child(_buildHeader())
root.add_child(_buildGridPanel())
root.add_child(_buildFooter())
_request = HTTPRequest.new()
_request.timeout = 18.0
_request.request_completed.connect(_onRequestCompleted)
add_child(_request)
_updatePanelAlpha(0.0, 0.0)
func _buildHeader() -> Control:
var header := PanelContainer.new()
header.custom_minimum_size = Vector2(0, 106)
header.add_theme_stylebox_override("panel", _createPanelStyle(Color(0.000, 0.322, 0.851, 0.96), 24, false))
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 28)
margin.add_theme_constant_override("margin_top", 12)
margin.add_theme_constant_override("margin_right", 20)
margin.add_theme_constant_override("margin_bottom", 12)
header.add_child(margin)
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_CENTER
row.add_theme_constant_override("separation", 16)
margin.add_child(row)
var titleStack := VBoxContainer.new()
titleStack.size_flags_horizontal = Control.SIZE_EXPAND_FILL
titleStack.add_theme_constant_override("separation", 4)
row.add_child(titleStack)
var title := Label.new()
title.text = "Datawhale 课程看板"
title.add_theme_font_size_override("font_size", 32)
title.add_theme_color_override("font_color", Color.WHITE)
titleStack.add_child(title)
var subtitle := Label.new()
subtitle.text = "AI学习中心最新课程"
subtitle.add_theme_font_size_override("font_size", 18)
subtitle.add_theme_color_override("font_color", Color(0.835, 0.925, 1.0, 1.0))
titleStack.add_child(subtitle)
var closeButton := Button.new()
closeButton.text = "×"
closeButton.custom_minimum_size = Vector2(54, 54)
closeButton.focus_mode = Control.FOCUS_NONE
closeButton.add_theme_font_size_override("font_size", 30)
closeButton.add_theme_color_override("font_color", Color.WHITE)
closeButton.add_theme_stylebox_override("normal", _createButtonStyle(Color(1.0, 1.0, 1.0, 0.12)))
closeButton.add_theme_stylebox_override("hover", _createButtonStyle(Color(1.0, 1.0, 1.0, 0.22)))
closeButton.add_theme_stylebox_override("pressed", _createButtonStyle(Color(1.0, 1.0, 1.0, 0.30)))
closeButton.add_theme_stylebox_override("focus", StyleBoxEmpty.new())
closeButton.pressed.connect(hide_panel)
row.add_child(closeButton)
return header
func _buildGridPanel() -> Control:
var holder := PanelContainer.new()
holder.size_flags_vertical = Control.SIZE_EXPAND_FILL
holder.add_theme_stylebox_override("panel", _createPanelStyle(Color(0.948, 0.978, 1.0, 0.92), 20, false))
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 18)
margin.add_theme_constant_override("margin_top", 18)
margin.add_theme_constant_override("margin_right", 18)
margin.add_theme_constant_override("margin_bottom", 18)
holder.add_child(margin)
var scroll := ScrollContainer.new()
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO
scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
margin.add_child(scroll)
_grid = GridContainer.new()
_grid.columns = 4
_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_grid.add_theme_constant_override("h_separation", 24)
_grid.add_theme_constant_override("v_separation", 24)
scroll.add_child(_grid)
return holder
func _buildFooter() -> Control:
var footer := HBoxContainer.new()
footer.custom_minimum_size = Vector2(0, 34)
footer.alignment = BoxContainer.ALIGNMENT_CENTER
_statusLabel = Label.new()
_statusLabel.text = "靠近招募看板按 E 打开,点击课程卡片进入详情"
_statusLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_statusLabel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_statusLabel.add_theme_font_size_override("font_size", 16)
_statusLabel.add_theme_color_override("font_color", MUTED_COLOR)
footer.add_child(_statusLabel)
return footer
func _fetchCourses() -> void:
_statusLabel.text = "正在同步 Datawhale 课程..."
_clearChildren(_grid)
var err := _request.request("%s%s" % [NetworkConfig.get_api_base_url(), API_PATH])
if err != OK:
_statusLabel.text = "课程加载失败:%s" % error_string(err)
func _onRequestCompleted(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
if result != HTTPRequest.RESULT_SUCCESS or responseCode < 200 or responseCode >= 300:
_statusLabel.text = "课程加载失败,请稍后再试"
return
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
if not parsed is Dictionary:
_statusLabel.text = "课程数据解析失败"
return
var payload: Dictionary = parsed as Dictionary
if not bool(payload.get("success", true)):
_statusLabel.text = str(payload.get("message", "课程加载失败"))
return
var data: Variant = payload.get("data", {})
if not data is Dictionary:
_statusLabel.text = "课程数据为空"
return
var coursesVariant: Variant = (data as Dictionary).get("courses", [])
if not coursesVariant is Array:
_statusLabel.text = "课程数据为空"
return
var courses: Array = coursesVariant as Array
_renderCourses(courses)
_statusLabel.text = "已同步 %d 门课程" % courses.size()
func _renderCourses(rows: Array) -> void:
_clearChildren(_grid)
for row in rows:
if row is Dictionary:
_grid.add_child(_buildCourseCard(row as Dictionary))
func _buildCourseCard(course: Dictionary) -> Button:
var card := Button.new()
card.custom_minimum_size = CARD_SIZE
card.size_flags_horizontal = Control.SIZE_EXPAND_FILL
card.focus_mode = Control.FOCUS_NONE
card.clip_contents = true
card.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
card.add_theme_stylebox_override("normal", _createCardStyle(false))
card.add_theme_stylebox_override("hover", _createCardStyle(true))
card.add_theme_stylebox_override("pressed", _createCardStyle(true))
card.add_theme_stylebox_override("focus", StyleBoxEmpty.new())
card.pressed.connect(_openCourse.bind(course))
var root := VBoxContainer.new()
root.set_anchors_preset(Control.PRESET_FULL_RECT)
root.add_theme_constant_override("separation", 0)
root.mouse_filter = Control.MOUSE_FILTER_IGNORE
card.add_child(root)
var coverPanel := Control.new()
coverPanel.custom_minimum_size = Vector2(0, COVER_HEIGHT)
coverPanel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
coverPanel.clip_contents = true
coverPanel.mouse_filter = Control.MOUSE_FILTER_IGNORE
root.add_child(coverPanel)
var coverBg := ColorRect.new()
coverBg.set_anchors_preset(Control.PRESET_FULL_RECT)
coverBg.color = IMAGE_PLACEHOLDER
coverBg.mouse_filter = Control.MOUSE_FILTER_IGNORE
coverPanel.add_child(coverBg)
var coverImage := TextureRect.new()
coverImage.set_anchors_preset(Control.PRESET_FULL_RECT)
coverImage.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
coverImage.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED
coverImage.mouse_filter = Control.MOUSE_FILTER_IGNORE
coverPanel.add_child(coverImage)
var coverText := Label.new()
coverText.text = _shortTitle(str(course.get("title", "课程")))
coverText.set_anchors_preset(Control.PRESET_FULL_RECT)
coverText.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
coverText.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
coverText.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
coverText.add_theme_font_size_override("font_size", 22)
coverText.add_theme_color_override("font_color", ACCENT_COLOR)
coverText.mouse_filter = Control.MOUSE_FILTER_IGNORE
coverPanel.add_child(coverText)
_loadCoverImage(str(course.get("coverUrl", "")), coverImage, coverText)
var bodyMargin := MarginContainer.new()
bodyMargin.size_flags_horizontal = Control.SIZE_EXPAND_FILL
bodyMargin.size_flags_vertical = Control.SIZE_EXPAND_FILL
bodyMargin.add_theme_constant_override("margin_left", 16)
bodyMargin.add_theme_constant_override("margin_top", 16)
bodyMargin.add_theme_constant_override("margin_right", 16)
bodyMargin.add_theme_constant_override("margin_bottom", 30)
bodyMargin.mouse_filter = Control.MOUSE_FILTER_IGNORE
root.add_child(bodyMargin)
var info := VBoxContainer.new()
info.size_flags_horizontal = Control.SIZE_EXPAND_FILL
info.size_flags_vertical = Control.SIZE_EXPAND_FILL
info.clip_contents = true
info.add_theme_constant_override("separation", 6)
info.mouse_filter = Control.MOUSE_FILTER_IGNORE
bodyMargin.add_child(info)
var title := Label.new()
title.text = str(course.get("title", "未命名课程"))
title.custom_minimum_size = Vector2(0, 46)
title.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
title.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
title.add_theme_font_size_override("font_size", 18)
title.add_theme_color_override("font_color", TEXT_COLOR)
title.mouse_filter = Control.MOUSE_FILTER_IGNORE
info.add_child(title)
var intro := Label.new()
intro.text = str(course.get("intro", ""))
intro.custom_minimum_size = Vector2(0, 58)
intro.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
intro.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
intro.add_theme_font_size_override("font_size", 13)
intro.add_theme_color_override("font_color", MUTED_COLOR)
intro.mouse_filter = Control.MOUSE_FILTER_IGNORE
info.add_child(intro)
var spacer := Control.new()
spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
info.add_child(spacer)
var meta := HFlowContainer.new()
meta.custom_minimum_size = Vector2(0, 34)
meta.size_flags_horizontal = Control.SIZE_EXPAND_FILL
meta.add_theme_constant_override("h_separation", 6)
meta.add_theme_constant_override("v_separation", 6)
meta.mouse_filter = Control.MOUSE_FILTER_IGNORE
info.add_child(meta)
meta.add_child(_buildTag(str(course.get("difficultyLabel", "其他")), Color(0.918, 0.960, 1.0, 1.0), ACCENT_COLOR, 56.0))
meta.add_child(_buildTag(str(course.get("categoryLabel", "其他")), Color(0.945, 0.950, 0.965, 1.0), MUTED_COLOR, 112.0))
var views := int(course.get("viewCount", 0))
if views > 0:
meta.add_child(_buildTag(_formatViews(views), Color(0.950, 0.980, 0.965, 1.0), Color(0.160, 0.510, 0.340), 82.0))
return card
func _buildTag(text: String, bgColor: Color, fontColor: Color, maxWidth: float) -> Label:
var tag := Label.new()
tag.text = text
tag.custom_minimum_size = Vector2(maxWidth, 26)
tag.clip_text = true
tag.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
tag.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
tag.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
tag.add_theme_font_size_override("font_size", 12)
tag.add_theme_color_override("font_color", fontColor)
tag.add_theme_stylebox_override("normal", _createPanelStyle(bgColor, 12, false))
tag.mouse_filter = Control.MOUSE_FILTER_IGNORE
return tag
func _loadCoverImage(url: String, textureRect: TextureRect, fallbackLabel: Label) -> void:
var coverUrl := url.strip_edges()
if coverUrl.is_empty():
return
var request := HTTPRequest.new()
request.timeout = 12.0
request.request_completed.connect(_onCoverRequestCompleted.bind(textureRect, fallbackLabel, request))
add_child(request)
var err := request.request(coverUrl)
if err != OK:
request.queue_free()
func _onCoverRequestCompleted(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray, textureRect: TextureRect, fallbackLabel: Label, request: HTTPRequest) -> void:
if is_instance_valid(request):
request.queue_free()
if not is_instance_valid(textureRect):
return
if result != HTTPRequest.RESULT_SUCCESS or responseCode < 200 or responseCode >= 300:
return
var image := Image.new()
var loadError := image.load_png_from_buffer(body)
if loadError != OK:
loadError = image.load_jpg_from_buffer(body)
if loadError != OK:
loadError = image.load_webp_from_buffer(body)
if loadError != OK:
return
textureRect.texture = ImageTexture.create_from_image(image)
if is_instance_valid(fallbackLabel):
fallbackLabel.visible = false
func _openCourse(course: Dictionary) -> void:
var detailUrl := str(course.get("detailUrl", "")).strip_edges()
if detailUrl.is_empty():
return
OS.shell_open(detailUrl)
func _onOverlayGuiInput(event: InputEvent) -> void:
if event is InputEventMouseButton:
var mouseEvent := event as InputEventMouseButton
if mouseEvent.pressed and mouseEvent.button_index == MOUSE_BUTTON_LEFT:
hide_panel()
func _positionPanel() -> void:
var viewportSize := get_viewport_rect().size
var width := minf(PANEL_SIZE.x, maxf(980.0, viewportSize.x - MIN_MARGIN.x * 2.0))
var height := minf(PANEL_SIZE.y, maxf(760.0, viewportSize.y - MIN_MARGIN.y * 2.0))
_panel.position = (viewportSize - Vector2(width, height)) * 0.5
_panel.size = Vector2(width, height)
_panel.pivot_offset = _panel.size * 0.5
func _animatePanel(opening: bool) -> void:
if is_instance_valid(_transitionTween):
_transitionTween.kill()
_transitionTween = create_tween()
_transitionTween.set_parallel(true)
if opening:
_updatePanelAlpha(0.0, 0.0)
_panel.scale = Vector2(0.985, 0.985)
_transitionTween.tween_property(_overlay, "modulate:a", 1.0, 0.16)
_transitionTween.tween_property(_panel, "modulate:a", 1.0, 0.18)
_transitionTween.tween_property(_panel, "scale", Vector2.ONE, 0.18)
else:
_transitionTween.tween_property(_overlay, "modulate:a", 0.0, 0.12)
_transitionTween.tween_property(_panel, "modulate:a", 0.0, 0.12)
_transitionTween.tween_property(_panel, "scale", Vector2(0.985, 0.985), 0.12)
_transitionTween.finished.connect(_onCloseAnimationFinished)
func _onCloseAnimationFinished() -> void:
if _isOpen:
return
visible = false
mouse_filter = Control.MOUSE_FILTER_IGNORE
set_process(false)
func _updatePanelAlpha(overlayAlpha: float, panelAlpha: float) -> void:
if is_instance_valid(_overlay):
_overlay.modulate.a = overlayAlpha
if is_instance_valid(_panel):
_panel.modulate.a = panelAlpha
func _releaseMovementInputState() -> void:
Input.flush_buffered_events()
_releaseMovementActions()
func _releaseMovementActions() -> void:
for action in MOVEMENT_ACTIONS:
Input.action_release(action)
func _clearChildren(node: Node) -> void:
for child in node.get_children():
child.queue_free()
func _shortTitle(title: String) -> String:
var trimmed := title.strip_edges()
if trimmed.length() <= 8:
return trimmed
return trimmed.substr(0, 8)
func _formatViews(views: int) -> String:
if views >= 10000:
var wan := float(views) / 10000.0
return "%.1f万看过" % wan
return "%d人看过" % views
func _createPanelStyle(color: Color, radius: int, shadow: bool) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = color
style.corner_radius_top_left = radius
style.corner_radius_top_right = radius
style.corner_radius_bottom_left = radius
style.corner_radius_bottom_right = radius
style.content_margin_left = 10
style.content_margin_top = 7
style.content_margin_right = 10
style.content_margin_bottom = 7
if shadow:
style.shadow_color = Color(0.030, 0.120, 0.250, 0.18)
style.shadow_size = 16
style.shadow_offset = Vector2(0, 5)
return style
func _createButtonStyle(color: Color) -> StyleBoxFlat:
var style := _createPanelStyle(color, 18, false)
style.content_margin_left = 0
style.content_margin_top = 0
style.content_margin_right = 0
style.content_margin_bottom = 0
return style
func _createCardStyle(hover: bool) -> StyleBoxFlat:
var style := _createPanelStyle(Color.WHITE if not hover else Color(0.986, 0.996, 1.0, 1.0), 18, false)
style.border_width_left = 2
style.border_width_top = 2
style.border_width_right = 2
style.border_width_bottom = 2
style.border_color = Color(0.815, 0.890, 0.960, 0.92) if not hover else Color(0.000, 0.322, 0.851, 0.72)
style.shadow_color = Color(0.040, 0.150, 0.300, 0.08 if not hover else 0.14)
style.shadow_size = 4 if not hover else 8
style.shadow_offset = Vector2(0, 3)
return style

View File

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

View File

@@ -0,0 +1,13 @@
[gd_scene load_steps=2 format=4 uid="uid://whaletown_course_board_panel"]
[ext_resource type="Script" path="res://scenes/ui/CourseBoardPanel.gd" id="1_course_board"]
[node name="CourseBoardPanel" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
script = ExtResource("1_course_board")

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,647 @@
extends Control
# ============================================================================
# FriendListPanel.gd - 右下角好友列表面板
# ============================================================================
# 独立展示好友在线、好友请求和好友选择。点击好友后通过事件驱动 ChatUI
# 切换到对应好友会话,不在面板内承载聊天内容。
# ============================================================================
const PANEL_SIZE: Vector2 = Vector2(384, 492)
const PANEL_MARGIN: Vector2 = Vector2(32, 32)
const ONLINE_COLOR: Color = Color(0.286, 0.765, 0.486)
const OFFLINE_COLOR: Color = Color(0.678, 0.725, 0.773)
const TEXT_COLOR: Color = Color(0.188, 0.294, 0.424)
const MUTED_COLOR: Color = Color(0.560, 0.639, 0.733)
const ACCENT_COLOR: Color = Color(0.258824, 0.627451, 0.913725)
const SURFACE_COLOR: Color = Color(1.0, 0.996, 0.984, 0.97)
const SURFACE_RAISED_COLOR: Color = Color(1.0, 1.0, 1.0, 0.9)
const BORDER_COLOR: Color = Color(0.788, 0.851, 0.910, 0.52)
const FRIEND_AVATAR_SIZE: Vector2 = Vector2(48, 48)
const FRIEND_AVATAR_CORNER_RADIUS: int = 12
@export var autoRequestFriendList: bool = true
var _panel: PanelContainer
var _titleLabel: Label
var _summaryLabel: Label
var _closeButton: Button
var _requestList: VBoxContainer
var _friendList: VBoxContainer
var _statusLabel: Label
var _friends: Array[Dictionary] = []
var _requests: Array[Dictionary] = []
var _supported: bool = true
var _statusMessage: String = ""
var _isOpen: bool = false
var _hasRequestedFriendList: bool = false
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
_build_ui()
set_panel_open(false)
_subscribe_to_events()
func _exit_tree() -> void:
var eventSystem := _get_event_system()
if eventSystem != null:
eventSystem.call("disconnect_event", EventNames.CHAT_FRIENDS_UPDATED, _on_friends_updated, self)
eventSystem.call("disconnect_event", EventNames.HUD_FRIEND_LIST_TOGGLE, _on_friend_list_toggle, self)
eventSystem.call("disconnect_event", EventNames.SETTINGS_CHANGED, _on_settings_changed, self)
func set_panel_open(open: bool) -> void:
_isOpen = open
if is_instance_valid(_panel):
_panel.visible = _isOpen
if _isOpen and autoRequestFriendList and not _hasRequestedFriendList:
_request_initial_friend_list()
func is_panel_open() -> bool:
return _isOpen
func toggle_panel() -> void:
set_panel_open(not _isOpen)
func _build_ui() -> void:
_panel = PanelContainer.new()
_panel.name = "FriendListCard"
_panel.custom_minimum_size = PANEL_SIZE
_panel.mouse_filter = Control.MOUSE_FILTER_STOP
_panel.add_theme_stylebox_override("panel", _create_panel_style())
add_child(_panel)
_anchor_panel()
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 18)
margin.add_theme_constant_override("margin_top", 18)
margin.add_theme_constant_override("margin_right", 18)
margin.add_theme_constant_override("margin_bottom", 18)
_panel.add_child(margin)
var content := VBoxContainer.new()
content.add_theme_constant_override("separation", 12)
margin.add_child(content)
var header := HBoxContainer.new()
header.custom_minimum_size = Vector2(0, 48)
header.add_theme_constant_override("separation", 12)
content.add_child(header)
var titleStack := VBoxContainer.new()
titleStack.size_flags_horizontal = Control.SIZE_EXPAND_FILL
titleStack.alignment = BoxContainer.ALIGNMENT_CENTER
titleStack.add_theme_constant_override("separation", 1)
header.add_child(titleStack)
_titleLabel = Label.new()
_titleLabel.text = "好友"
_titleLabel.add_theme_color_override("font_color", TEXT_COLOR)
_titleLabel.add_theme_font_size_override("font_size", 22)
titleStack.add_child(_titleLabel)
var subtitleLabel := Label.new()
subtitleLabel.text = "和鲸落镇的伙伴保持联系"
subtitleLabel.add_theme_color_override("font_color", MUTED_COLOR)
subtitleLabel.add_theme_font_size_override("font_size", 12)
titleStack.add_child(subtitleLabel)
_closeButton = Button.new()
_closeButton.text = "×"
_closeButton.tooltip_text = "收起好友列表"
_closeButton.custom_minimum_size = Vector2(36, 36)
_closeButton.focus_mode = Control.FOCUS_NONE
_closeButton.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
_closeButton.add_theme_font_size_override("font_size", 22)
_closeButton.add_theme_color_override("font_color", MUTED_COLOR)
_closeButton.add_theme_color_override("font_hover_color", ACCENT_COLOR)
_closeButton.add_theme_color_override("font_pressed_color", ACCENT_COLOR.darkened(0.12))
_closeButton.add_theme_color_override("font_disabled_color", Color(0.37, 0.48, 0.58, 0.55))
_closeButton.add_theme_stylebox_override("normal", _create_empty_style())
_closeButton.add_theme_stylebox_override("hover", _create_pill_style(Color(0.918, 0.961, 0.992, 0.96), 12))
_closeButton.add_theme_stylebox_override("pressed", _create_pill_style(Color(0.858, 0.925, 0.980, 1.0), 12))
_closeButton.add_theme_stylebox_override("focus", _create_empty_style())
_closeButton.pressed.connect(func() -> void: set_panel_open(false))
header.add_child(_closeButton)
var summaryCard := PanelContainer.new()
summaryCard.custom_minimum_size = Vector2(0, 54)
summaryCard.add_theme_stylebox_override("panel", _create_summary_style())
content.add_child(summaryCard)
var summaryRow := HBoxContainer.new()
summaryRow.add_theme_constant_override("separation", 10)
summaryCard.add_child(summaryRow)
var onlineDot := PanelContainer.new()
onlineDot.custom_minimum_size = Vector2(10, 10)
onlineDot.size_flags_vertical = Control.SIZE_SHRINK_CENTER
onlineDot.add_theme_stylebox_override("panel", _create_dot_style(ONLINE_COLOR))
summaryRow.add_child(onlineDot)
_summaryLabel = Label.new()
_summaryLabel.text = "暂无在线好友"
_summaryLabel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_summaryLabel.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_summaryLabel.add_theme_color_override("font_color", Color(0.207843, 0.427451, 0.686275))
_summaryLabel.add_theme_font_size_override("font_size", 15)
summaryRow.add_child(_summaryLabel)
var chatHint := Label.new()
chatHint.text = "选择好友开始私聊"
chatHint.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
chatHint.add_theme_color_override("font_color", MUTED_COLOR)
chatHint.add_theme_font_size_override("font_size", 12)
summaryRow.add_child(chatHint)
_statusLabel = Label.new()
_statusLabel.visible = false
_statusLabel.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_statusLabel.add_theme_color_override("font_color", MUTED_COLOR)
_statusLabel.add_theme_font_size_override("font_size", 15)
content.add_child(_statusLabel)
_requestList = VBoxContainer.new()
_requestList.add_theme_constant_override("separation", 8)
content.add_child(_requestList)
var friendsHeading := Label.new()
friendsHeading.text = "全部好友"
friendsHeading.add_theme_color_override("font_color", MUTED_COLOR)
friendsHeading.add_theme_font_size_override("font_size", 13)
content.add_child(friendsHeading)
var scroll := ScrollContainer.new()
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
scroll.add_theme_stylebox_override("panel", _create_empty_style())
content.add_child(scroll)
_friendList = VBoxContainer.new()
_friendList.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_friendList.add_theme_constant_override("separation", 8)
scroll.add_child(_friendList)
_render()
func _anchor_panel() -> void:
_panel.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
_panel.offset_left = -PANEL_SIZE.x - PANEL_MARGIN.x
_panel.offset_top = -PANEL_SIZE.y - PANEL_MARGIN.y
_panel.offset_right = -PANEL_MARGIN.x
_panel.offset_bottom = -PANEL_MARGIN.y
_panel.grow_horizontal = Control.GROW_DIRECTION_BEGIN
_panel.grow_vertical = Control.GROW_DIRECTION_BEGIN
func _subscribe_to_events() -> void:
var eventSystem := _get_event_system()
if eventSystem == null:
push_warning("FriendListPanel: EventSystem autoload is not available.")
return
eventSystem.call("connect_event", EventNames.CHAT_FRIENDS_UPDATED, _on_friends_updated, self)
eventSystem.call("connect_event", EventNames.HUD_FRIEND_LIST_TOGGLE, _on_friend_list_toggle, self)
eventSystem.call("connect_event", EventNames.SETTINGS_CHANGED, _on_settings_changed, self)
func _request_initial_friend_list() -> void:
_hasRequestedFriendList = true
var chatManager := get_node_or_null("/root/ChatManager")
if chatManager != null and chatManager.has_method("request_friend_list"):
chatManager.call_deferred("request_friend_list")
func _on_friend_list_toggle(_data: Variant = null) -> void:
toggle_panel()
func _on_friends_updated(data: Dictionary) -> void:
_friends.clear()
_requests.clear()
_supported = bool(data.get("supported", true))
_statusMessage = str(data.get("status", "")).strip_edges()
var friendsVariant: Variant = data.get("friends", [])
if friendsVariant is Array:
for friendVariant in friendsVariant:
if friendVariant is Dictionary:
_friends.append(friendVariant)
var requestsVariant: Variant = data.get("requests", [])
if requestsVariant is Array:
for requestVariant in requestsVariant:
if requestVariant is Dictionary:
_requests.append(requestVariant)
_render()
func _on_settings_changed(_data: Dictionary) -> void:
_render()
func _render() -> void:
if not is_instance_valid(_friendList) or not is_instance_valid(_requestList):
return
_clear_children(_friendList)
_clear_children(_requestList)
var onlineCount := _count_online_friends()
_titleLabel.text = "好友"
if is_instance_valid(_summaryLabel):
if _friends.is_empty():
_summaryLabel.text = "暂无好友,去广场认识新伙伴吧"
else:
_summaryLabel.text = "%d 位好友在线,共 %d 位伙伴" % [onlineCount, _friends.size()]
if not _supported:
_show_status(_statusMessage if not _statusMessage.is_empty() else "当前后端暂不支持好友功能")
return
_statusLabel.visible = false
_render_requests()
_render_friends()
func _render_requests() -> void:
if _requests.is_empty() or not _settings_bool("friend_request_notifications", true):
_requestList.visible = false
return
_requestList.visible = true
var titleCard := PanelContainer.new()
titleCard.custom_minimum_size = Vector2(0, 34)
titleCard.add_theme_stylebox_override("panel", _create_section_style())
_requestList.add_child(titleCard)
var title := Label.new()
title.text = "待处理请求 %d" % _requests.size()
title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
title.add_theme_color_override("font_color", Color(0.207843, 0.427451, 0.686275))
title.add_theme_font_size_override("font_size", 13)
titleCard.add_child(title)
for request in _requests:
_requestList.add_child(_create_request_row(request))
func _render_friends() -> void:
if _friends.is_empty():
var emptyCard := PanelContainer.new()
emptyCard.custom_minimum_size = Vector2(0, 100)
emptyCard.add_theme_stylebox_override("panel", _create_empty_state_style())
_friendList.add_child(emptyCard)
var emptyLabel := Label.new()
emptyLabel.text = "暂无好友\n靠近玩家按 F 发送好友请求"
emptyLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
emptyLabel.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
emptyLabel.add_theme_color_override("font_color", MUTED_COLOR)
emptyLabel.add_theme_font_size_override("font_size", 14)
emptyCard.add_child(emptyLabel)
return
for friend in _sorted_friends():
_friendList.add_child(_create_friend_row(friend))
func _create_friend_row(friend: Dictionary) -> Control:
var userId := _read_user_id(friend)
var username := str(friend.get("username", "好友")).strip_edges()
if username.is_empty():
username = "好友"
var online := bool(friend.get("online", false))
var button := Button.new()
button.focus_mode = Control.FOCUS_NONE
button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
button.disabled = userId.is_empty()
button.custom_minimum_size = Vector2(0, 68)
button.text = ""
button.add_theme_stylebox_override("normal", _create_friend_row_style(false, online))
button.add_theme_stylebox_override("hover", _create_friend_row_style(true, online))
button.add_theme_stylebox_override("pressed", _create_friend_row_style(true, online, true))
button.add_theme_stylebox_override("disabled", _create_friend_row_style(false, false))
button.add_theme_stylebox_override("focus", _create_empty_style())
button.pressed.connect(func() -> void: _select_friend(userId, username, online))
var row := HBoxContainer.new()
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.set_anchors_preset(Control.PRESET_FULL_RECT)
row.offset_left = 10
row.offset_top = 8
row.offset_right = -10
row.offset_bottom = -8
row.add_theme_constant_override("separation", 12)
button.add_child(row)
row.add_child(_create_avatar(username, online))
var textBox := VBoxContainer.new()
textBox.mouse_filter = Control.MOUSE_FILTER_IGNORE
textBox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
textBox.alignment = BoxContainer.ALIGNMENT_CENTER
textBox.add_theme_constant_override("separation", 3)
row.add_child(textBox)
var nameLabel := Label.new()
nameLabel.mouse_filter = Control.MOUSE_FILTER_IGNORE
nameLabel.text = username
nameLabel.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
nameLabel.add_theme_color_override("font_color", TEXT_COLOR if online else MUTED_COLOR)
nameLabel.add_theme_font_size_override("font_size", 16)
textBox.add_child(nameLabel)
var statusLabel := Label.new()
statusLabel.mouse_filter = Control.MOUSE_FILTER_IGNORE
statusLabel.text = "正在鲸落镇" if online else "暂时离线"
statusLabel.add_theme_color_override("font_color", ONLINE_COLOR if online else MUTED_COLOR)
statusLabel.add_theme_font_size_override("font_size", 12)
textBox.add_child(statusLabel)
var dot := PanelContainer.new()
dot.name = "FriendOnlineIndicator"
dot.custom_minimum_size = Vector2(8, 8)
dot.size_flags_vertical = Control.SIZE_SHRINK_CENTER
dot.mouse_filter = Control.MOUSE_FILTER_IGNORE
dot.add_theme_stylebox_override("panel", _create_dot_style(ONLINE_COLOR if online else OFFLINE_COLOR))
row.add_child(dot)
return button
func _create_request_row(request: Dictionary) -> Control:
var userId := _read_user_id(request)
var username := str(request.get("username", "玩家")).strip_edges()
if username.is_empty():
username = "玩家"
var rowPanel := PanelContainer.new()
rowPanel.custom_minimum_size = Vector2(0, 48)
rowPanel.add_theme_stylebox_override("panel", _create_request_style())
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
rowPanel.add_child(row)
var nameLabel := Label.new()
nameLabel.mouse_filter = Control.MOUSE_FILTER_IGNORE
nameLabel.text = username
nameLabel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
nameLabel.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
nameLabel.add_theme_color_override("font_color", TEXT_COLOR)
nameLabel.add_theme_font_size_override("font_size", 14)
row.add_child(nameLabel)
var acceptButton := _create_small_button("接受", Color(0.333, 0.761, 0.514))
acceptButton.pressed.connect(func() -> void: _respond_request(userId, username, true))
row.add_child(acceptButton)
var rejectButton := _create_small_button("拒绝", Color(0.812, 0.553, 0.553))
rejectButton.pressed.connect(func() -> void: _respond_request(userId, username, false))
row.add_child(rejectButton)
return rowPanel
func _select_friend(userId: String, username: String, _online: bool) -> void:
if userId.strip_edges().is_empty():
return
var eventSystem := _get_event_system()
if eventSystem != null:
eventSystem.call("emit_event", EventNames.CHAT_FRIEND_SELECTED, {
"user_id": userId,
"username": username
})
func _respond_request(userId: String, username: String, accepted: bool) -> void:
if userId.strip_edges().is_empty():
return
var chatManager := get_node_or_null("/root/ChatManager")
if chatManager == null:
return
var ok := false
if accepted and chatManager.has_method("accept_friend_request"):
ok = bool(chatManager.call("accept_friend_request", userId, username))
elif not accepted and chatManager.has_method("reject_friend_request"):
ok = bool(chatManager.call("reject_friend_request", userId, username))
if ok:
for i in range(_requests.size() - 1, -1, -1):
if _read_user_id(_requests[i]) == userId:
_requests.remove_at(i)
_render()
func _show_status(message: String) -> void:
_statusLabel.text = message
_statusLabel.visible = true
_requestList.visible = false
func _count_online_friends() -> int:
var count := 0
for friend in _friends:
if bool(friend.get("online", false)):
count += 1
return count
func _sorted_friends() -> Array[Dictionary]:
var sortedFriends := _friends.duplicate(true)
sortedFriends.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
var onlineA := bool(a.get("online", false))
var onlineB := bool(b.get("online", false))
if onlineA != onlineB:
return onlineA
return str(a.get("username", "")).nocasecmp_to(str(b.get("username", ""))) < 0
)
return sortedFriends
func _read_user_id(data: Dictionary) -> String:
return str(data.get("user_id", data.get("userId", ""))).strip_edges()
func _create_avatar(username: String, online: bool) -> PanelContainer:
var avatar := PanelContainer.new()
avatar.name = "FriendAvatarPanel"
avatar.custom_minimum_size = FRIEND_AVATAR_SIZE
avatar.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
avatar.size_flags_vertical = Control.SIZE_SHRINK_CENTER
avatar.mouse_filter = Control.MOUSE_FILTER_IGNORE
avatar.add_theme_stylebox_override("panel", _create_avatar_style(online))
var label := Label.new()
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
label.text = _avatar_text(username)
label.custom_minimum_size = FRIEND_AVATAR_SIZE
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
label.add_theme_color_override("font_color", Color.WHITE)
label.add_theme_font_size_override("font_size", 16)
avatar.add_child(label)
return avatar
func _avatar_text(username: String) -> String:
var normalized := username.strip_edges()
if normalized.is_empty():
return ""
return normalized.substr(0, 1).to_upper()
func _create_small_button(text: String, color: Color) -> Button:
var button := Button.new()
button.text = text
button.custom_minimum_size = Vector2(52, 30)
button.focus_mode = Control.FOCUS_NONE
button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
button.add_theme_font_size_override("font_size", 13)
button.add_theme_color_override("font_color", Color.WHITE)
button.add_theme_stylebox_override("normal", _create_pill_style(color, 9))
button.add_theme_stylebox_override("hover", _create_pill_style(color.lightened(0.08), 9))
button.add_theme_stylebox_override("pressed", _create_pill_style(color.darkened(0.08), 9))
button.add_theme_stylebox_override("focus", _create_empty_style())
return button
func _create_panel_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = SURFACE_COLOR
style.border_width_left = 1
style.border_width_top = 1
style.border_width_right = 1
style.border_width_bottom = 1
style.border_color = BORDER_COLOR
style.corner_radius_top_left = 20
style.corner_radius_top_right = 20
style.corner_radius_bottom_left = 20
style.corner_radius_bottom_right = 20
style.shadow_color = Color(0.12549, 0.282353, 0.407843, 0.18)
style.shadow_size = 18
style.shadow_offset = Vector2(0, 7)
return style
func _create_avatar_style(online: bool) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(0.361, 0.690, 0.914) if online else Color(0.678, 0.725, 0.773)
style.corner_radius_top_left = FRIEND_AVATAR_CORNER_RADIUS
style.corner_radius_top_right = FRIEND_AVATAR_CORNER_RADIUS
style.corner_radius_bottom_left = FRIEND_AVATAR_CORNER_RADIUS
style.corner_radius_bottom_right = FRIEND_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 _create_summary_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(0.918, 0.961, 0.992, 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.76, 0.86, 0.94, 0.62)
style.corner_radius_top_left = 12
style.corner_radius_top_right = 12
style.corner_radius_bottom_left = 12
style.corner_radius_bottom_right = 12
style.content_margin_left = 14
style.content_margin_right = 14
return style
func _create_dot_style(color: Color) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = color
style.corner_radius_top_left = 5
style.corner_radius_top_right = 5
style.corner_radius_bottom_left = 5
style.corner_radius_bottom_right = 5
return style
func _create_section_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(0.948, 0.974, 0.996, 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.87, 0.94, 0.54)
style.corner_radius_top_left = 9
style.corner_radius_top_right = 9
style.corner_radius_bottom_left = 9
style.corner_radius_bottom_right = 9
style.content_margin_left = 12
style.content_margin_right = 12
return style
func _create_empty_state_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(0.965, 0.982, 0.996, 0.86)
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.80, 0.88, 0.94, 0.52)
style.corner_radius_top_left = 12
style.corner_radius_top_right = 12
style.corner_radius_bottom_left = 12
style.corner_radius_bottom_right = 12
return style
func _create_request_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(1, 1, 1, 0.94)
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.86, 0.93, 0.64)
style.corner_radius_top_left = 10
style.corner_radius_top_right = 10
style.corner_radius_bottom_left = 10
style.corner_radius_bottom_right = 10
style.content_margin_left = 10
style.content_margin_top = 8
style.content_margin_right = 10
style.content_margin_bottom = 8
return style
func _create_friend_row_style(isHovered: bool, isOnline: bool, isPressed: bool = false) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = SURFACE_RAISED_COLOR if isOnline else Color(0.965, 0.973, 0.982, 0.86)
if isHovered:
style.bg_color = Color(0.918, 0.961, 0.992, 0.98)
if isPressed:
style.bg_color = style.bg_color.darkened(0.12)
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.55, 0.78, 0.95, 0.72) if isHovered else Color(0.80, 0.87, 0.93, 0.56)
style.corner_radius_top_left = 12
style.corner_radius_top_right = 12
style.corner_radius_bottom_left = 12
style.corner_radius_bottom_right = 12
return style
func _create_pill_style(color: Color, radius: int) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = color
style.corner_radius_top_left = radius
style.corner_radius_top_right = radius
style.corner_radius_bottom_left = radius
style.corner_radius_bottom_right = radius
style.content_margin_left = 10
style.content_margin_right = 10
style.content_margin_top = 5
style.content_margin_bottom = 5
return style
func _create_empty_style() -> StyleBoxEmpty:
return StyleBoxEmpty.new()
func _clear_children(container: Node) -> void:
for child in container.get_children():
container.remove_child(child)
child.queue_free()
func _get_event_system() -> Node:
return get_node_or_null("/root/EventSystem")
func _settings_bool(key: String, defaultValue: bool) -> bool:
var settingsManager := get_node_or_null("/root/SettingsManager")
if settingsManager != null and settingsManager.has_method("get_bool"):
return bool(settingsManager.call("get_bool", key))
return defaultValue

View File

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

View File

@@ -0,0 +1,13 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scenes/ui/FriendListPanel.gd" id="1"]
[node name="FriendListPanel" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
script = ExtResource("1")

View File

@@ -0,0 +1,143 @@
extends Control
# ============================================================================
# HudShortcutIcon.gd - 顶部快捷入口轻量线描图标
# ============================================================================
# 专门按 25px HUD 小尺寸绘制,保持细线、少细节、轻视觉重量。
# ============================================================================
const LINE_COLOR: Color = Color(0.592157, 0.72549, 0.835294, 0.82)
const DOT_COLOR: Color = Color(0.941176, 0.54902, 0.54902, 0.86)
const DOT_BORDER_COLOR: Color = Color(1.0, 1.0, 1.0, 0.95)
const LINE_WIDTH: float = 0.85
const DETAIL_WIDTH: float = 0.75
const BASE_SIZE: float = 27.0
var iconName: String = "map"
var _iconScale: float = 1.0
var _iconOffset: Vector2 = Vector2.ZERO
func _ready() -> void:
custom_minimum_size = Vector2(31, 31)
mouse_filter = Control.MOUSE_FILTER_IGNORE
func set_icon_name(value: String) -> void:
iconName = value
queue_redraw()
func _draw() -> void:
_iconScale = min(size.x, size.y) / BASE_SIZE
_iconOffset = (size - Vector2(BASE_SIZE, BASE_SIZE) * _iconScale) * 0.5
match iconName:
"map":
_draw_map()
"task":
_draw_task()
"activity":
_draw_activity()
"backpack":
_draw_backpack()
"friends":
_draw_friends()
"settings":
_draw_settings()
_:
_draw_map()
func _draw_map() -> void:
_draw_polyline([
Vector2(6.0, 7.0),
Vector2(10.9, 5.4),
Vector2(16.2, 7.1),
Vector2(21.0, 5.5),
Vector2(21.0, 20.1),
Vector2(16.2, 21.6),
Vector2(10.9, 19.9),
Vector2(6.0, 21.5),
Vector2(6.0, 7.0),
])
_draw_line(Vector2(10.9, 6.0), Vector2(10.9, 19.2), DETAIL_WIDTH)
_draw_line(Vector2(16.2, 7.7), Vector2(16.2, 21.0), DETAIL_WIDTH)
func _draw_task() -> void:
_draw_round_rect(Rect2(7.3, 6.2, 12.4, 15.4), 2.2)
_draw_line(Vector2(11.0, 5.0), Vector2(16.0, 5.0), LINE_WIDTH)
_draw_line(Vector2(9.8, 11.0), Vector2(16.8, 11.0), DETAIL_WIDTH)
_draw_line(Vector2(9.8, 14.8), Vector2(16.8, 14.8), DETAIL_WIDTH)
_draw_line(Vector2(9.8, 18.6), Vector2(14.8, 18.6), DETAIL_WIDTH)
_draw_red_dot(Vector2(20.0, 5.6))
func _draw_activity() -> void:
_draw_round_rect(Rect2(6.2, 7.5, 14.1, 13.2), 2.2)
_draw_line(Vector2(6.9, 11.5), Vector2(19.5, 11.5), DETAIL_WIDTH)
_draw_line(Vector2(9.6, 5.5), Vector2(9.6, 9.0), DETAIL_WIDTH)
_draw_line(Vector2(16.7, 5.5), Vector2(16.7, 9.0), DETAIL_WIDTH)
_draw_line(Vector2(10.0, 15.6), Vector2(12.0, 15.6), DETAIL_WIDTH)
_draw_line(Vector2(14.6, 15.6), Vector2(16.6, 15.6), DETAIL_WIDTH)
_draw_red_dot(Vector2(20.4, 5.8))
func _draw_backpack() -> void:
_draw_round_rect(Rect2(6.4, 8.4, 14.2, 12.6), 2.4)
_draw_arc_poly(Vector2(13.5, 9.0), 4.0, PI + 0.18, TAU - 0.18, 18, DETAIL_WIDTH)
_draw_line(Vector2(8.9, 12.4), Vector2(18.1, 12.4), DETAIL_WIDTH)
_draw_round_rect(Rect2(10.4, 13.9, 6.2, 4.4), 1.2, DETAIL_WIDTH)
_draw_line(Vector2(8.2, 10.2), Vector2(8.2, 20.0), DETAIL_WIDTH)
_draw_line(Vector2(18.8, 10.2), Vector2(18.8, 20.0), DETAIL_WIDTH)
func _draw_friends() -> void:
_draw_arc(Vector2(10.2, 9.4), 3.1, 0.0, TAU, 28, LINE_WIDTH)
_draw_arc_poly(Vector2(10.2, 21.6), 6.1, PI + 0.10, TAU - 0.10, 22)
_draw_arc(Vector2(17.8, 10.6), 2.5, 0.0, TAU, 24, DETAIL_WIDTH)
_draw_arc_poly(Vector2(17.8, 21.6), 4.7, PI + 0.16, TAU - 0.24, 18, DETAIL_WIDTH)
func _draw_settings() -> void:
var center := Vector2(13.5, 13.5)
for i in range(8):
var angle := TAU * float(i) / 8.0
var inner := center + Vector2(cos(angle), sin(angle)) * 5.0
var outer := center + Vector2(cos(angle), sin(angle)) * 6.7
_draw_line(inner, outer, DETAIL_WIDTH)
_draw_arc(center, 4.9, 0.0, TAU, 32, LINE_WIDTH)
_draw_arc(center, 2.0, 0.0, TAU, 24, DETAIL_WIDTH)
func _draw_round_rect(rect: Rect2, radius: float, width: float = LINE_WIDTH) -> void:
var left := rect.position.x
var top := rect.position.y
var right := rect.end.x
var bottom := rect.end.y
_draw_line(Vector2(left + radius, top), Vector2(right - radius, top), width)
_draw_line(Vector2(right, top + radius), Vector2(right, bottom - radius), width)
_draw_line(Vector2(right - radius, bottom), Vector2(left + radius, bottom), width)
_draw_line(Vector2(left, bottom - radius), Vector2(left, top + radius), width)
_draw_arc(Vector2(left + radius, top + radius), radius, PI, PI * 1.5, 8, width)
_draw_arc(Vector2(right - radius, top + radius), radius, PI * 1.5, TAU, 8, width)
_draw_arc(Vector2(right - radius, bottom - radius), radius, 0.0, PI * 0.5, 8, width)
_draw_arc(Vector2(left + radius, bottom - radius), radius, PI * 0.5, PI, 8, width)
func _draw_arc_poly(center: Vector2, radius: float, start: float, end: float, pointCount: int, width: float = LINE_WIDTH) -> void:
var points: PackedVector2Array = []
for i in range(pointCount + 1):
var t := float(i) / float(pointCount)
var angle := lerpf(start, end, t)
points.append(_p(center + Vector2(cos(angle), sin(angle)) * radius))
draw_polyline(points, LINE_COLOR, width, true)
func _draw_polyline(points: Array[Vector2], width: float = LINE_WIDTH) -> void:
var packed: PackedVector2Array = []
for point in points:
packed.append(_p(point))
draw_polyline(packed, LINE_COLOR, width, true)
func _draw_red_dot(center: Vector2) -> void:
draw_circle(_p(center), 2.3 * _iconScale, DOT_BORDER_COLOR)
draw_circle(_p(center), 1.55 * _iconScale, DOT_COLOR)
func _draw_line(from: Vector2, to: Vector2, width: float) -> void:
draw_line(_p(from), _p(to), LINE_COLOR, width, true)
func _draw_arc(center: Vector2, radius: float, startAngle: float, endAngle: float, pointCount: int, width: float) -> void:
draw_arc(_p(center), radius * _iconScale, startAngle, endAngle, pointCount, LINE_COLOR, width, true)
func _p(point: Vector2) -> Vector2:
return _iconOffset + point * _iconScale

View File

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

516
scenes/ui/MapPanel.gd Normal file
View File

@@ -0,0 +1,516 @@
extends Control
# ============================================================================
# MapPanel.gd - 右上角小镇地图弹窗
# ============================================================================
# 监听 HUD 地图入口,展示当前区域全图与关键建筑标识。
# ============================================================================
const PANEL_SIZE: Vector2 = Vector2(548, 452)
const PANEL_MARGIN_TOP: float = 122.0
const PANEL_MARGIN_RIGHT: float = 56.0
const MAP_VIEW_SIZE: Vector2 = Vector2(500, 375)
const MAP_WORLD_SIZE: Vector2 = Vector2(2560, 1920)
const MAP_CAMERA_ZOOM: Vector2 = Vector2(MAP_VIEW_SIZE.x / MAP_WORLD_SIZE.x, MAP_VIEW_SIZE.y / MAP_WORLD_SIZE.y)
const MINIMAP_EXCLUDED_NODE_NAMES: Array[String] = [
"Characters",
"MultiplayerController",
]
const TEXT_COLOR: Color = Color(0.188, 0.294, 0.424)
const MUTED_COLOR: Color = Color(0.560, 0.639, 0.733)
const ACCENT_COLOR: Color = Color(0.258824, 0.627451, 0.913725)
const SOFT_BLUE: Color = Color(0.918, 0.961, 0.992, 0.94)
const PIN_BLUE: Color = Color(0.244, 0.572, 0.862)
const MAP_CONFIGS: Dictionary = {
"Square": {
"title": "小镇地图",
"sourceNodes": [
"HDGrassBase",
"GroundGrassLayer",
"PathBaseLayer",
"PathEdgeLayer",
"PathEdgeCornerLayer",
"PathEdgeJoinLayer",
"PathCurbLayer",
"YSortWorld",
],
"markers": [
{"label": "码头", "icon": "anchor", "pos": Vector2(0.160, 0.395), "side": "right"},
{"label": "总部", "icon": "home", "pos": Vector2(0.505, 0.188), "side": "right"},
{"label": "广场", "icon": "whale", "pos": Vector2(0.505, 0.515), "side": "right"},
{"label": "小屋", "icon": "home", "pos": Vector2(0.830, 0.400), "side": "right"},
{"label": "工坊", "icon": "tool", "pos": Vector2(0.752, 0.760), "side": "left"},
{"label": "公告", "icon": "notice", "pos": Vector2(0.288, 0.838), "side": "right"},
{"label": "入口", "icon": "gate", "pos": Vector2(0.500, 0.900), "side": "right"},
],
},
"WorkZone": {
"title": "打工区地图",
"sourceNodes": [
"StageBackdrop",
"RoadPavementLayer",
"RoadAsphaltLayer",
"RoadCrosswalkLayer",
"RoadMarkingLayer",
"YSortWorld",
],
"markers": [
{"label": "商城", "icon": "home", "pos": Vector2(0.500, 0.180), "side": "right"},
{"label": "咖啡店", "icon": "home", "pos": Vector2(0.092, 0.620), "side": "right"},
{"label": "任务", "icon": "notice", "pos": Vector2(0.304, 0.600), "side": "right"},
{"label": "课程", "icon": "notice", "pos": Vector2(0.694, 0.500), "side": "left"},
{"label": "AI站", "icon": "tool", "pos": Vector2(0.676, 0.785), "side": "left"},
{"label": "鲸币", "icon": "whale", "pos": Vector2(0.920, 0.785), "side": "left"},
{"label": "入口", "icon": "gate", "pos": Vector2(0.500, 0.900), "side": "right"},
],
},
"CafeInterior": {
"title": "鲸鱼咖啡馆",
"worldSize": Vector2(1536, 1024),
"sourceNodes": [
"StageBackdrop",
"CafeServiceHallBase",
],
"markers": [
{"label": "服务台", "icon": "whale", "pos": Vector2(0.500, 0.465), "side": "right"},
{"label": "陪伴区", "icon": "notice", "pos": Vector2(0.240, 0.280), "side": "right"},
{"label": "出口", "icon": "gate", "pos": Vector2(0.500, 0.855), "side": "right"},
],
},
}
var _panel: PanelContainer
var _mapFrame: PanelContainer
var _mapLayer: Control
var _mapViewport: SubViewport
var _mapWorld: Node2D
var _mapCamera: Camera2D
var _titleLabel: Label
var _markerNodes: Array[Control] = []
var _isOpen: bool = false
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
_build_ui()
set_panel_open(false)
_subscribe_to_events()
func _exit_tree() -> void:
var eventSystem := _get_event_system()
if eventSystem != null:
eventSystem.call("disconnect_event", EventNames.HUD_MAP_TOGGLE, _on_map_toggle, self)
func _notification(what: int) -> void:
if what == NOTIFICATION_RESIZED and is_instance_valid(_panel):
_anchor_panel()
func _input(event: InputEvent) -> void:
if not _isOpen:
return
if event is InputEventKey:
var keyEvent := event as InputEventKey
if keyEvent.pressed and not keyEvent.echo and keyEvent.keycode == KEY_ESCAPE:
set_panel_open(false)
get_viewport().set_input_as_handled()
func set_panel_open(open: bool) -> void:
_isOpen = open
visible = _isOpen
mouse_filter = Control.MOUSE_FILTER_STOP if _isOpen else Control.MOUSE_FILTER_IGNORE
if is_instance_valid(_panel):
_panel.visible = _isOpen
if _isOpen:
_rebuild_minimap_world()
func is_panel_open() -> bool:
return _isOpen
func toggle_panel() -> void:
set_panel_open(not _isOpen)
func _build_ui() -> void:
_panel = PanelContainer.new()
_panel.name = "MapCard"
_panel.custom_minimum_size = PANEL_SIZE
_panel.mouse_filter = Control.MOUSE_FILTER_STOP
_panel.add_theme_stylebox_override("panel", _create_panel_style())
add_child(_panel)
_anchor_panel()
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 24)
margin.add_theme_constant_override("margin_top", 20)
margin.add_theme_constant_override("margin_right", 24)
margin.add_theme_constant_override("margin_bottom", 22)
_panel.add_child(margin)
var content := VBoxContainer.new()
content.add_theme_constant_override("separation", 12)
margin.add_child(content)
content.add_child(_build_header())
content.add_child(_build_map_view())
func _build_header() -> Control:
var header := HBoxContainer.new()
header.custom_minimum_size = Vector2(0, 42)
header.alignment = BoxContainer.ALIGNMENT_CENTER
header.add_theme_constant_override("separation", 12)
var icon := MapGlyph.new()
icon.custom_minimum_size = Vector2(32, 32)
icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
header.add_child(icon)
var title := Label.new()
title.name = "TitleLabel"
_titleLabel = title
title.text = "小镇地图"
title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
title.add_theme_color_override("font_color", TEXT_COLOR)
title.add_theme_font_size_override("font_size", 24)
header.add_child(title)
var closeButton := Button.new()
closeButton.text = "×"
closeButton.tooltip_text = "关闭地图"
closeButton.custom_minimum_size = Vector2(42, 42)
closeButton.focus_mode = Control.FOCUS_NONE
closeButton.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
closeButton.add_theme_font_size_override("font_size", 28)
closeButton.add_theme_color_override("font_color", MUTED_COLOR)
closeButton.add_theme_color_override("font_hover_color", ACCENT_COLOR)
closeButton.add_theme_color_override("font_pressed_color", ACCENT_COLOR.darkened(0.18))
closeButton.add_theme_color_override("font_disabled_color", Color(0.520, 0.600, 0.680, 0.55))
closeButton.add_theme_stylebox_override("normal", _create_round_style(SOFT_BLUE, 21))
closeButton.add_theme_stylebox_override("hover", _create_round_style(Color(0.858, 0.925, 0.980, 1.0), 21))
closeButton.add_theme_stylebox_override("pressed", _create_round_style(Color(0.778, 0.884, 0.965, 1.0), 21))
closeButton.add_theme_stylebox_override("focus", StyleBoxEmpty.new())
closeButton.pressed.connect(func() -> void: set_panel_open(false))
header.add_child(closeButton)
return header
func _build_map_view() -> Control:
_mapFrame = PanelContainer.new()
_mapFrame.custom_minimum_size = MAP_VIEW_SIZE
_mapFrame.mouse_filter = Control.MOUSE_FILTER_STOP
_mapFrame.add_theme_stylebox_override("panel", _create_map_frame_style())
_mapLayer = Control.new()
_mapLayer.custom_minimum_size = MAP_VIEW_SIZE
_mapLayer.clip_contents = true
_mapLayer.mouse_filter = Control.MOUSE_FILTER_IGNORE
_mapFrame.add_child(_mapLayer)
var viewportContainer := SubViewportContainer.new()
viewportContainer.name = "SquareMapViewportContainer"
viewportContainer.set_anchors_preset(Control.PRESET_FULL_RECT)
viewportContainer.stretch = true
viewportContainer.mouse_filter = Control.MOUSE_FILTER_IGNORE
_mapLayer.add_child(viewportContainer)
_mapViewport = SubViewport.new()
_mapViewport.name = "SquareMapViewport"
_mapViewport.size = Vector2i(int(MAP_VIEW_SIZE.x), int(MAP_VIEW_SIZE.y))
_mapViewport.render_target_update_mode = SubViewport.UPDATE_WHEN_VISIBLE
_mapViewport.world_2d = World2D.new()
viewportContainer.add_child(_mapViewport)
_mapWorld = Node2D.new()
_mapWorld.name = "RenderedMapWorld"
_mapViewport.add_child(_mapWorld)
_mapCamera = Camera2D.new()
_mapCamera.name = "MinimapCamera"
_mapCamera.position = Vector2.ZERO
_mapCamera.zoom = MAP_CAMERA_ZOOM
_mapCamera.enabled = true
_mapViewport.add_child(_mapCamera)
var wash := ColorRect.new()
wash.name = "MapSoftWash"
wash.set_anchors_preset(Control.PRESET_FULL_RECT)
wash.color = Color(1.0, 0.985, 0.930, 0.10)
wash.mouse_filter = Control.MOUSE_FILTER_IGNORE
_mapLayer.add_child(wash)
_render_markers()
return _mapFrame
func _render_markers() -> void:
for markerNode in _markerNodes:
if is_instance_valid(markerNode):
markerNode.queue_free()
_markerNodes.clear()
for marker in _current_markers():
var markerNode := _create_marker(marker)
_markerNodes.append(markerNode)
_mapLayer.add_child(markerNode)
func _create_marker(marker: Dictionary) -> Control:
var button := Button.new()
button.name = "Marker%s" % str(marker.get("label", ""))
button.text = ""
button.focus_mode = Control.FOCUS_NONE
button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
button.custom_minimum_size = Vector2(82, 34)
button.add_theme_stylebox_override("normal", _create_marker_style(Color(1.0, 1.0, 1.0, 0.93), Color(0.650, 0.792, 0.910, 0.88)))
button.add_theme_stylebox_override("hover", _create_marker_style(Color(0.925, 0.966, 0.996, 0.98), ACCENT_COLOR))
button.add_theme_stylebox_override("pressed", _create_marker_style(Color(0.858, 0.925, 0.980, 0.98), ACCENT_COLOR.darkened(0.05)))
button.add_theme_stylebox_override("focus", StyleBoxEmpty.new())
var row := HBoxContainer.new()
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.set_anchors_preset(Control.PRESET_FULL_RECT)
row.offset_left = 8
row.offset_right = -9
row.alignment = BoxContainer.ALIGNMENT_CENTER
row.add_theme_constant_override("separation", 5)
button.add_child(row)
var icon := MarkerGlyph.new()
icon.set("iconName", str(marker.get("icon", "pin")))
icon.custom_minimum_size = Vector2(20, 20)
icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_child(icon)
var label := Label.new()
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
label.text = str(marker.get("label", "地点"))
label.add_theme_color_override("font_color", TEXT_COLOR)
label.add_theme_font_size_override("font_size", 14)
row.add_child(label)
var position := marker.get("pos", Vector2.ZERO) as Vector2
var buttonSize := button.custom_minimum_size
var xOffset := 8.0 if str(marker.get("side", "right")) == "right" else -buttonSize.x - 8.0
button.position = Vector2(
position.x * MAP_VIEW_SIZE.x + xOffset,
position.y * MAP_VIEW_SIZE.y - buttonSize.y * 0.5
)
return button
func _anchor_panel() -> void:
_panel.set_anchors_preset(Control.PRESET_TOP_RIGHT)
_panel.offset_left = -PANEL_SIZE.x - PANEL_MARGIN_RIGHT
_panel.offset_top = PANEL_MARGIN_TOP
_panel.offset_right = -PANEL_MARGIN_RIGHT
_panel.offset_bottom = PANEL_MARGIN_TOP + PANEL_SIZE.y
_panel.grow_horizontal = Control.GROW_DIRECTION_BEGIN
func _subscribe_to_events() -> void:
var eventSystem := _get_event_system()
if eventSystem == null:
push_warning("MapPanel: EventSystem autoload is not available.")
return
eventSystem.call("connect_event", EventNames.HUD_MAP_TOGGLE, _on_map_toggle, self)
func _on_map_toggle(_data: Variant = null) -> void:
toggle_panel()
func _rebuild_minimap_world() -> void:
if not is_instance_valid(_mapWorld):
return
_clear_children(_mapWorld)
var sourceScene := _find_source_scene()
if sourceScene == null:
push_warning("MapPanel: source scene for minimap is unavailable.")
return
_apply_scene_config(sourceScene)
for nodeName in _current_source_node_names(sourceScene):
var sourceNode := sourceScene.get_node_or_null(nodeName)
if sourceNode == null:
continue
var clone := sourceNode.duplicate(0)
if clone == null:
continue
_remove_excluded_minimap_nodes(clone)
_mapWorld.add_child(clone)
func _find_source_scene() -> Node:
var node: Node = self
while node != null:
if MAP_CONFIGS.has(str(node.name)):
return node
node = node.get_parent()
return get_tree().current_scene
func _apply_scene_config(sourceScene: Node) -> void:
var config := _map_config_for_scene(sourceScene)
if is_instance_valid(_titleLabel):
_titleLabel.text = str(config.get("title", "区域地图"))
_configure_map_camera(config)
_render_markers()
func _configure_map_camera(config: Dictionary) -> void:
if not is_instance_valid(_mapCamera):
return
var worldSize := config.get("worldSize", MAP_WORLD_SIZE) as Vector2
if worldSize.x <= 0.0 or worldSize.y <= 0.0:
worldSize = MAP_WORLD_SIZE
var zoomScale := minf(MAP_VIEW_SIZE.x / worldSize.x, MAP_VIEW_SIZE.y / worldSize.y)
_mapCamera.zoom = Vector2.ONE * zoomScale
func _current_source_node_names(sourceScene: Node) -> Array:
var config := _map_config_for_scene(sourceScene)
var nodeNames: Array = config.get("sourceNodes", [])
return nodeNames
func _current_markers() -> Array:
var config := _map_config_for_scene(_find_source_scene())
var markers: Array = config.get("markers", [])
return markers
func _map_config_for_scene(sourceScene: Node) -> Dictionary:
if sourceScene != null:
var sceneName := str(sourceScene.name)
if MAP_CONFIGS.has(sceneName):
return MAP_CONFIGS[sceneName]
return MAP_CONFIGS["Square"]
func _remove_excluded_minimap_nodes(root: Node) -> void:
for nodeName in MINIMAP_EXCLUDED_NODE_NAMES:
var node := root.find_child(nodeName, true, false)
if node != null and node.get_parent() != null:
node.get_parent().remove_child(node)
node.queue_free()
func _clear_children(container: Node) -> void:
for child in container.get_children():
container.remove_child(child)
child.queue_free()
func _create_panel_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(1.0, 1.0, 1.0, 0.948)
style.corner_radius_top_left = 28
style.corner_radius_top_right = 28
style.corner_radius_bottom_left = 28
style.corner_radius_bottom_right = 28
style.shadow_color = Color(0.12549, 0.282353, 0.407843, 0.18)
style.shadow_size = 22
style.shadow_offset = Vector2(0, 9)
return style
func _create_map_frame_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(0.918, 0.961, 0.992, 0.96)
style.border_color = Color(0.431, 0.733, 0.929, 0.86)
style.border_width_left = 2
style.border_width_top = 2
style.border_width_right = 2
style.border_width_bottom = 2
style.corner_radius_top_left = 16
style.corner_radius_top_right = 16
style.corner_radius_bottom_left = 16
style.corner_radius_bottom_right = 16
return style
func _create_marker_style(color: Color, borderColor: Color) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = color
style.border_color = borderColor
style.border_width_left = 2
style.border_width_top = 2
style.border_width_right = 2
style.border_width_bottom = 2
style.corner_radius_top_left = 18
style.corner_radius_top_right = 18
style.corner_radius_bottom_left = 18
style.corner_radius_bottom_right = 18
style.shadow_color = Color(0.078, 0.223, 0.360, 0.14)
style.shadow_size = 7
style.shadow_offset = Vector2(0, 3)
return style
func _create_round_style(color: Color, radius: int) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = color
style.corner_radius_top_left = radius
style.corner_radius_top_right = radius
style.corner_radius_bottom_left = radius
style.corner_radius_bottom_right = radius
return style
func _get_event_system() -> Node:
return get_node_or_null("/root/EventSystem")
class MapGlyph:
extends Control
func _draw() -> void:
var color := Color(0.258824, 0.627451, 0.913725)
var width := 2.2
draw_polyline(PackedVector2Array([
Vector2(6, 9), Vector2(12, 7), Vector2(19, 9), Vector2(26, 7),
Vector2(26, 25), Vector2(19, 27), Vector2(12, 25), Vector2(6, 27),
Vector2(6, 9)
]), color, width, true)
draw_line(Vector2(12, 8), Vector2(12, 24), color, 1.6, true)
draw_line(Vector2(19, 10), Vector2(19, 26), color, 1.6, true)
class MarkerGlyph:
extends Control
var iconName: String = "pin"
func _draw() -> void:
var color := Color(0.258824, 0.627451, 0.913725)
var center := size * 0.5
match iconName:
"anchor":
_draw_anchor(center, color)
"home":
_draw_home(center, color)
"whale":
_draw_whale(center, color)
"tool":
_draw_tool(center, color)
"notice":
_draw_notice(center, color)
"gate":
_draw_gate(center, color)
_:
draw_circle(center, 5.0, color)
func _draw_anchor(center: Vector2, color: Color) -> void:
draw_line(center + Vector2(0, -7), center + Vector2(0, 7), color, 2.0, true)
draw_circle(center + Vector2(0, -7), 2.8, color)
draw_arc(center + Vector2(0, 2), 7.0, 0.20, PI - 0.20, 24, color, 2.0, true)
draw_line(center + Vector2(-7, 3), center + Vector2(-10, 0), color, 2.0, true)
draw_line(center + Vector2(7, 3), center + Vector2(10, 0), color, 2.0, true)
func _draw_home(center: Vector2, color: Color) -> void:
draw_polyline(PackedVector2Array([
center + Vector2(-8, -1), center + Vector2(0, -8), center + Vector2(8, -1)
]), color, 2.0, true)
draw_rect(Rect2(center + Vector2(-6, -1), Vector2(12, 10)), color, false, 2.0)
draw_line(center + Vector2(-1, 9), center + Vector2(-1, 3), color, 2.0, true)
func _draw_whale(center: Vector2, color: Color) -> void:
draw_arc(center + Vector2(-1, 2), 8.0, PI, TAU, 28, color, 2.0, true)
draw_line(center + Vector2(6, 0), center + Vector2(11, -4), color, 2.0, true)
draw_line(center + Vector2(6, 0), center + Vector2(11, 4), color, 2.0, true)
draw_circle(center + Vector2(-4, 0), 1.2, color)
func _draw_tool(center: Vector2, color: Color) -> void:
draw_line(center + Vector2(-7, 7), center + Vector2(7, -7), color, 2.4, true)
draw_arc(center + Vector2(7, -7), 5.0, PI * 0.1, PI * 1.3, 18, color, 2.0, true)
draw_circle(center + Vector2(-7, 7), 2.4, color)
func _draw_notice(center: Vector2, color: Color) -> void:
draw_rect(Rect2(center + Vector2(-8, -7), Vector2(16, 13)), color, false, 2.0)
draw_line(center + Vector2(-5, -2), center + Vector2(5, -2), color, 1.7, true)
draw_line(center + Vector2(-5, 2), center + Vector2(2, 2), color, 1.7, true)
func _draw_gate(center: Vector2, color: Color) -> void:
draw_rect(Rect2(center + Vector2(-9, -3), Vector2(18, 11)), color, false, 2.0)
draw_line(center + Vector2(0, -3), center + Vector2(0, 8), color, 1.7, true)
draw_line(center + Vector2(-11, 8), center + Vector2(11, 8), color, 2.0, true)

View File

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

13
scenes/ui/MapPanel.tscn Normal file
View File

@@ -0,0 +1,13 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scenes/ui/MapPanel.gd" id="1"]
[node name="MapPanel" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
script = ExtResource("1")

129
scenes/ui/NoticeDialog.gd Normal file
View File

@@ -0,0 +1,129 @@
extends CanvasLayer
class_name NoticeDialog
const DEFAULT_PAGES: Array[Dictionary] = [
{
"text": "[center][color=#2f6b73]小镇新闻[/color][/center]\n\n线上新闻接口接入后,这里会同步 Datawhale Town 的最新动态、活动公告和版本消息。\n\n当前请先通过欢迎板查看新人引导手册。",
"image_path": "res://assets/maps/square/v1/props/center_whale_fountain_v2_hd_clean.png",
},
]
@onready var contentLabel: RichTextLabel = $CenterContainer/PanelContainer/VBoxContainer/ContentContainer/TextPanel/ContentLabel
@onready var prevButton: Button = $CenterContainer/PanelContainer/VBoxContainer/Footer/PrevButton
@onready var nextButton: Button = $CenterContainer/PanelContainer/VBoxContainer/Footer/NextButton
@onready var dotsContainer: HBoxContainer = $CenterContainer/PanelContainer/VBoxContainer/Footer/DotsContainer
@onready var contentContainer: Container = $CenterContainer/PanelContainer/VBoxContainer/ContentContainer
@onready var imageRect: TextureRect = $CenterContainer/PanelContainer/VBoxContainer/ContentContainer/ImagePanel/ImageRect
@onready var imageLabel: Label = $CenterContainer/PanelContainer/VBoxContainer/ContentContainer/ImagePanel/ImageLabel
var pages: Array[Dictionary] = DEFAULT_PAGES.duplicate(true)
var currentPage: int = 0
var tween: Tween
var _chatUi: Control
var _chatUiPrevMouseFilter: Control.MouseFilter = Control.MOUSE_FILTER_STOP
var _chatUiMouseDisabled: bool = false
func _ready() -> void:
get_tree().paused = true
_disableChatUiMouseInput()
var closeButton := $CenterContainer/PanelContainer/VBoxContainer/Header/RightContainer/CloseButton as Button
closeButton.pressed.connect(_onClosePressed)
prevButton.pressed.connect(_onPrevPressed)
nextButton.pressed.connect(_onNextPressed)
contentContainer.modulate.a = 1.0
_setupDots()
_updateUi(false)
func _exit_tree() -> void:
_restoreChatUiMouseInput()
func _setupDots() -> void:
for child in dotsContainer.get_children():
child.queue_free()
for index in range(pages.size()):
var dot := ColorRect.new()
dot.custom_minimum_size = Vector2(12, 12)
dotsContainer.add_child(dot)
func _updateUi(animate: bool = true) -> void:
if pages.is_empty():
return
prevButton.disabled = currentPage == 0
nextButton.disabled = currentPage == pages.size() - 1
var dots := dotsContainer.get_children()
for index in range(dots.size()):
var dot := dots[index] as ColorRect
if dot == null:
continue
if index == currentPage:
dot.color = Color(0.15, 0.36, 0.44, 1.0)
dot.custom_minimum_size = Vector2(18, 12)
else:
dot.color = Color(0.72, 0.78, 0.72, 1.0)
dot.custom_minimum_size = Vector2(12, 12)
if animate:
_animateContentChange()
else:
_setContentImmediate()
func _setContentImmediate() -> void:
var page := pages[currentPage]
contentLabel.text = page.get("text", "") as String
var imagePath := page.get("image_path", "") as String
if imagePath != "" and ResourceLoader.exists(imagePath):
imageRect.texture = load(imagePath) as Texture2D
imageLabel.visible = false
else:
imageRect.texture = null
imageLabel.visible = true
imageLabel.text = "暂无图片"
func _animateContentChange() -> void:
if tween != null and tween.is_valid():
tween.kill()
tween = create_tween()
tween.tween_property(contentContainer, "modulate:a", 0.0, 0.15)
tween.tween_callback(_setContentImmediate)
tween.tween_property(contentContainer, "modulate:a", 1.0, 0.15)
func _onPrevPressed() -> void:
if currentPage > 0:
currentPage -= 1
_updateUi()
func _onNextPressed() -> void:
if currentPage < pages.size() - 1:
currentPage += 1
_updateUi()
func _onClosePressed() -> void:
get_tree().paused = false
queue_free()
func _disableChatUiMouseInput() -> void:
var currentScene := get_tree().current_scene
if currentScene != null:
_chatUi = currentScene.get_node_or_null("UILayer/ChatUI") as Control
if _chatUi == null:
_chatUi = get_tree().root.find_child("ChatUI", true, false) as Control
if _chatUi != null:
_chatUiPrevMouseFilter = _chatUi.mouse_filter
_chatUi.mouse_filter = Control.MOUSE_FILTER_IGNORE
_chatUiMouseDisabled = true
func _restoreChatUiMouseInput() -> void:
if _chatUiMouseDisabled and is_instance_valid(_chatUi):
_chatUi.mouse_filter = _chatUiPrevMouseFilter
_chatUiMouseDisabled = false
_chatUi = null

View File

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

489
scenes/ui/PlayerHud.gd Normal file
View File

@@ -0,0 +1,489 @@
extends Control
# ============================================================================
# PlayerHud.gd - 右上角玩家头像与快捷入口
# ============================================================================
# 展示当前玩家头像入口,以及地图/任务/背包/好友/设置入口。
# 好友入口只负责打开或收起右下角好友列表。
# ============================================================================
const HUD_MARGIN: Vector2 = Vector2(16, 16)
const SHORTCUT_BAR_SIZE: Vector2 = Vector2(350, 86)
const PLAYER_PROFILE_BUTTON_SIZE: Vector2 = Vector2(210, 78)
const PLAYER_AVATAR_SIZE: Vector2 = Vector2(58, 58)
const HUD_SEPARATION: int = 18
const HUD_TOTAL_WIDTH: float = SHORTCUT_BAR_SIZE.x + PLAYER_PROFILE_BUTTON_SIZE.x + HUD_SEPARATION
const TEXT_COLOR: Color = Color(0.188, 0.294, 0.424)
const MUTED_COLOR: Color = Color(0.560, 0.639, 0.733)
const ACCENT_COLOR: Color = Color(0.258824, 0.627451, 0.913725)
const WALLET_REFRESH_INTERVAL: float = 15.0
const HUD_SHORTCUT_ICON_SCRIPT: Script = preload("res://scenes/ui/HudShortcutIcon.gd")
var _usernameLabel: Label
var _walletLabel: Label
var _avatarLabel: Label
var _avatarPanel: PanelContainer
var _walletRefreshTimer: Timer
var _currentUsername: String = "玩家"
var _walletBalance: int = 0
var _walletLoaded: bool = false
var _walletLoading: bool = false
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
_build_ui()
_build_wallet_refresh_timer()
_subscribe_to_events()
_load_current_user()
_refresh_wallet()
func _exit_tree() -> void:
var eventSystem := _get_event_system()
if eventSystem != null:
eventSystem.call("disconnect_event", EventNames.CHAT_LOGIN_SUCCESS, _on_chat_login_success, self)
eventSystem.call("disconnect_event", EventNames.AUTH_LOGIN_SUCCESS, _on_auth_login_success, self)
eventSystem.call("disconnect_event", EventNames.AUTH_REGISTER_SUCCESS, _on_auth_register_success, self)
eventSystem.call("disconnect_event", EventNames.AUTH_LOGOUT, _on_auth_logout, self)
eventSystem.call("disconnect_event", EventNames.APPEARANCE_AVATAR_CHANGED, _on_appearance_avatar_changed, self)
eventSystem.call("disconnect_event", EventNames.MALL_PURCHASE_SUCCEEDED, _on_wallet_mutation_event, self)
eventSystem.call("disconnect_event", EventNames.CAFE_COMPANION_CHAT_TIME_PURCHASED, _on_wallet_mutation_event, self)
eventSystem.call("disconnect_event", EventNames.CAFE_COMPANION_EMPLOYMENT_RESIGNED, _on_wallet_mutation_event, self)
var playerStateManager := get_node_or_null("/root/PlayerStateManager")
if playerStateManager != null and playerStateManager.has_signal("wallet_changed"):
var walletCallback := Callable(self, "_on_player_wallet_changed")
if playerStateManager.is_connected("wallet_changed", walletCallback):
playerStateManager.disconnect("wallet_changed", walletCallback)
var authManager := get_node_or_null("/root/AuthManager")
if authManager != null and authManager.has_signal("auth_state_changed"):
var callback := Callable(self, "_on_auth_state_changed")
if authManager.is_connected("auth_state_changed", callback):
authManager.disconnect("auth_state_changed", callback)
func _build_ui() -> void:
var rootRow := HBoxContainer.new()
rootRow.name = "HudRoot"
rootRow.set_anchors_preset(Control.PRESET_TOP_RIGHT)
rootRow.offset_left = -HUD_TOTAL_WIDTH - HUD_MARGIN.x
rootRow.offset_top = HUD_MARGIN.y
rootRow.offset_right = -HUD_MARGIN.x
rootRow.add_theme_constant_override("separation", HUD_SEPARATION)
add_child(rootRow)
rootRow.add_child(_build_shortcut_bar())
rootRow.add_child(_build_player_avatar_button())
func _build_shortcut_bar() -> PanelContainer:
var panel := PanelContainer.new()
panel.custom_minimum_size = SHORTCUT_BAR_SIZE
panel.mouse_filter = Control.MOUSE_FILTER_STOP
panel.add_theme_stylebox_override("panel", _create_panel_style(32))
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 18)
margin.add_theme_constant_override("margin_top", 12)
margin.add_theme_constant_override("margin_right", 18)
margin.add_theme_constant_override("margin_bottom", 12)
panel.add_child(margin)
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_CENTER
row.add_theme_constant_override("separation", 8)
margin.add_child(row)
row.add_child(_create_shortcut_button("map", "地图", _on_map_pressed))
row.add_child(_create_shortcut_button("task", "任务", _on_task_pressed))
row.add_child(_create_shortcut_button("backpack", "背包", _on_backpack_pressed))
row.add_child(_create_shortcut_button("friends", "好友", _on_friends_pressed))
row.add_child(_create_shortcut_button("settings", "设置", _on_settings_pressed))
return panel
func _build_player_avatar_button() -> Button:
var button := Button.new()
button.custom_minimum_size = PLAYER_PROFILE_BUTTON_SIZE
button.tooltip_text = "角色设置"
button.focus_mode = Control.FOCUS_NONE
button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
button.text = ""
button.add_theme_stylebox_override("normal", _create_avatar_button_style(Color(1, 1, 1, 0.94), 39))
button.add_theme_stylebox_override("hover", _create_avatar_button_style(Color(0.918, 0.961, 0.992, 0.98), 39))
button.add_theme_stylebox_override("pressed", _create_avatar_button_style(Color(0.858, 0.925, 0.980, 1.0), 39))
button.add_theme_stylebox_override("focus", _create_empty_style())
button.pressed.connect(_on_avatar_pressed)
var margin := MarginContainer.new()
margin.mouse_filter = Control.MOUSE_FILTER_IGNORE
margin.set_anchors_preset(Control.PRESET_FULL_RECT)
margin.add_theme_constant_override("margin_left", 10)
margin.add_theme_constant_override("margin_top", 10)
margin.add_theme_constant_override("margin_right", 14)
margin.add_theme_constant_override("margin_bottom", 10)
button.add_child(margin)
var row := HBoxContainer.new()
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.alignment = BoxContainer.ALIGNMENT_CENTER
row.add_theme_constant_override("separation", 10)
margin.add_child(row)
var avatar := PanelContainer.new()
_avatarPanel = avatar
avatar.custom_minimum_size = PLAYER_AVATAR_SIZE
avatar.mouse_filter = Control.MOUSE_FILTER_IGNORE
avatar.clip_contents = true
row.add_child(avatar)
_avatarLabel = Label.new()
_avatarLabel.mouse_filter = Control.MOUSE_FILTER_IGNORE
_avatarLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_avatarLabel.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_avatarLabel.add_theme_color_override("font_color", Color.WHITE)
_avatarLabel.add_theme_font_size_override("font_size", 22)
avatar.add_child(_avatarLabel)
_apply_current_avatar()
_usernameLabel = Label.new()
_usernameLabel.mouse_filter = Control.MOUSE_FILTER_IGNORE
_usernameLabel.text = _currentUsername
_usernameLabel.custom_minimum_size = Vector2(96, 0)
_usernameLabel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_usernameLabel.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_usernameLabel.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
_usernameLabel.add_theme_color_override("font_color", TEXT_COLOR)
_usernameLabel.add_theme_font_size_override("font_size", 18)
var info := VBoxContainer.new()
info.mouse_filter = Control.MOUSE_FILTER_IGNORE
info.size_flags_horizontal = Control.SIZE_EXPAND_FILL
info.alignment = BoxContainer.ALIGNMENT_CENTER
info.add_theme_constant_override("separation", 2)
row.add_child(info)
info.add_child(_usernameLabel)
_walletLabel = Label.new()
_walletLabel.mouse_filter = Control.MOUSE_FILTER_IGNORE
_walletLabel.text = "鲸币 --"
_walletLabel.custom_minimum_size = Vector2(112, 0)
_walletLabel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_walletLabel.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
_walletLabel.add_theme_color_override("font_color", ACCENT_COLOR)
_walletLabel.add_theme_font_size_override("font_size", 14)
info.add_child(_walletLabel)
return button
func _create_shortcut_button(iconName: String, text: String, callback: Callable) -> Button:
var button := Button.new()
button.custom_minimum_size = Vector2(56, 60)
button.focus_mode = Control.FOCUS_NONE
button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
button.text = ""
button.add_theme_stylebox_override("normal", _create_empty_style())
button.add_theme_stylebox_override("hover", _create_pill_style(Color(0.918, 0.961, 0.992, 0.86), 18))
button.add_theme_stylebox_override("pressed", _create_pill_style(Color(0.858, 0.925, 0.980, 0.98), 18))
button.add_theme_stylebox_override("focus", _create_empty_style())
button.pressed.connect(callback)
var content := VBoxContainer.new()
content.mouse_filter = Control.MOUSE_FILTER_IGNORE
content.set_anchors_preset(Control.PRESET_FULL_RECT)
content.alignment = BoxContainer.ALIGNMENT_CENTER
content.add_theme_constant_override("separation", 1)
button.add_child(content)
var icon := HUD_SHORTCUT_ICON_SCRIPT.new() as Control
icon.set("iconName", iconName)
icon.custom_minimum_size = Vector2(31, 31)
content.add_child(icon)
var label := Label.new()
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
label.text = text
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.add_theme_color_override("font_color", MUTED_COLOR)
label.add_theme_font_size_override("font_size", 14)
content.add_child(label)
button.mouse_entered.connect(func() -> void:
if is_instance_valid(label):
label.add_theme_color_override("font_color", ACCENT_COLOR)
)
button.mouse_exited.connect(func() -> void:
if is_instance_valid(label):
label.add_theme_color_override("font_color", MUTED_COLOR)
)
return button
func _subscribe_to_events() -> void:
var eventSystem := _get_event_system()
if eventSystem == null:
push_warning("PlayerHud: EventSystem autoload is not available.")
return
eventSystem.call("connect_event", EventNames.CHAT_LOGIN_SUCCESS, _on_chat_login_success, self)
eventSystem.call("connect_event", EventNames.AUTH_LOGIN_SUCCESS, _on_auth_login_success, self)
eventSystem.call("connect_event", EventNames.AUTH_REGISTER_SUCCESS, _on_auth_register_success, self)
eventSystem.call("connect_event", EventNames.AUTH_LOGOUT, _on_auth_logout, self)
eventSystem.call("connect_event", EventNames.APPEARANCE_AVATAR_CHANGED, _on_appearance_avatar_changed, self)
eventSystem.call("connect_event", EventNames.MALL_PURCHASE_SUCCEEDED, _on_wallet_mutation_event, self)
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_CHAT_TIME_PURCHASED, _on_wallet_mutation_event, self)
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_EMPLOYMENT_RESIGNED, _on_wallet_mutation_event, self)
var playerStateManager := get_node_or_null("/root/PlayerStateManager")
if playerStateManager != null and playerStateManager.has_signal("wallet_changed"):
var walletCallback := Callable(self, "_on_player_wallet_changed")
if not playerStateManager.is_connected("wallet_changed", walletCallback):
playerStateManager.connect("wallet_changed", walletCallback)
var authManager := get_node_or_null("/root/AuthManager")
if authManager != null and authManager.has_signal("auth_state_changed"):
var callback := Callable(self, "_on_auth_state_changed")
if not authManager.is_connected("auth_state_changed", callback):
authManager.connect("auth_state_changed", callback)
func _load_current_user() -> void:
var authManager := get_node_or_null("/root/AuthManager")
if authManager != null and authManager.has_method("get_current_username"):
var username := str(authManager.call("get_current_username")).strip_edges()
if not username.is_empty():
_set_username(username)
func _on_chat_login_success(data: Dictionary) -> void:
var username := str(data.get("username", "")).strip_edges()
if not username.is_empty():
_set_username(username)
func _on_auth_login_success(data: Dictionary) -> void:
_apply_auth_user_payload(data)
func _on_auth_register_success(data: Dictionary) -> void:
_apply_auth_user_payload(data)
func _on_auth_state_changed(isAuthenticated: bool, user: Dictionary) -> void:
if not isAuthenticated:
_set_username("玩家")
_reset_wallet()
return
_apply_auth_user_payload({"user": user})
_refresh_wallet()
func _apply_auth_user_payload(data: Dictionary) -> void:
var userVariant: Variant = data.get("user", {})
if userVariant is Dictionary:
var username := str((userVariant as Dictionary).get("username", "")).strip_edges()
if not username.is_empty():
_set_username(username)
func _on_auth_logout(_data: Variant = null) -> void:
_set_username("玩家")
_reset_wallet()
func _on_appearance_avatar_changed(_data: Dictionary) -> void:
_apply_current_avatar()
func _set_username(username: String) -> void:
_currentUsername = username.strip_edges()
if _currentUsername.is_empty():
_currentUsername = "玩家"
if is_instance_valid(_usernameLabel):
_usernameLabel.text = _currentUsername
_apply_current_avatar()
func _on_wallet_mutation_event(data: Dictionary) -> void:
var balanceVariant: Variant = data.get("balance", null)
if balanceVariant != null and (balanceVariant is int or balanceVariant is float):
_apply_wallet_balance(int(balanceVariant))
return
_refresh_wallet()
func _build_wallet_refresh_timer() -> void:
_walletRefreshTimer = Timer.new()
_walletRefreshTimer.name = "HudWalletRefreshTimer"
_walletRefreshTimer.wait_time = WALLET_REFRESH_INTERVAL
_walletRefreshTimer.autostart = true
_walletRefreshTimer.timeout.connect(_on_wallet_refresh_timer_timeout)
add_child(_walletRefreshTimer)
func _on_wallet_refresh_timer_timeout() -> void:
_refresh_wallet()
func _refresh_wallet() -> void:
if not _is_wallet_available():
_reset_wallet()
return
var playerStateManager := get_node_or_null("/root/PlayerStateManager")
if playerStateManager == null:
_reset_wallet()
return
if playerStateManager.has_method("get_wallet"):
var walletVariant: Variant = playerStateManager.call("get_wallet")
if walletVariant is Dictionary and not (walletVariant as Dictionary).is_empty():
_on_player_wallet_changed(walletVariant as Dictionary)
return
if _walletLoading:
return
_walletLoading = true
_update_wallet_label()
if playerStateManager.has_method("refresh_snapshot"):
playerStateManager.call("refresh_snapshot")
func _on_player_wallet_changed(wallet: Dictionary) -> void:
if wallet.is_empty():
_reset_wallet()
return
_apply_wallet_balance(int(wallet.get("balance", 0)))
func _apply_wallet_balance(balance: int) -> void:
_walletBalance = max(0, balance)
_walletLoaded = true
_walletLoading = false
_update_wallet_label()
func _reset_wallet() -> void:
_walletBalance = 0
_walletLoaded = false
_walletLoading = false
_update_wallet_label()
func _update_wallet_label(overrideText: String = "") -> void:
if not is_instance_valid(_walletLabel):
return
if not overrideText.is_empty():
_walletLabel.text = "鲸币 %s" % overrideText
_walletLabel.tooltip_text = "鲸币余额"
return
if _walletLoading:
_walletLabel.text = "鲸币 读取中"
elif _walletLoaded:
_walletLabel.text = "鲸币 %s" % _format_number(_walletBalance)
else:
_walletLabel.text = "鲸币 --"
_walletLabel.tooltip_text = "鲸币余额"
func _format_number(value: int) -> String:
var text := str(value)
var result := ""
var count := 0
for i in range(text.length() - 1, -1, -1):
if count > 0 and count % 3 == 0:
result = "," + result
result = text[i] + result
count += 1
return result
func _is_wallet_available() -> bool:
var authManager := get_node_or_null("/root/AuthManager")
return authManager != null and authManager.has_method("is_authenticated") and bool(authManager.call("is_authenticated")) and authManager.has_method("get_access_token")
func _on_map_pressed() -> void:
var eventSystem := _get_event_system()
if eventSystem != null:
eventSystem.call("emit_event", EventNames.HUD_MAP_TOGGLE, {})
func _on_task_pressed() -> void:
_emit_status_message("任务入口稍后接入")
func _on_backpack_pressed() -> void:
var eventSystem := _get_event_system()
if eventSystem != null:
eventSystem.call("emit_event", EventNames.HUD_BACKPACK_TOGGLE, {})
func _on_friends_pressed() -> void:
var eventSystem := _get_event_system()
if eventSystem != null:
eventSystem.call("emit_event", EventNames.HUD_FRIEND_LIST_TOGGLE, {})
func _on_settings_pressed() -> void:
var eventSystem := _get_event_system()
if eventSystem != null:
eventSystem.call("emit_event", EventNames.HUD_SETTINGS_REQUESTED, {})
func _on_avatar_pressed() -> void:
var eventSystem := _get_event_system()
if eventSystem != null:
eventSystem.call("emit_event", EventNames.HUD_SETTINGS_REQUESTED, {})
func _emit_status_message(message: String) -> void:
var eventSystem := _get_event_system()
if eventSystem != null:
eventSystem.call("emit_event", EventNames.CHAT_MESSAGE_RECEIVED, {
"from_user": "系统",
"content": message,
"timestamp": Time.get_unix_time_from_system(),
"is_self": false,
"scope": "system",
"tab": "world"
})
func _create_panel_style(radius: int) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(1, 1, 1, 0.925)
style.corner_radius_top_left = radius
style.corner_radius_top_right = radius
style.corner_radius_bottom_left = radius
style.corner_radius_bottom_right = radius
style.shadow_color = Color(0.12549, 0.282353, 0.407843, 0.15)
style.shadow_size = 18
style.shadow_offset = Vector2(0, 7)
return style
func _create_avatar_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(0.361, 0.690, 0.914)
style.corner_radius_top_left = 32
style.corner_radius_top_right = 32
style.corner_radius_bottom_left = 32
style.corner_radius_bottom_right = 32
return style
func _apply_current_avatar() -> void:
if not is_instance_valid(_avatarPanel) or not is_instance_valid(_avatarLabel):
return
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", _avatarPanel, _avatarLabel, "", _currentUsername)
_avatarPanel.add_theme_stylebox_override("panel", _create_round_avatar_style(_avatarPanel))
return
_avatarPanel.add_theme_stylebox_override("panel", _create_avatar_style())
_avatarLabel.visible = true
_avatarLabel.text = _currentUsername.substr(0, 1).to_upper()
func _create_avatar_button_style(color: Color, radius: int) -> StyleBoxFlat:
var style := _create_pill_style(color, radius)
style.shadow_color = Color(0.12549, 0.282353, 0.407843, 0.14)
style.shadow_size = 16
style.shadow_offset = Vector2(0, 6)
return style
func _create_round_avatar_style(panel: PanelContainer) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(0.361, 0.690, 0.914)
var radius := int(round(minf(panel.custom_minimum_size.x, panel.custom_minimum_size.y) * 0.5))
style.corner_radius_top_left = radius
style.corner_radius_top_right = radius
style.corner_radius_bottom_left = radius
style.corner_radius_bottom_right = radius
style.border_width_left = 2
style.border_width_top = 2
style.border_width_right = 2
style.border_width_bottom = 2
style.border_color = Color(1, 1, 1, 0.78)
return style
func _create_pill_style(color: Color, radius: int) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = color
style.corner_radius_top_left = radius
style.corner_radius_top_right = radius
style.corner_radius_bottom_left = radius
style.corner_radius_bottom_right = radius
style.content_margin_left = 8
style.content_margin_right = 8
style.content_margin_top = 5
style.content_margin_bottom = 5
return style
func _create_empty_style() -> StyleBoxEmpty:
return StyleBoxEmpty.new()
func _get_event_system() -> Node:
return get_node_or_null("/root/EventSystem")

View File

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

13
scenes/ui/PlayerHud.tscn Normal file
View File

@@ -0,0 +1,13 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scenes/ui/PlayerHud.gd" id="1"]
[node name="PlayerHud" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
script = ExtResource("1")

1315
scenes/ui/SettingsPanel.gd Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
uid://3vltb77vwxy

View File

@@ -0,0 +1,13 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scenes/ui/SettingsPanel.gd" id="1_settings"]
[node name="SettingsPanel" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
script = ExtResource("1_settings")

View File

@@ -0,0 +1,208 @@
extends Control
# ============================================================================
# SettingsPanelIcon.gd - 设置面板细线图标
# ============================================================================
# 用代码绘制设置面板内的小图标,保持和右上角 HUD 一致的轻线条风格。
# ============================================================================
const BASE_SIZE: float = 32.0
const INACTIVE_COLOR: Color = Color(0.356, 0.525, 0.690, 0.86)
const ACTIVE_COLOR: Color = Color(0.259, 0.627, 0.914, 1.0)
const MUTED_COLOR: Color = Color(0.650, 0.745, 0.827, 0.72)
const LINE_WIDTH: float = 1.25
const DETAIL_WIDTH: float = 1.05
@export var iconName: String = "basic"
@export var active: bool = false
var _iconScale: float = 1.0
var _iconOffset: Vector2 = Vector2.ZERO
func _ready() -> void:
custom_minimum_size = Vector2(32, 32)
mouse_filter = Control.MOUSE_FILTER_IGNORE
func set_active(value: bool) -> void:
active = value
queue_redraw()
func _draw() -> void:
_iconScale = min(size.x, size.y) / BASE_SIZE
_iconOffset = (size - Vector2(BASE_SIZE, BASE_SIZE) * _iconScale) * 0.5
match iconName:
"basic":
_draw_basic()
"audio":
_draw_audio()
"chat":
_draw_chat()
"controls":
_draw_controls()
"account":
_draw_account()
"volume":
_draw_volume()
"music":
_draw_music()
"effects":
_draw_effects()
"window":
_draw_window()
"eye":
_draw_eye()
"keyboard":
_draw_keyboard()
"whale":
_draw_whale()
_:
_draw_basic()
func _draw_basic() -> void:
_draw_polyline([
Vector2(7.0, 17.0),
Vector2(16.0, 8.6),
Vector2(25.0, 17.0)
])
_draw_round_rect(Rect2(9.6, 16.3, 12.8, 9.0), 1.8)
_draw_line(Vector2(16.0, 20.4), Vector2(16.0, 25.0), DETAIL_WIDTH)
func _draw_audio() -> void:
_draw_music()
_draw_line(Vector2(21.0, 8.0), Vector2(24.4, 11.4), DETAIL_WIDTH)
_draw_line(Vector2(24.4, 11.4), Vector2(21.0, 14.8), DETAIL_WIDTH)
func _draw_chat() -> void:
_draw_round_rect(Rect2(6.5, 8.5, 19.0, 14.0), 4.0)
_draw_polyline([
Vector2(12.0, 22.0),
Vector2(10.0, 26.0),
Vector2(16.0, 22.5)
], DETAIL_WIDTH)
_draw_line(Vector2(11.0, 14.2), Vector2(21.0, 14.2), DETAIL_WIDTH)
_draw_line(Vector2(11.0, 18.0), Vector2(18.4, 18.0), DETAIL_WIDTH)
func _draw_controls() -> void:
_draw_round_rect(Rect2(5.7, 12.0, 20.6, 10.8), 4.8)
_draw_line(Vector2(10.5, 15.2), Vector2(10.5, 19.4), DETAIL_WIDTH)
_draw_line(Vector2(8.4, 17.3), Vector2(12.6, 17.3), DETAIL_WIDTH)
draw_circle(_p(Vector2(19.6, 16.0)), 1.15 * _iconScale, _color())
draw_circle(_p(Vector2(22.0, 19.0)), 1.15 * _iconScale, _color())
func _draw_account() -> void:
_draw_arc(Vector2(16.0, 12.0), 4.4, 0.0, TAU, 32, LINE_WIDTH)
_draw_arc_poly(Vector2(16.0, 28.5), 10.2, PI + 0.20, TAU - 0.20, 28)
func _draw_volume() -> void:
_draw_polyline([
Vector2(6.5, 14.0),
Vector2(11.0, 14.0),
Vector2(17.2, 9.0),
Vector2(17.2, 23.0),
Vector2(11.0, 18.0),
Vector2(6.5, 18.0),
Vector2(6.5, 14.0)
])
_draw_arc(Vector2(18.0, 16.0), 5.6, -0.62, 0.62, 14, DETAIL_WIDTH)
_draw_arc(Vector2(18.0, 16.0), 8.8, -0.56, 0.56, 16, DETAIL_WIDTH)
func _draw_music() -> void:
_draw_line(Vector2(12.0, 9.0), Vector2(12.0, 22.0), LINE_WIDTH)
_draw_line(Vector2(22.0, 7.0), Vector2(22.0, 20.2), LINE_WIDTH)
_draw_line(Vector2(12.0, 9.0), Vector2(22.0, 7.0), LINE_WIDTH)
_draw_line(Vector2(12.0, 12.5), Vector2(22.0, 10.5), DETAIL_WIDTH)
_draw_arc(Vector2(9.6, 23.0), 3.0, 0.0, TAU, 24, LINE_WIDTH)
_draw_arc(Vector2(19.6, 21.0), 3.0, 0.0, TAU, 24, LINE_WIDTH)
func _draw_effects() -> void:
_draw_polyline([
Vector2(16.0, 6.5),
Vector2(18.4, 13.2),
Vector2(25.5, 13.4),
Vector2(19.8, 17.7),
Vector2(21.8, 24.6),
Vector2(16.0, 20.4),
Vector2(10.2, 24.6),
Vector2(12.2, 17.7),
Vector2(6.5, 13.4),
Vector2(13.6, 13.2),
Vector2(16.0, 6.5)
])
func _draw_window() -> void:
_draw_round_rect(Rect2(6.0, 8.0, 20.0, 16.0), 2.3)
_draw_line(Vector2(6.6, 12.2), Vector2(25.4, 12.2), DETAIL_WIDTH)
_draw_line(Vector2(10.0, 16.2), Vector2(16.0, 16.2), DETAIL_WIDTH)
_draw_line(Vector2(10.0, 19.6), Vector2(20.8, 19.6), DETAIL_WIDTH)
func _draw_eye() -> void:
_draw_arc_poly(Vector2(16.0, 17.0), 10.2, PI + 0.34, TAU - 0.34, 28)
_draw_arc_poly(Vector2(16.0, 10.8), 10.2, 0.34, PI - 0.34, 28)
_draw_arc(Vector2(16.0, 14.0), 2.8, 0.0, TAU, 24, DETAIL_WIDTH)
func _draw_keyboard() -> void:
_draw_round_rect(Rect2(5.5, 9.0, 21.0, 14.0), 2.5)
for y in [13.0, 17.0]:
_draw_line(Vector2(9.0, y), Vector2(23.0, y), DETAIL_WIDTH)
_draw_line(Vector2(11.0, 20.3), Vector2(21.0, 20.3), DETAIL_WIDTH)
func _draw_whale() -> void:
var bodyColor := Color(0.424, 0.713, 0.918, 1.0)
var finColor := Color(0.306, 0.612, 0.847, 1.0)
var white := Color(1.0, 1.0, 1.0, 0.95)
draw_arc(_p(Vector2(16.0, 18.0)), 9.0 * _iconScale, PI * 0.04, PI * 1.08, 40, bodyColor, 5.5 * _iconScale, true)
draw_circle(_p(Vector2(16.0, 18.0)), 6.8 * _iconScale, bodyColor)
draw_circle(_p(Vector2(18.2, 15.4)), 0.9 * _iconScale, Color(0.160, 0.286, 0.420, 0.9))
_draw_line_colored(Vector2(8.3, 18.2), Vector2(4.5, 14.8), finColor, 1.8)
_draw_line_colored(Vector2(8.4, 18.4), Vector2(4.6, 21.4), finColor, 1.8)
_draw_arc_colored(Vector2(15.0, 8.4), 4.0, PI * 1.05, PI * 1.80, 14, finColor, 1.5)
_draw_arc_colored(Vector2(18.0, 8.4), 4.0, PI * 1.20, PI * 1.95, 14, finColor, 1.5)
draw_circle(_p(Vector2(20.8, 22.0)), 1.1 * _iconScale, white)
draw_circle(_p(Vector2(12.7, 22.2)), 1.0 * _iconScale, white)
func _draw_round_rect(rect: Rect2, radius: float, width: float = LINE_WIDTH) -> void:
var left := rect.position.x
var top := rect.position.y
var right := rect.end.x
var bottom := rect.end.y
_draw_line(Vector2(left + radius, top), Vector2(right - radius, top), width)
_draw_line(Vector2(right, top + radius), Vector2(right, bottom - radius), width)
_draw_line(Vector2(right - radius, bottom), Vector2(left + radius, bottom), width)
_draw_line(Vector2(left, bottom - radius), Vector2(left, top + radius), width)
_draw_arc(Vector2(left + radius, top + radius), radius, PI, PI * 1.5, 8, width)
_draw_arc(Vector2(right - radius, top + radius), radius, PI * 1.5, TAU, 8, width)
_draw_arc(Vector2(right - radius, bottom - radius), radius, 0.0, PI * 0.5, 8, width)
_draw_arc(Vector2(left + radius, bottom - radius), radius, PI * 0.5, PI, 8, width)
func _draw_arc_poly(center: Vector2, radius: float, start: float, end: float, pointCount: int, width: float = LINE_WIDTH) -> void:
var points: PackedVector2Array = []
for i in range(pointCount + 1):
var t := float(i) / float(pointCount)
var angle := lerpf(start, end, t)
points.append(_p(center + Vector2(cos(angle), sin(angle)) * radius))
draw_polyline(points, _color(), width * _iconScale, true)
func _draw_polyline(points: Array[Vector2], width: float = LINE_WIDTH) -> void:
var packed: PackedVector2Array = []
for point in points:
packed.append(_p(point))
draw_polyline(packed, _color(), width * _iconScale, true)
func _draw_line(from: Vector2, to: Vector2, width: float) -> void:
draw_line(_p(from), _p(to), _color(), width * _iconScale, true)
func _draw_line_colored(from: Vector2, to: Vector2, color: Color, width: float) -> void:
draw_line(_p(from), _p(to), color, width * _iconScale, true)
func _draw_arc(center: Vector2, radius: float, startAngle: float, endAngle: float, pointCount: int, width: float) -> void:
draw_arc(_p(center), radius * _iconScale, startAngle, endAngle, pointCount, _color(), width * _iconScale, true)
func _draw_arc_colored(center: Vector2, radius: float, startAngle: float, endAngle: float, pointCount: int, color: Color, width: float) -> void:
draw_arc(_p(center), radius * _iconScale, startAngle, endAngle, pointCount, color, width * _iconScale, true)
func _p(point: Vector2) -> Vector2:
return _iconOffset + point * _iconScale
func _color() -> Color:
return ACTIVE_COLOR if active else INACTIVE_COLOR

View File

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

View File

@@ -0,0 +1,83 @@
extends Control
# ============================================================================
# SettingsSlider.gd - 设置面板自绘滑条
# ============================================================================
# 使用白色圆形滑块和柔和轨道,贴近概念图里的轻量滑条样式。
# ============================================================================
signal value_changed(value: float)
const TRACK_COLOR: Color = Color(0.842, 0.894, 0.936, 0.95)
const FILL_COLOR: Color = Color(0.258824, 0.627451, 0.913725, 1.0)
const KNOB_COLOR: Color = Color(1.0, 1.0, 1.0, 1.0)
const KNOB_SHADOW_COLOR: Color = Color(0.125, 0.282, 0.408, 0.16)
var value: float = 1.0
var _dragging: bool = false
func _ready() -> void:
custom_minimum_size = Vector2(360, 34)
mouse_filter = Control.MOUSE_FILTER_STOP
mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
func set_value(newValue: float, emitSignal: bool = false) -> void:
var clamped: float = clampf(newValue, 0.0, 1.0)
if abs(value - clamped) < 0.001:
return
value = clamped
queue_redraw()
if emitSignal:
value_changed.emit(value)
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton:
var mouseEvent := event as InputEventMouseButton
if mouseEvent.button_index != MOUSE_BUTTON_LEFT:
return
_dragging = mouseEvent.pressed
if _dragging:
_update_from_x(mouseEvent.position.x)
accept_event()
return
if event is InputEventMouseMotion and _dragging:
var motionEvent := event as InputEventMouseMotion
_update_from_x(motionEvent.position.x)
accept_event()
func _draw() -> void:
var trackHeight: float = 8.0
var knobRadius: float = 12.0
var left: float = knobRadius + 1.0
var right: float = size.x - knobRadius - 1.0
var centerY: float = size.y * 0.5
var width: float = max(1.0, right - left)
var knobX: float = left + width * value
var trackRect: Rect2 = Rect2(Vector2(left, centerY - trackHeight * 0.5), Vector2(width, trackHeight))
draw_style_box(_round_box(TRACK_COLOR, int(trackHeight * 0.5)), trackRect)
var fillRect: Rect2 = Rect2(trackRect.position, Vector2(max(trackHeight, knobX - left), trackHeight))
draw_style_box(_round_box(FILL_COLOR, int(trackHeight * 0.5)), fillRect)
var knobCenter: Vector2 = Vector2(knobX, centerY)
draw_circle(knobCenter + Vector2(0, 1.4), knobRadius + 1.0, KNOB_SHADOW_COLOR)
draw_circle(knobCenter, knobRadius, KNOB_COLOR)
draw_arc(knobCenter, knobRadius, 0.0, TAU, 32, Color(0.790, 0.848, 0.902, 0.75), 1.0, true)
func _update_from_x(x: float) -> void:
var knobRadius: float = 12.0
var left: float = knobRadius + 1.0
var right: float = size.x - knobRadius - 1.0
var width: float = max(1.0, right - left)
set_value((x - left) / width, true)
func _round_box(color: Color, radius: int) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = color
style.corner_radius_top_left = radius
style.corner_radius_top_right = radius
style.corner_radius_bottom_left = radius
style.corner_radius_bottom_right = radius
return style

View File

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

View File

@@ -0,0 +1,45 @@
extends Button
# ============================================================================
# SettingsToggle.gd - 设置面板开关控件
# ============================================================================
# 轻量自绘开关,避免默认 CheckButton 的厚重视觉。
# ============================================================================
const ON_COLOR: Color = Color(0.337, 0.690, 0.934, 1.0)
const OFF_COLOR: Color = Color(0.805, 0.843, 0.882, 1.0)
const KNOB_COLOR: Color = Color(1.0, 1.0, 1.0, 1.0)
const SHADOW_COLOR: Color = Color(0.114, 0.286, 0.420, 0.15)
func _ready() -> void:
custom_minimum_size = Vector2(74, 40)
focus_mode = Control.FOCUS_NONE
mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
toggle_mode = true
text = ""
add_theme_stylebox_override("normal", StyleBoxEmpty.new())
add_theme_stylebox_override("hover", StyleBoxEmpty.new())
add_theme_stylebox_override("pressed", StyleBoxEmpty.new())
add_theme_stylebox_override("focus", StyleBoxEmpty.new())
toggled.connect(func(_pressed: bool) -> void: queue_redraw())
func _draw() -> void:
var trackRect: Rect2 = Rect2(Vector2(6, 7), Vector2(size.x - 12, size.y - 14))
var radius: float = trackRect.size.y * 0.5
var color: Color = ON_COLOR if button_pressed else OFF_COLOR
draw_style_box(_create_round_box(color, int(radius)), trackRect)
var knobRadius: float = max(10.0, trackRect.size.y * 0.38)
var x: float = trackRect.position.x + trackRect.size.x - radius if button_pressed else trackRect.position.x + radius
var center: Vector2 = Vector2(x, trackRect.position.y + radius)
draw_circle(center + Vector2(0, 1.2), knobRadius + 0.4, SHADOW_COLOR)
draw_circle(center, knobRadius, KNOB_COLOR)
func _create_round_box(color: Color, radius: int) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = color
style.corner_radius_top_left = radius
style.corner_radius_top_right = radius
style.corner_radius_bottom_left = radius
style.corner_radius_bottom_right = radius
return style

View File

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

293
scenes/ui/WelcomeDialog.gd Normal file
View File

@@ -0,0 +1,293 @@
extends CanvasLayer
class_name WelcomeDialog
const GUIDE_PAGES: Array[Dictionary] = [
{
"text": "欢迎来到 [color=#3399ff]Datawhale Town[/color]!\n\n这里是开源学习者的家园。在这里,我们一同探索知识,分享成长。\n\n[center]WhaleTown[/center]",
"image_path": "res://assets/maps/square/v1/props/bottom_entrance_left_task_props_v2_hd_clean.png",
},
{
"text": "最新活动:\n\n- 镇长在公会大厅附近接待新伙伴。\n- 码头区域已经开放,可以和码头 NPC 交流。\n- 广场公告板后续会同步线上新闻动态。",
"image_path": "res://assets/maps/square/v1/props/center_whale_fountain_v2_hd_clean.png",
},
{
"text": "操作提示:\n\n- 按 [color=#ffaa00]E[/color] 键可以与 NPC、公告板和信息板互动。\n- 靠近目标后面向它,再按互动键。\n- 输入框获得焦点时,角色移动会暂停响应。",
"image_path": "res://assets/maps/square/v1/props/bottom_entrance_right_service_props_v2_hd_clean.png",
},
]
@onready var panelContainer: PanelContainer = $CenterContainer/PanelContainer
@onready var vboxContainer: VBoxContainer = $CenterContainer/PanelContainer/VBoxContainer
@onready var titleLabel: Label = $CenterContainer/PanelContainer/VBoxContainer/Header/Title
@onready var logoContainer: PanelContainer = $CenterContainer/PanelContainer/VBoxContainer/LogoContainer
@onready var bodyText: Label = $CenterContainer/PanelContainer/VBoxContainer/BodyText
@onready var actionContainer: CenterContainer = $CenterContainer/PanelContainer/VBoxContainer/ActionContainer
var _chatUi: Control
var _chatUiPrevMouseFilter: Control.MouseFilter = Control.MOUSE_FILTER_STOP
var _chatUiMouseDisabled: bool = false
var _guideContentContainer: HBoxContainer
var _guideImageRect: TextureRect
var _guideImageLabel: Label
var _guideContentLabel: RichTextLabel
var _guideFooter: HBoxContainer
var _guidePrevButton: Button
var _guideNextButton: Button
var _guideDotsContainer: HBoxContainer
var _guideCurrentPage: int = 0
var _guideTween: Tween
func _ready() -> void:
_disableChatUiMouseInput()
var closeButton := find_child("CloseButton", true, false) as Button
if closeButton != null:
closeButton.pressed.connect(_onClosePressed)
var startButton := find_child("StartButton", true, false) as Button
if startButton != null:
startButton.pressed.connect(_onStartPressed)
func _exit_tree() -> void:
_restoreChatUiMouseInput()
func _input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
queue_free()
func _onClosePressed() -> void:
queue_free()
func _onStartPressed() -> void:
_showGuide()
func _showGuide() -> void:
panelContainer.custom_minimum_size = Vector2(720, 560)
titleLabel.text = "新人引导手册"
logoContainer.visible = false
bodyText.visible = false
actionContainer.visible = false
if _guideContentContainer == null:
_buildGuideUi()
_guideContentContainer.visible = true
_guideFooter.visible = true
_guideCurrentPage = 0
_setupGuideDots()
_updateGuideUi(false)
func _buildGuideUi() -> void:
_guideContentContainer = HBoxContainer.new()
_guideContentContainer.name = "GuideContentContainer"
_guideContentContainer.custom_minimum_size = Vector2(0, 360)
_guideContentContainer.size_flags_vertical = Control.SIZE_EXPAND_FILL
_guideContentContainer.add_theme_constant_override("separation", 22)
vboxContainer.add_child(_guideContentContainer)
var imagePanel := PanelContainer.new()
imagePanel.name = "ImagePanel"
imagePanel.custom_minimum_size = Vector2(322, 0)
imagePanel.size_flags_vertical = Control.SIZE_EXPAND_FILL
imagePanel.add_theme_stylebox_override("panel", _createImageStyle())
_guideContentContainer.add_child(imagePanel)
_guideImageRect = TextureRect.new()
_guideImageRect.name = "ImageRect"
_guideImageRect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
_guideImageRect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
imagePanel.add_child(_guideImageRect)
_guideImageLabel = Label.new()
_guideImageLabel.name = "ImageLabel"
_guideImageLabel.text = "暂无图片"
_guideImageLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_guideImageLabel.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_guideImageLabel.add_theme_color_override("font_color", Color(0.43, 0.48, 0.45, 1.0))
_guideImageLabel.add_theme_font_size_override("font_size", 18)
imagePanel.add_child(_guideImageLabel)
var textPanel := PanelContainer.new()
textPanel.name = "TextPanel"
textPanel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
textPanel.size_flags_vertical = Control.SIZE_EXPAND_FILL
textPanel.add_theme_stylebox_override("panel", _createTextStyle())
_guideContentContainer.add_child(textPanel)
_guideContentLabel = RichTextLabel.new()
_guideContentLabel.name = "ContentLabel"
_guideContentLabel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_guideContentLabel.size_flags_vertical = Control.SIZE_EXPAND_FILL
_guideContentLabel.bbcode_enabled = true
_guideContentLabel.fit_content = true
_guideContentLabel.scroll_active = false
_guideContentLabel.add_theme_color_override("default_color", Color(0.18, 0.2, 0.18, 1.0))
_guideContentLabel.add_theme_font_size_override("normal_font_size", 21)
textPanel.add_child(_guideContentLabel)
_guideFooter = HBoxContainer.new()
_guideFooter.name = "GuideFooter"
_guideFooter.custom_minimum_size = Vector2(0, 58)
_guideFooter.alignment = BoxContainer.ALIGNMENT_CENTER
_guideFooter.add_theme_constant_override("separation", 22)
vboxContainer.add_child(_guideFooter)
_guidePrevButton = _createGuideButton("<")
_guidePrevButton.name = "PrevButton"
_guidePrevButton.pressed.connect(_onGuidePrevPressed)
_guideFooter.add_child(_guidePrevButton)
_guideDotsContainer = HBoxContainer.new()
_guideDotsContainer.name = "DotsContainer"
_guideDotsContainer.custom_minimum_size = Vector2(88, 0)
_guideDotsContainer.alignment = BoxContainer.ALIGNMENT_CENTER
_guideDotsContainer.add_theme_constant_override("separation", 10)
_guideFooter.add_child(_guideDotsContainer)
_guideNextButton = _createGuideButton(">")
_guideNextButton.name = "NextButton"
_guideNextButton.pressed.connect(_onGuideNextPressed)
_guideFooter.add_child(_guideNextButton)
func _createGuideButton(text: String) -> Button:
var button := Button.new()
button.custom_minimum_size = Vector2(56, 44)
button.text = text
button.add_theme_color_override("font_color", Color.WHITE)
button.add_theme_font_size_override("font_size", 22)
button.add_theme_stylebox_override("normal", _createButtonStyle(Color(0.2, 0.43, 0.48, 1.0)))
button.add_theme_stylebox_override("hover", _createButtonStyle(Color(0.28, 0.52, 0.55, 1.0)))
button.add_theme_stylebox_override("pressed", _createButtonStyle(Color(0.28, 0.52, 0.55, 1.0)))
button.add_theme_stylebox_override("disabled", _createButtonStyle(Color(0.72, 0.76, 0.72, 1.0)))
return button
func _createImageStyle() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.content_margin_left = 10.0
style.content_margin_top = 10.0
style.content_margin_right = 10.0
style.content_margin_bottom = 10.0
style.bg_color = Color(0.875, 0.93, 0.92, 1.0)
style.border_width_left = 2
style.border_width_top = 2
style.border_width_right = 2
style.border_width_bottom = 2
style.border_color = Color(0.55, 0.68, 0.64, 1.0)
style.corner_radius_top_left = 10
style.corner_radius_top_right = 10
style.corner_radius_bottom_right = 10
style.corner_radius_bottom_left = 10
return style
func _createTextStyle() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.content_margin_left = 20.0
style.content_margin_top = 18.0
style.content_margin_right = 20.0
style.content_margin_bottom = 18.0
style.bg_color = Color(1.0, 0.985, 0.935, 1.0)
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.7, 0.55, 1.0)
style.corner_radius_top_left = 10
style.corner_radius_top_right = 10
style.corner_radius_bottom_right = 10
style.corner_radius_bottom_left = 10
return style
func _createButtonStyle(color: Color) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.content_margin_left = 16.0
style.content_margin_top = 8.0
style.content_margin_right = 16.0
style.content_margin_bottom = 8.0
style.bg_color = color
style.corner_radius_top_left = 8
style.corner_radius_top_right = 8
style.corner_radius_bottom_right = 8
style.corner_radius_bottom_left = 8
return style
func _setupGuideDots() -> void:
for child in _guideDotsContainer.get_children():
child.queue_free()
for index in range(GUIDE_PAGES.size()):
var dot := ColorRect.new()
dot.custom_minimum_size = Vector2(12, 12)
_guideDotsContainer.add_child(dot)
func _updateGuideUi(animate: bool = true) -> void:
_guidePrevButton.disabled = _guideCurrentPage == 0
_guideNextButton.disabled = _guideCurrentPage == GUIDE_PAGES.size() - 1
var dots := _guideDotsContainer.get_children()
for index in range(dots.size()):
var dot := dots[index] as ColorRect
if dot == null:
continue
if index == _guideCurrentPage:
dot.color = Color(0.15, 0.36, 0.44, 1.0)
dot.custom_minimum_size = Vector2(18, 12)
else:
dot.color = Color(0.72, 0.78, 0.72, 1.0)
dot.custom_minimum_size = Vector2(12, 12)
if animate:
_animateGuideContentChange()
else:
_setGuideContentImmediate()
func _setGuideContentImmediate() -> void:
var page := GUIDE_PAGES[_guideCurrentPage]
_guideContentLabel.text = page.get("text", "") as String
var imagePath := page.get("image_path", "") as String
if imagePath != "" and ResourceLoader.exists(imagePath):
_guideImageRect.texture = load(imagePath) as Texture2D
_guideImageLabel.visible = false
else:
_guideImageRect.texture = null
_guideImageLabel.visible = true
_guideImageLabel.text = "暂无图片"
func _animateGuideContentChange() -> void:
if _guideTween != null and _guideTween.is_valid():
_guideTween.kill()
_guideTween = create_tween()
_guideTween.tween_property(_guideContentContainer, "modulate:a", 0.0, 0.15)
_guideTween.tween_callback(_setGuideContentImmediate)
_guideTween.tween_property(_guideContentContainer, "modulate:a", 1.0, 0.15)
func _onGuidePrevPressed() -> void:
if _guideCurrentPage > 0:
_guideCurrentPage -= 1
_updateGuideUi()
func _onGuideNextPressed() -> void:
if _guideCurrentPage < GUIDE_PAGES.size() - 1:
_guideCurrentPage += 1
_updateGuideUi()
func _disableChatUiMouseInput() -> void:
var currentScene := get_tree().current_scene
if currentScene != null:
_chatUi = currentScene.get_node_or_null("UILayer/ChatUI") as Control
if _chatUi == null:
_chatUi = get_tree().root.find_child("ChatUI", true, false) as Control
if _chatUi != null:
_chatUiPrevMouseFilter = _chatUi.mouse_filter
_chatUi.mouse_filter = Control.MOUSE_FILTER_IGNORE
_chatUiMouseDisabled = true
func _restoreChatUiMouseInput() -> void:
if _chatUiMouseDisabled and is_instance_valid(_chatUi):
_chatUi.mouse_filter = _chatUiPrevMouseFilter
_chatUiMouseDisabled = false
_chatUi = null

View File

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

View File

@@ -0,0 +1,8 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scenes/ui/DatawhaleHonorRankingPanel.gd" id="1_script"]
[node name="DatawhaleHonorRankingPanel" type="CanvasLayer"]
process_mode = 3
layer = 60
script = ExtResource("1_script")

View File

@@ -0,0 +1,177 @@
class_name MallItemCard
extends Button
# ============================================================================
# MallItemCard.gd - 商城商品卡片
# ============================================================================
# 展示商品槽位、状态、价格和选中态,由 MallPanel 统一驱动。
# ============================================================================
signal item_selected(item: Dictionary)
const TEXT_COLOR: Color = Color(0.111, 0.231, 0.380)
const MUTED_COLOR: Color = Color(0.486, 0.584, 0.694)
const ACCENT_COLOR: Color = Color(0.086, 0.608, 0.922)
const LOCKED_COLOR: Color = Color(0.455, 0.510, 0.584)
const COIN_TEXTURE: Texture2D = preload("res://assets/ui/mall/branding/branding_whale_coin.png")
const CATALOG_SCRIPT: Script = preload("res://Config/MallCatalog.gd")
var itemData: Dictionary = {}
var isSelected: bool = false
var _background: PanelContainer
var _icon: TextureRect
var _nameLabel: Label
var _statusLabel: Label
var _priceLabel: Label
var _lockedOverlay: ColorRect
func _ready() -> void:
focus_mode = Control.FOCUS_NONE
mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
custom_minimum_size = Vector2(218, 288)
add_theme_stylebox_override("normal", StyleBoxEmpty.new())
add_theme_stylebox_override("hover", StyleBoxEmpty.new())
add_theme_stylebox_override("pressed", StyleBoxEmpty.new())
add_theme_stylebox_override("focus", StyleBoxEmpty.new())
_build_ui()
pressed.connect(_on_pressed)
func setup(data: Dictionary, selected: bool) -> void:
itemData = data.duplicate(true)
isSelected = selected
if not is_inside_tree():
return
_render()
func set_selected(selected: bool) -> void:
isSelected = selected
if is_inside_tree():
_render()
func _build_ui() -> void:
_background = PanelContainer.new()
_background.name = "background"
_background.set_anchors_preset(Control.PRESET_FULL_RECT)
_background.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_background)
var margin := MarginContainer.new()
margin.name = "contentMargin"
margin.set_anchors_preset(Control.PRESET_FULL_RECT)
margin.add_theme_constant_override("margin_left", 14)
margin.add_theme_constant_override("margin_top", 14)
margin.add_theme_constant_override("margin_right", 14)
margin.add_theme_constant_override("margin_bottom", 14)
margin.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(margin)
var content := VBoxContainer.new()
content.alignment = BoxContainer.ALIGNMENT_CENTER
content.add_theme_constant_override("separation", 7)
content.mouse_filter = Control.MOUSE_FILTER_IGNORE
margin.add_child(content)
_statusLabel = Label.new()
_statusLabel.custom_minimum_size = Vector2(0, 24)
_statusLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_statusLabel.add_theme_font_size_override("font_size", 15)
_statusLabel.add_theme_color_override("font_color", MUTED_COLOR)
content.add_child(_statusLabel)
_icon = TextureRect.new()
_icon.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
_icon.custom_minimum_size = Vector2(152, 134)
_icon.expand_mode = TextureRect.EXPAND_FIT_WIDTH_PROPORTIONAL
_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
content.add_child(_icon)
_nameLabel = Label.new()
_nameLabel.custom_minimum_size = Vector2(0, 48)
_nameLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_nameLabel.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_nameLabel.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_nameLabel.add_theme_font_size_override("font_size", 19)
_nameLabel.add_theme_color_override("font_color", TEXT_COLOR)
content.add_child(_nameLabel)
var priceRow := HBoxContainer.new()
priceRow.alignment = BoxContainer.ALIGNMENT_CENTER
priceRow.add_theme_constant_override("separation", 4)
priceRow.mouse_filter = Control.MOUSE_FILTER_IGNORE
content.add_child(priceRow)
var coin := TextureRect.new()
coin.texture = COIN_TEXTURE
coin.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
coin.custom_minimum_size = Vector2(28, 28)
coin.expand_mode = TextureRect.EXPAND_FIT_WIDTH_PROPORTIONAL
coin.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
coin.mouse_filter = Control.MOUSE_FILTER_IGNORE
priceRow.add_child(coin)
_priceLabel = Label.new()
_priceLabel.add_theme_font_size_override("font_size", 18)
_priceLabel.add_theme_color_override("font_color", MUTED_COLOR)
priceRow.add_child(_priceLabel)
_lockedOverlay = ColorRect.new()
_lockedOverlay.name = "lockedOverlay"
_lockedOverlay.set_anchors_preset(Control.PRESET_FULL_RECT)
_lockedOverlay.color = Color(0.350, 0.420, 0.510, 0.18)
_lockedOverlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_lockedOverlay)
_render()
func _render() -> void:
_background.add_theme_stylebox_override("panel", _create_card_style(isSelected))
var status := str(itemData.get("status", CATALOG_SCRIPT.STATUS_COMING_SOON))
var iconPath := str(itemData.get("icon", ""))
_icon.texture = _load_texture(iconPath)
_nameLabel.text = str(itemData.get("name", "商品槽位"))
_statusLabel.text = CATALOG_SCRIPT.get_status_label(status)
_priceLabel.text = _format_price(itemData)
_lockedOverlay.visible = status == CATALOG_SCRIPT.STATUS_COMING_SOON or status == CATALOG_SCRIPT.STATUS_LOCKED
_statusLabel.add_theme_color_override("font_color", ACCENT_COLOR if status == CATALOG_SCRIPT.STATUS_AVAILABLE else LOCKED_COLOR)
func _create_card_style(selected: bool) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(0.982, 0.995, 1.0, 1.0)
style.corner_radius_top_left = 18
style.corner_radius_top_right = 18
style.corner_radius_bottom_left = 18
style.corner_radius_bottom_right = 18
style.border_width_left = 2 if not selected else 4
style.border_width_top = 2 if not selected else 4
style.border_width_right = 2 if not selected else 4
style.border_width_bottom = 2 if not selected else 4
style.border_color = Color(0.658, 0.835, 0.965, 0.86) if not selected else Color(0.086, 0.690, 0.960, 1.0)
style.shadow_color = Color(0.086, 0.314, 0.520, 0.15 if selected else 0.08)
style.shadow_size = 14 if selected else 8
style.shadow_offset = Vector2(0, 4)
return style
func _format_price(data: Dictionary) -> String:
var status := str(data.get("status", ""))
if status == CATALOG_SCRIPT.STATUS_OWNED:
return "已购买"
if status == CATALOG_SCRIPT.STATUS_COMING_SOON or status == CATALOG_SCRIPT.STATUS_LOCKED:
return "暂未开放"
return str(int(data.get("price", 0)))
func _load_texture(path: String) -> Texture2D:
if path.is_empty():
return null
if FileAccess.file_exists("%s.import" % path):
var texture := load(path) as Texture2D
if texture != null:
return texture
var image := Image.load_from_file(ProjectSettings.globalize_path(path))
if image == null or image.is_empty():
return null
return ImageTexture.create_from_image(image)
func _on_pressed() -> void:
item_selected.emit(itemData)

View File

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

1211
scenes/ui/mall/MallPanel.gd Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,13 @@
[gd_scene load_steps=2 format=4 uid="uid://whaletown_mall_panel"]
[ext_resource type="Script" path="res://scenes/ui/mall/MallPanel.gd" id="1_mall_panel"]
[node name="MallPanel" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
script = ExtResource("1_mall_panel")

View File

@@ -0,0 +1,232 @@
[gd_scene load_steps=10 format=3]
[ext_resource type="Script" path="res://scenes/ui/NoticeDialog.gd" id="1_script"]
[ext_resource type="Theme" path="res://assets/ui/world_text_theme.tres" id="2_theme"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_panel"]
content_margin_left = 30.0
content_margin_top = 24.0
content_margin_right = 30.0
content_margin_bottom = 24.0
bg_color = Color(0.975, 0.955, 0.89, 1)
border_width_left = 3
border_width_top = 3
border_width_right = 3
border_width_bottom = 3
border_color = Color(0.28, 0.43, 0.42, 1)
corner_radius_top_left = 14
corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
shadow_color = Color(0.05, 0.08, 0.08, 0.32)
shadow_size = 18
shadow_offset = Vector2(0, 8)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_image"]
content_margin_left = 10.0
content_margin_top = 10.0
content_margin_right = 10.0
content_margin_bottom = 10.0
bg_color = Color(0.875, 0.93, 0.92, 1)
border_width_left = 2
border_width_top = 2
border_width_right = 2
border_width_bottom = 2
border_color = Color(0.55, 0.68, 0.64, 1)
corner_radius_top_left = 10
corner_radius_top_right = 10
corner_radius_bottom_right = 10
corner_radius_bottom_left = 10
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_text"]
content_margin_left = 20.0
content_margin_top = 18.0
content_margin_right = 20.0
content_margin_bottom = 18.0
bg_color = Color(1, 0.985, 0.935, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.78, 0.7, 0.55, 1)
corner_radius_top_left = 10
corner_radius_top_right = 10
corner_radius_bottom_right = 10
corner_radius_bottom_left = 10
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_button"]
content_margin_left = 16.0
content_margin_top = 8.0
content_margin_right = 16.0
content_margin_bottom = 8.0
bg_color = Color(0.2, 0.43, 0.48, 1)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_button_hover"]
content_margin_left = 16.0
content_margin_top = 8.0
content_margin_right = 16.0
content_margin_bottom = 8.0
bg_color = Color(0.28, 0.52, 0.55, 1)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_button_disabled"]
content_margin_left = 16.0
content_margin_top = 8.0
content_margin_right = 16.0
content_margin_bottom = 8.0
bg_color = Color(0.72, 0.76, 0.72, 1)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_close"]
bg_color = Color(0.86, 0.33, 0.27, 1)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[node name="NoticeDialog" type="CanvasLayer"]
process_mode = 3
script = ExtResource("1_script")
[node name="Dimmer" type="ColorRect" parent="."]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
color = Color(0.03, 0.04, 0.04, 0.58)
[node name="CenterContainer" type="CenterContainer" parent="."]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="PanelContainer" type="PanelContainer" parent="CenterContainer"]
custom_minimum_size = Vector2(720, 560)
layout_mode = 2
theme = ExtResource("2_theme")
theme_override_styles/panel = SubResource("StyleBoxFlat_panel")
[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer/PanelContainer"]
layout_mode = 2
theme_override_constants/separation = 18
[node name="Header" type="HBoxContainer" parent="CenterContainer/PanelContainer/VBoxContainer"]
custom_minimum_size = Vector2(0, 52)
layout_mode = 2
theme_override_constants/separation = 12
[node name="LeftSpacer" type="Control" parent="CenterContainer/PanelContainer/VBoxContainer/Header"]
layout_mode = 2
size_flags_horizontal = 3
[node name="Title" type="Label" parent="CenterContainer/PanelContainer/VBoxContainer/Header"]
layout_mode = 2
theme_override_colors/font_color = Color(0.15, 0.26, 0.3, 1)
theme_override_font_sizes/font_size = 30
text = "公告板"
horizontal_alignment = 1
vertical_alignment = 1
[node name="RightContainer" type="HBoxContainer" parent="CenterContainer/PanelContainer/VBoxContainer/Header"]
layout_mode = 2
size_flags_horizontal = 3
alignment = 2
[node name="CloseButton" type="Button" parent="CenterContainer/PanelContainer/VBoxContainer/Header/RightContainer"]
custom_minimum_size = Vector2(42, 42)
layout_mode = 2
theme_override_colors/font_color = Color(1, 1, 1, 1)
theme_override_font_sizes/font_size = 20
theme_override_styles/normal = SubResource("StyleBoxFlat_close")
theme_override_styles/hover = SubResource("StyleBoxFlat_close")
theme_override_styles/pressed = SubResource("StyleBoxFlat_close")
text = "X"
[node name="ContentContainer" type="HBoxContainer" parent="CenterContainer/PanelContainer/VBoxContainer"]
custom_minimum_size = Vector2(0, 360)
layout_mode = 2
size_flags_vertical = 3
theme_override_constants/separation = 22
[node name="ImagePanel" type="PanelContainer" parent="CenterContainer/PanelContainer/VBoxContainer/ContentContainer"]
custom_minimum_size = Vector2(322, 0)
layout_mode = 2
size_flags_vertical = 3
theme_override_styles/panel = SubResource("StyleBoxFlat_image")
[node name="ImageRect" type="TextureRect" parent="CenterContainer/PanelContainer/VBoxContainer/ContentContainer/ImagePanel"]
layout_mode = 2
expand_mode = 1
stretch_mode = 5
[node name="ImageLabel" type="Label" parent="CenterContainer/PanelContainer/VBoxContainer/ContentContainer/ImagePanel"]
layout_mode = 2
theme_override_colors/font_color = Color(0.43, 0.48, 0.45, 1)
theme_override_font_sizes/font_size = 18
text = "暂无图片"
horizontal_alignment = 1
vertical_alignment = 1
[node name="TextPanel" type="PanelContainer" parent="CenterContainer/PanelContainer/VBoxContainer/ContentContainer"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_styles/panel = SubResource("StyleBoxFlat_text")
[node name="ContentLabel" type="RichTextLabel" parent="CenterContainer/PanelContainer/VBoxContainer/ContentContainer/TextPanel"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_colors/default_color = Color(0.18, 0.2, 0.18, 1)
theme_override_font_sizes/normal_font_size = 21
bbcode_enabled = true
fit_content = true
scroll_active = false
text = "Announcement Content..."
[node name="Footer" type="HBoxContainer" parent="CenterContainer/PanelContainer/VBoxContainer"]
custom_minimum_size = Vector2(0, 58)
layout_mode = 2
theme_override_constants/separation = 22
alignment = 1
[node name="PrevButton" type="Button" parent="CenterContainer/PanelContainer/VBoxContainer/Footer"]
custom_minimum_size = Vector2(56, 44)
layout_mode = 2
theme_override_colors/font_color = Color(1, 1, 1, 1)
theme_override_font_sizes/font_size = 22
theme_override_styles/normal = SubResource("StyleBoxFlat_button")
theme_override_styles/hover = SubResource("StyleBoxFlat_button_hover")
theme_override_styles/pressed = SubResource("StyleBoxFlat_button_hover")
theme_override_styles/disabled = SubResource("StyleBoxFlat_button_disabled")
text = "<"
[node name="DotsContainer" type="HBoxContainer" parent="CenterContainer/PanelContainer/VBoxContainer/Footer"]
custom_minimum_size = Vector2(88, 0)
layout_mode = 2
theme_override_constants/separation = 10
alignment = 1
[node name="NextButton" type="Button" parent="CenterContainer/PanelContainer/VBoxContainer/Footer"]
custom_minimum_size = Vector2(56, 44)
layout_mode = 2
theme_override_colors/font_color = Color(1, 1, 1, 1)
theme_override_font_sizes/font_size = 22
theme_override_styles/normal = SubResource("StyleBoxFlat_button")
theme_override_styles/hover = SubResource("StyleBoxFlat_button_hover")
theme_override_styles/pressed = SubResource("StyleBoxFlat_button_hover")
theme_override_styles/disabled = SubResource("StyleBoxFlat_button_disabled")
text = ">"

View File

@@ -0,0 +1,169 @@
[gd_scene load_steps=9 format=3]
[ext_resource type="Script" path="res://scenes/ui/WelcomeDialog.gd" id="1_script"]
[ext_resource type="Texture2D" path="res://assets/maps/square/v1/props/bottom_entrance_right_service_props_v2_hd_clean.png" id="2_board"]
[ext_resource type="Theme" path="res://assets/ui/world_text_theme.tres" id="3_theme"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_card"]
content_margin_left = 34.0
content_margin_top = 28.0
content_margin_right = 34.0
content_margin_bottom = 28.0
bg_color = Color(0.965, 0.95, 0.88, 1)
border_width_left = 3
border_width_top = 3
border_width_right = 3
border_width_bottom = 3
border_color = Color(0.28, 0.42, 0.38, 1)
corner_radius_top_left = 14
corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
shadow_color = Color(0.05, 0.08, 0.08, 0.32)
shadow_size = 18
shadow_offset = Vector2(0, 8)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_image"]
content_margin_left = 12.0
content_margin_top = 12.0
content_margin_right = 12.0
content_margin_bottom = 12.0
bg_color = Color(0.87, 0.93, 0.91, 1)
border_width_left = 2
border_width_top = 2
border_width_right = 2
border_width_bottom = 2
border_color = Color(0.54, 0.67, 0.62, 1)
corner_radius_top_left = 10
corner_radius_top_right = 10
corner_radius_bottom_right = 10
corner_radius_bottom_left = 10
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_button"]
content_margin_left = 22.0
content_margin_top = 10.0
content_margin_right = 22.0
content_margin_bottom = 10.0
bg_color = Color(0.2, 0.43, 0.48, 1)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_button_hover"]
content_margin_left = 22.0
content_margin_top = 10.0
content_margin_right = 22.0
content_margin_bottom = 10.0
bg_color = Color(0.28, 0.52, 0.55, 1)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_close"]
bg_color = Color(0.86, 0.33, 0.27, 1)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[node name="WelcomeDialog" type="CanvasLayer"]
script = ExtResource("1_script")
[node name="ColorRect" type="ColorRect" parent="."]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
color = Color(0.03, 0.04, 0.04, 0.52)
[node name="CenterContainer" type="CenterContainer" parent="."]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="PanelContainer" type="PanelContainer" parent="CenterContainer"]
custom_minimum_size = Vector2(680, 520)
layout_mode = 2
theme = ExtResource("3_theme")
theme_override_styles/panel = SubResource("StyleBoxFlat_card")
[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer/PanelContainer"]
layout_mode = 2
theme_override_constants/separation = 18
[node name="Header" type="HBoxContainer" parent="CenterContainer/PanelContainer/VBoxContainer"]
custom_minimum_size = Vector2(0, 52)
layout_mode = 2
theme_override_constants/separation = 12
alignment = 1
[node name="Spacer" type="Control" parent="CenterContainer/PanelContainer/VBoxContainer/Header"]
layout_mode = 2
size_flags_horizontal = 3
[node name="Title" type="Label" parent="CenterContainer/PanelContainer/VBoxContainer/Header"]
layout_mode = 2
theme_override_colors/font_color = Color(0.14, 0.25, 0.29, 1)
theme_override_font_sizes/font_size = 28
text = "Datawhale Town 信息板"
vertical_alignment = 1
[node name="Spacer2" type="Control" parent="CenterContainer/PanelContainer/VBoxContainer/Header"]
layout_mode = 2
size_flags_horizontal = 3
[node name="CloseButton" type="Button" parent="CenterContainer/PanelContainer/VBoxContainer/Header"]
custom_minimum_size = Vector2(42, 42)
layout_mode = 2
theme_override_colors/font_color = Color(1, 1, 1, 1)
theme_override_font_sizes/font_size = 20
theme_override_styles/normal = SubResource("StyleBoxFlat_close")
theme_override_styles/hover = SubResource("StyleBoxFlat_close")
theme_override_styles/pressed = SubResource("StyleBoxFlat_close")
text = "X"
[node name="LogoContainer" type="PanelContainer" parent="CenterContainer/PanelContainer/VBoxContainer"]
custom_minimum_size = Vector2(0, 190)
layout_mode = 2
theme_override_styles/panel = SubResource("StyleBoxFlat_image")
[node name="TextureRect" type="TextureRect" parent="CenterContainer/PanelContainer/VBoxContainer/LogoContainer"]
layout_mode = 2
texture = ExtResource("2_board")
expand_mode = 1
stretch_mode = 5
[node name="BodyText" type="Label" parent="CenterContainer/PanelContainer/VBoxContainer"]
custom_minimum_size = Vector2(0, 150)
layout_mode = 2
size_flags_vertical = 3
theme_override_colors/font_color = Color(0.18, 0.2, 0.18, 1)
theme_override_font_sizes/font_size = 20
text = "连接、共生、见证。
Datawhale Town 是学习者的赛博家园与精神坐标。
实时广场:看大家都在学什么。
个人空间:展示你的学习笔记与作品。
开源营地:更有氛围的组队学习体验。"
horizontal_alignment = 1
vertical_alignment = 1
autowrap_mode = 3
[node name="ActionContainer" type="CenterContainer" parent="CenterContainer/PanelContainer/VBoxContainer"]
custom_minimum_size = Vector2(0, 58)
layout_mode = 2
[node name="StartButton" type="Button" parent="CenterContainer/PanelContainer/VBoxContainer/ActionContainer"]
custom_minimum_size = Vector2(190, 48)
layout_mode = 2
theme_override_colors/font_color = Color(1, 1, 1, 1)
theme_override_font_sizes/font_size = 19
theme_override_styles/normal = SubResource("StyleBoxFlat_button")
theme_override_styles/hover = SubResource("StyleBoxFlat_button_hover")
theme_override_styles/pressed = SubResource("StyleBoxFlat_button_hover")
text = "开始探索"