Initial WhaleTown V2 frontend

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

View File

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