extends Node # 社区资料、通知与旅行 API 的客户端协调器。 const ACCENT := Color("58c7db") const PANEL_COLOR := Color(0.035, 0.071, 0.106, 0.97) const DISCOVERY_DISTANCE: float = 180.0 const DISCOVERY_POINTS: Array[Dictionary] = [ {"id": "square_center", "mapId": "whale_port", "position": Vector2(0, 30)}, {"id": "square_dock", "mapId": "whale_port", "position": Vector2(-870, -202)}, {"id": "square_headquarters", "mapId": "whale_port", "position": Vector2(13, -600)}, {"id": "square_cottage", "mapId": "whale_port", "position": Vector2(845, -192)}, {"id": "square_workshop", "mapId": "whale_port", "position": Vector2(645, 500)}, {"id": "square_notice", "mapId": "whale_port", "position": Vector2(-542, 648)}, {"id": "square_work_zone_gate", "mapId": "whale_port", "position": Vector2(0, 775)}, {"id": "work_entrance", "mapId": "work_zone", "position": Vector2(0, 755)}, {"id": "work_mall", "mapId": "work_zone", "position": Vector2(0, -614)}, {"id": "work_cafe_gate", "mapId": "work_zone", "position": Vector2(-1044, 222)}, {"id": "work_jobs", "mapId": "work_zone", "position": Vector2(-502, 192)}, {"id": "work_courses", "mapId": "work_zone", "position": Vector2(496, 0)}, {"id": "work_ai", "mapId": "work_zone", "position": Vector2(452, 548)}, {"id": "work_exchange", "mapId": "work_zone", "position": Vector2(1075, 548)}, {"id": "cafe_entrance", "mapId": "whale_cafe", "position": Vector2(0, 363)}, {"id": "cafe_counter", "mapId": "whale_cafe", "position": Vector2(0, -37)}, {"id": "cafe_companion", "mapId": "whale_cafe", "position": Vector2(-398, -226)}, {"id": "personal_room", "mapId": "personal_space", "position": Vector2.ZERO}, ] var _canvas: CanvasLayer var _profile_panel: PanelContainer var _profile_content: VBoxContainer var _notification_panel: PanelContainer var _notification_content: VBoxContainer var _current_profile: Dictionary = {} var _discoveryRequests: Dictionary = {} var _discoveredDestinations: Dictionary = {} func _ready() -> void: add_to_group("whaletown_escape_dismissible") _build_ui() var event_system := get_node_or_null("/root/EventSystem") if event_system != null: event_system.call("connect_event", EventNames.SOCIAL_NOTIFICATION_RECEIVED, _on_social_realtime, self) func _exit_tree() -> void: var event_system := get_node_or_null("/root/EventSystem") if event_system != null: event_system.call("disconnect_event", EventNames.SOCIAL_NOTIFICATION_RECEIVED, _on_social_realtime, self) func show_profile(user_id: String) -> void: var normalized := user_id.strip_edges() if normalized.is_empty(): return _request_get("/social/profiles/%s" % normalized, func(success: bool, response: Dictionary, error: Dictionary) -> void: if not success: _show_status("无法查看名片:%s" % str(error.get("message", "请求失败"))) return var data_variant: Variant = response.get("data", {}) if data_variant is Dictionary: _current_profile = data_variant as Dictionary _render_profile() ) func show_own_profile() -> void: _request_get("/social/profile", func(success: bool, response: Dictionary, error: Dictionary) -> void: if not success: _show_status("无法读取个人名片:%s" % str(error.get("message", "请求失败"))) return var data_variant: Variant = response.get("data", {}) if data_variant is Dictionary: _current_profile = data_variant as Dictionary _render_profile() ) func request_friend(user_id: String, username: String = "") -> void: var chat := get_node_or_null("/root/ChatManager") if chat != null and chat.has_method("request_friend"): chat.call("request_friend", user_id, username) func open_private_chat(user_id: String, username: String) -> void: var event_system := get_node_or_null("/root/EventSystem") if event_system != null: event_system.call("emit_event", EventNames.CHAT_PRIVATE_TARGET_SELECTED, {"userId": user_id, "username": username}) _close_profile() func request_travel(destination_id: String, completion: Callable = Callable()) -> void: _request_post("/world/travel-destinations/%s/travel" % destination_id.uri_encode(), {}, func(success: bool, response: Dictionary, error: Dictionary) -> void: if not success: _show_status(str(error.get("message", "该地点尚未解锁"))) if completion.is_valid(): completion.call(false, {}) return var data_variant: Variant = response.get("data", {}) var data: Dictionary = data_variant if data_variant is Dictionary else {} if completion.is_valid(): completion.call(true, data) ) func discover_destination(destination_id: String) -> void: var normalized := destination_id.strip_edges() if normalized.is_empty() or _discoveredDestinations.has(normalized) or _discoveryRequests.has(normalized): return _discoveryRequests[normalized] = true _request_post("/world/travel-destinations/%s/discover" % normalized.uri_encode(), {}, func(success: bool, _response: Dictionary, _error: Dictionary) -> void: _discoveryRequests.erase(normalized) if success: _discoveredDestinations[normalized] = true ) func discover_nearby_destinations(map_id: String, position: Vector2) -> void: for point in DISCOVERY_POINTS: if str(point.get("mapId", "")) != map_id: continue var target := point.get("position", Vector2.ZERO) as Vector2 if position.distance_to(target) <= DISCOVERY_DISTANCE: discover_destination(str(point.get("id", ""))) func toggle_notifications() -> void: _notification_panel.visible = not _notification_panel.visible if _notification_panel.visible: _refresh_notifications() func is_escape_dismissible() -> bool: return (is_instance_valid(_profile_panel) and _profile_panel.visible) or (is_instance_valid(_notification_panel) and _notification_panel.visible) func get_escape_priority() -> int: return 800 func request_escape_close() -> void: if is_instance_valid(_profile_panel) and _profile_panel.visible: _close_profile() return if is_instance_valid(_notification_panel): _notification_panel.visible = false func _on_social_realtime(_payload: Dictionary) -> void: if _notification_panel.visible: _refresh_notifications() func _refresh_notifications() -> void: _request_get("/social/notifications?limit=30", func(success: bool, response: Dictionary, error: Dictionary) -> void: if not success: _show_notification_rows(["通知暂时无法读取:%s" % str(error.get("message", "请求失败"))]) return var data_variant: Variant = response.get("data", {}) var data: Dictionary = data_variant if data_variant is Dictionary else {} var rows: Array[String] = [] var notifications_variant: Variant = data.get("notifications", []) if notifications_variant is Array: for item in notifications_variant as Array: if item is Dictionary: var notification: Dictionary = item rows.append("%s\n%s" % [str(notification.get("title", "通知")), str(notification.get("content", ""))]) if rows.is_empty(): rows.append("暂无通知") _show_notification_rows(rows) ) func _render_profile() -> void: _profile_panel.visible = true for child in _profile_content.get_children(): child.queue_free() var nickname := str(_current_profile.get("nickname", "玩家")) var username := str(_current_profile.get("username", "")) var online := bool(_current_profile.get("online", false)) var area := str(_current_profile.get("currentArea", "")) var identity := HBoxContainer.new() identity.add_theme_constant_override("separation", 12) identity.add_child(_avatar_badge(nickname)) var identity_text := VBoxContainer.new() identity_text.size_flags_horizontal = Control.SIZE_EXPAND_FILL identity_text.add_child(_label(nickname, 26, ACCENT)) identity_text.add_child(_label("@%s · %s" % [username, "在线" if online else "离线"], 15, Color(0.72, 0.82, 0.87, 1))) identity.add_child(identity_text) _profile_content.add_child(identity) _profile_content.add_child(_label("当前区域:%s" % _area_label(area), 16, Color(0.88, 0.94, 0.96, 1))) var skin_id := str(_current_profile.get("skinId", "")).strip_edges() _profile_content.add_child(_label("角色:%s" % (skin_id if not skin_id.is_empty() else "默认角色"), 14, Color(0.50, 0.76, 0.80, 1))) var bio := str(_current_profile.get("bio", "")).strip_edges() _profile_content.add_child(_label(bio if not bio.is_empty() else "这个玩家还没有留下简介。", 16, Color(0.84, 0.90, 0.92, 1))) var interests_variant: Variant = _current_profile.get("interests", []) var interests: Array[String] = [] if interests_variant is Array: for interest in interests_variant as Array: interests.append(str(interest)) _profile_content.add_child(_label("兴趣:%s" % (" · ".join(interests) if not interests.is_empty() else "未设置"), 14, Color(0.50, 0.76, 0.80, 1))) var user_id := str(_current_profile.get("id", "")) var self_profile := user_id == _current_user_id() if self_profile: _profile_content.add_child(_label("编辑社区资料", 17, ACCENT)) var nickname_input := LineEdit.new() nickname_input.placeholder_text = "昵称(首次可立即修改,此后每 7 天一次)" nickname_input.text = nickname _profile_content.add_child(nickname_input) var bio_input := TextEdit.new() bio_input.placeholder_text = "简介(最多 160 字)" bio_input.text = bio bio_input.custom_minimum_size = Vector2(0, 82) _profile_content.add_child(bio_input) var current_interests: Array[String] = [] for interest in interests: current_interests.append(interest) var interest_row := HBoxContainer.new() interest_row.add_theme_constant_override("separation", 6) var interest_selects: Array[OptionButton] = [] for slot in 3: var selector := OptionButton.new() selector.custom_minimum_size = Vector2(145, 34) selector.add_item("兴趣标签") for tag in _interest_catalog(): selector.add_item(str(tag.get("label", ""))) selector.set_item_metadata(selector.item_count - 1, str(tag.get("id", ""))) if slot < current_interests.size(): for item_index in range(1, selector.item_count): if str(selector.get_item_metadata(item_index)) == current_interests[slot]: selector.select(item_index) break interest_selects.append(selector) interest_row.add_child(selector) _profile_content.add_child(interest_row) _profile_content.add_child(_button("保存资料", func() -> void: _save_own_profile(nickname_input.text, bio_input.text, interest_selects))) else: var actions := HBoxContainer.new() actions.add_theme_constant_override("separation", 8) actions.add_child(_button("私聊", func() -> void: open_private_chat(user_id, nickname))) actions.add_child(_button("加好友", func() -> void: request_friend(user_id, nickname))) _profile_content.add_child(actions) var safety := HBoxContainer.new() safety.add_theme_constant_override("separation", 8) safety.add_child(_button("拉黑", func() -> void: _block_user(user_id))) var report_form := _report_form(user_id) var report_button := _button("举报", func() -> void: report_form.visible = not report_form.visible) safety.add_child(report_button) _profile_content.add_child(safety) _profile_content.add_child(report_form) _profile_content.add_child(_button("关闭", _close_profile)) func _block_user(user_id: String) -> void: _request_post("/social/blocks", {"userId": user_id}, func(success: bool, _response: Dictionary, error: Dictionary) -> void: _show_status("已拉黑该玩家" if success else str(error.get("message", "拉黑失败"))) if success: _close_profile() ) func _save_own_profile(nickname: String, bio: String, interest_selects: Array[OptionButton]) -> void: var interests: Array[String] = [] for selector in interest_selects: var index := selector.selected if index > 0: var value := str(selector.get_item_metadata(index)).strip_edges() if not value.is_empty() and not interests.has(value): interests.append(value) _request_patch("/social/profile", {"nickname": nickname.strip_edges(), "bio": bio.strip_edges(), "interests": interests}, func(success: bool, response: Dictionary, error: Dictionary) -> void: if not success: _show_status(str(error.get("message", "资料保存失败"))) return var data_variant: Variant = response.get("data", {}) if data_variant is Dictionary: _current_profile = data_variant as Dictionary _render_profile() var event_system := get_node_or_null("/root/EventSystem") if event_system != null: event_system.call("emit_event", EventNames.SOCIAL_PROFILE_UPDATED, _current_profile) ) func _report_form(user_id: String) -> VBoxContainer: var form := VBoxContainer.new() form.visible = false form.add_theme_constant_override("separation", 6) form.add_theme_stylebox_override("panel", _subpanel_style()) form.add_child(_label("举报原因", 15, ACCENT)) var reason := OptionButton.new() for item in [ {"id": "harassment", "label": "骚扰或辱骂"}, {"id": "spam", "label": "垃圾信息"}, {"id": "inappropriate_content", "label": "不当内容"}, {"id": "impersonation", "label": "冒充他人"}, {"id": "other", "label": "其他"}, ]: reason.add_item(str(item.get("label", "其他"))) reason.set_item_metadata(reason.item_count - 1, str(item.get("id", "other"))) form.add_child(reason) var note := TextEdit.new() note.placeholder_text = "补充说明(可选,最多 500 字)" note.custom_minimum_size = Vector2(0, 68) form.add_child(note) var block_also := CheckBox.new() block_also.text = "同时拉黑此玩家" block_also.button_pressed = false form.add_child(block_also) form.add_child(_button("提交举报", func() -> void: _submit_report(user_id, str(reason.get_item_metadata(reason.selected)), note.text, block_also.button_pressed) )) return form func _submit_report(user_id: String, reason: String, note: String, block_also: bool) -> void: _request_post("/social/reports", { "userId": user_id, "reason": reason, "note": note.strip_edges(), "blockAlso": block_also, }, func(success: bool, _response: Dictionary, error: Dictionary) -> void: _show_status("举报已提交" if success else str(error.get("message", "举报失败"))) if success and block_also: _close_profile() ) func _build_ui() -> void: _canvas = CanvasLayer.new() _canvas.layer = 88 add_child(_canvas) _profile_panel = _panel(Vector2(540, 610)) _profile_panel.set_anchors_preset(Control.PRESET_CENTER) _profile_panel.position = Vector2(-270, -305) _canvas.add_child(_profile_panel) var profile_margin := _margin(_profile_panel) var profile_scroll := ScrollContainer.new() profile_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED profile_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL profile_margin.add_child(profile_scroll) _profile_content = VBoxContainer.new() _profile_content.add_theme_constant_override("separation", 12) _profile_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL profile_scroll.add_child(_profile_content) _notification_panel = _panel(Vector2(390, 430)) _notification_panel.set_anchors_preset(Control.PRESET_TOP_RIGHT) _notification_panel.position = Vector2(-414, 110) _canvas.add_child(_notification_panel) var notification_margin := _margin(_notification_panel) _notification_content = VBoxContainer.new() _notification_content.add_theme_constant_override("separation", 9) notification_margin.add_child(_notification_content) _notification_panel.visible = false _profile_panel.visible = false func _show_notification_rows(rows: Array[String]) -> void: for child in _notification_content.get_children(): child.queue_free() _notification_content.add_child(_label("通知中心", 22, ACCENT)) for row in rows: _notification_content.add_child(_label(row, 15, Color(0.87, 0.93, 0.95, 1))) _notification_content.add_child(_button("全部标为已读", func() -> void: _request_patch("/social/notifications/read-all", {}, func(_success: bool, _response: Dictionary, _error: Dictionary) -> void: _refresh_notifications()) )) func _panel(panel_size: Vector2) -> PanelContainer: var panel := PanelContainer.new() panel.size = panel_size panel.mouse_filter = Control.MOUSE_FILTER_STOP var style := StyleBoxFlat.new() style.bg_color = PANEL_COLOR style.border_color = Color(0.20, 0.52, 0.61, 0.9) style.set_border_width_all(1) style.set_corner_radius_all(12) style.shadow_color = Color(0, 0, 0, 0.45) style.shadow_size = 16 panel.add_theme_stylebox_override("panel", style) return panel func _margin(parent: Control) -> MarginContainer: var margin := MarginContainer.new() margin.add_theme_constant_override("margin_left", 20) margin.add_theme_constant_override("margin_top", 18) margin.add_theme_constant_override("margin_right", 20) margin.add_theme_constant_override("margin_bottom", 18) parent.add_child(margin) return margin func _label(text: String, font_size: int, color: Color) -> Label: var label := Label.new() label.text = text label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART label.add_theme_font_size_override("font_size", font_size) label.add_theme_color_override("font_color", color) return label func _button(text: String, callback: Callable) -> Button: var button := Button.new() button.text = text button.custom_minimum_size = Vector2(92, 34) button.pressed.connect(callback) return button func _avatar_badge(nickname: String) -> Label: var badge := Label.new() badge.text = nickname.left(1).to_upper() if not nickname.is_empty() else "玩" badge.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER badge.vertical_alignment = VERTICAL_ALIGNMENT_CENTER badge.custom_minimum_size = Vector2(52, 52) badge.add_theme_font_size_override("font_size", 22) badge.add_theme_color_override("font_color", Color(0.92, 0.99, 1.0, 1.0)) var style := StyleBoxFlat.new() style.bg_color = Color(0.12, 0.40, 0.48, 1.0) style.set_corner_radius_all(26) style.border_color = ACCENT style.set_border_width_all(1) badge.add_theme_stylebox_override("normal", style) return badge func _subpanel_style() -> StyleBoxFlat: var style := StyleBoxFlat.new() style.bg_color = Color(0.06, 0.13, 0.18, 0.88) style.set_border_width_all(1) style.border_color = Color(0.18, 0.45, 0.52, 0.75) style.set_corner_radius_all(8) style.content_margin_left = 10 style.content_margin_right = 10 style.content_margin_top = 8 style.content_margin_bottom = 8 return style func _close_profile() -> void: _profile_panel.visible = false func _show_status(message: String) -> void: push_warning("Social: %s" % message) func _request_get(endpoint: String, callback: Callable) -> void: var api := get_node_or_null("/root/ApiClient") if api != null: api.call("get_json", endpoint, callback, true) func _request_post(endpoint: String, payload: Dictionary, callback: Callable) -> void: var api := get_node_or_null("/root/ApiClient") if api != null: api.call("post_json", endpoint, payload, callback, true) func _request_patch(endpoint: String, payload: Dictionary, callback: Callable) -> void: var api := get_node_or_null("/root/ApiClient") if api != null: api.call("patch_json", endpoint, payload, callback, true) func _current_user_id() -> String: var auth := get_node_or_null("/root/AuthManager") if auth != null and auth.has_method("get_current_user"): var user_variant: Variant = auth.call("get_current_user") if user_variant is Dictionary: return str((user_variant as Dictionary).get("id", "")) return "" func _area_label(map_id: String) -> String: return {"whale_port": "中心广场", "work_zone": "打工区", "whale_cafe": "鲸鱼咖啡馆", "personal_space": "个人空间"}.get(map_id, map_id) func _interest_catalog() -> Array[Dictionary]: return [ {"id": "ai", "label": "AI/大模型"}, {"id": "programming", "label": "编程开发"}, {"id": "data_science", "label": "数据科学"}, {"id": "open_source", "label": "开源协作"}, {"id": "product", "label": "产品"}, {"id": "design", "label": "设计"}, {"id": "game_dev", "label": "游戏开发"}, {"id": "content_creation", "label": "内容创作"}, {"id": "community", "label": "社区活动"}, {"id": "learning_partner", "label": "学习搭子"}, {"id": "career", "label": "职业成长"}, {"id": "casual_chat", "label": "轻松闲聊"}, ]