From 9dee47492cd2e2ecc7366a95e8eb0dcd6a2a1f87 Mon Sep 17 00:00:00 2001 From: ANG-Server <96008766+ANGJustinl@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:19:41 +0800 Subject: [PATCH] feat: add nearby social interactions --- _Core/EventNames.gd | 2 + _Core/managers/AuthManager.gd | 7 + _Core/managers/ChatManager.gd | 47 ++- _Core/managers/InteractionManager.gd | 263 ++++++++++++ _Core/managers/InteractionManager.gd.uid | 1 + _Core/managers/SceneManager.gd | 9 + _Core/managers/SettingsManager.gd | 4 +- _Core/managers/SocialManager.gd | 438 ++++++++++++++++++++ _Core/managers/SocialManager.gd.uid | 1 + _Core/managers/UiEscapeManager.gd | 58 +++ _Core/managers/UiEscapeManager.gd.uid | 1 + _Core/ui/InteractionPointMarkers.gd | 20 + _Core/ui/InteractionPointMarkers.gd.uid | 1 + _Core/utils/InteractionAnchor.gd | 14 + _Core/utils/InteractionAnchor.gd.uid | 1 + project.godot | 8 +- scenes/Maps/CafeInterior.gd | 41 +- scenes/Maps/MapMultiplayerController.gd | 15 +- scenes/Maps/PersonalSpace.gd | 27 ++ scenes/Maps/ScenePortal.gd | 26 +- scenes/Maps/Square.gd | 29 ++ scenes/Maps/WorkZone.gd | 21 +- scenes/Maps/WorkZoneBuildingArea.gd | 16 + scenes/Maps/personal_space.tscn | 5 +- scenes/characters/NPCController.gd | 18 +- scenes/characters/PlayerController.gd | 56 ++- scenes/characters/RemotePlayer.gd | 49 +++ scenes/prefabs/items/DatawhaleHonorBoard.gd | 15 + scenes/prefabs/items/NoticeBoard.gd | 15 + scenes/prefabs/items/WelcomeBoard.gd | 15 + scenes/ui/AuthScene.gd | 10 + scenes/ui/CafeCompanionPanel.gd | 13 + scenes/ui/CafeCompanionRecruitmentPanel.gd | 10 + scenes/ui/ChatUI.gd | 12 + scenes/ui/CourseBoardPanel.gd | 12 + scenes/ui/DatawhaleHonorRankingPanel.gd | 12 + scenes/ui/FriendListPanel.gd | 10 + scenes/ui/MapPanel.gd | 199 ++++++++- scenes/ui/NoticeDialog.gd | 10 + scenes/ui/PlayerHud.gd | 8 +- scenes/ui/SettingsPanel.gd | 27 +- scenes/ui/WelcomeDialog.gd | 15 +- scenes/ui/mall/MallPanel.gd | 15 + 43 files changed, 1499 insertions(+), 77 deletions(-) create mode 100644 _Core/managers/InteractionManager.gd create mode 100644 _Core/managers/InteractionManager.gd.uid create mode 100644 _Core/managers/SocialManager.gd create mode 100644 _Core/managers/SocialManager.gd.uid create mode 100644 _Core/managers/UiEscapeManager.gd create mode 100644 _Core/managers/UiEscapeManager.gd.uid create mode 100644 _Core/ui/InteractionPointMarkers.gd create mode 100644 _Core/ui/InteractionPointMarkers.gd.uid create mode 100644 _Core/utils/InteractionAnchor.gd create mode 100644 _Core/utils/InteractionAnchor.gd.uid diff --git a/_Core/EventNames.gd b/_Core/EventNames.gd index 54450fa..873a292 100644 --- a/_Core/EventNames.gd +++ b/_Core/EventNames.gd @@ -82,6 +82,8 @@ const CHAT_LOGIN_FAILED = "chat_login_failed" const CHAT_PRIVATE_TARGET_SELECTED = "chat_private_target_selected" const CHAT_FRIEND_SELECTED = "chat_friend_selected" const CHAT_FRIENDS_UPDATED = "chat_friends_updated" +const SOCIAL_NOTIFICATION_RECEIVED = "social_notification_received" +const SOCIAL_PROFILE_UPDATED = "social_profile_updated" # ============================================================================ # 咖啡店陪伴机器人事件 diff --git a/_Core/managers/AuthManager.gd b/_Core/managers/AuthManager.gd index e51bb08..65ae2f1 100644 --- a/_Core/managers/AuthManager.gd +++ b/_Core/managers/AuthManager.gd @@ -32,6 +32,7 @@ var _session_generation: int = 0 var _account_generation: int = 0 var _refresh_in_flight: bool = false var _auth_config_path: String = DEFAULT_AUTH_CONFIG_PATH +var _show_welcome_after_registration: bool = false func _ready() -> void: _load_cached_session() @@ -70,6 +71,11 @@ func get_account_generation() -> int: func get_auth_config_path() -> String: return _auth_config_path +func consume_registration_welcome() -> bool: + var should_show: bool = _show_welcome_after_registration + _show_welcome_after_registration = false + return should_show + func login(identifier: String, password: String) -> void: var normalized_identifier := identifier.strip_edges() if normalized_identifier.is_empty(): @@ -297,6 +303,7 @@ func _on_register_response(success: bool, data: Dictionary, error_info: Dictiona register_failed.emit("注册响应缺少 access_token") return + _show_welcome_after_registration = true _emit_event(EventNames.AUTH_REGISTER_SUCCESS, { "user": get_current_user() }) diff --git a/_Core/managers/ChatManager.gd b/_Core/managers/ChatManager.gd index 18e0892..e8fb6ad 100644 --- a/_Core/managers/ChatManager.gd +++ b/_Core/managers/ChatManager.gd @@ -979,6 +979,12 @@ func _on_data_received(message: String) -> void: _handle_chat_error(data) "chat_render": _handle_chat_render(data) + "dm_message": + _handle_direct_message(data) + "dm_read": + _emit_event(EventNames.SOCIAL_NOTIFICATION_RECEIVED, {"type": "dm_read", "data": data}) + "notification_created", "friendship_changed", "friend_presence_changed": + _emit_event(EventNames.SOCIAL_NOTIFICATION_RECEIVED, {"type": message_type, "data": data}) "friend_list": _handle_friend_list(data) "friend_added": @@ -1301,6 +1307,41 @@ func _handle_chat_render(data: Dictionary) -> void: "is_private": is_private }) +func _handle_direct_message(data: Dictionary) -> void: + var message_variant: Variant = data.get("message", {}) + if not (message_variant is Dictionary): + return + var message: Dictionary = message_variant + var sender_variant: Variant = message.get("sender", {}) + var sender: Dictionary = sender_variant if sender_variant is Dictionary else {} + var sender_id := str(message.get("senderId", "")).strip_edges() + var recipient_id := str(message.get("recipientId", "")).strip_edges() + var from_user := str(sender.get("nickname", sender.get("username", "玩家"))).strip_edges() + var is_self := sender_id == _current_user_id() + var payload := { + "from": from_user, + "fromUserId": sender_id, + "txt": str(message.get("content", "")), + "timestamp": message.get("createdAt", Time.get_unix_time_from_system()), + "scope": "private", + "toUserId": recipient_id, + "toUsername": "", + "privateContext": "dm", + "bubble": false, + } + if is_self and _consume_pending_self_message(str(payload.get("txt", "")), "private", recipient_id): + return + _handle_chat_render(payload) + _emit_event(EventNames.SOCIAL_NOTIFICATION_RECEIVED, {"type": "dm_message", "data": data}) + +func _current_user_id() -> String: + var authManager := get_node_or_null("/root/AuthManager") + if authManager != null and authManager.has_method("get_current_user"): + var user_variant: Variant = authManager.call("get_current_user") + if user_variant is Dictionary: + return str((user_variant as Dictionary).get("id", "")) + return "" + # 解析聊天消息时间戳(兼容 unix 秒 / ISO 8601 字符串) func _parse_chat_timestamp_to_unix(timestamp_raw: Variant) -> float: if typeof(timestamp_raw) == TYPE_INT or typeof(timestamp_raw) == TYPE_FLOAT: @@ -1511,12 +1552,14 @@ func _normalize_friend_requests(requests_variant: Variant) -> Array[Dictionary]: if not (request_variant is Dictionary): continue var request: Dictionary = request_variant - var user_id := str(request.get("userId", request.get("user_id", ""))).strip_edges() + var requester_variant: Variant = request.get("requester", {}) + var requester: Dictionary = requester_variant if requester_variant is Dictionary else {} + var user_id := str(request.get("userId", request.get("user_id", requester.get("id", "")))).strip_edges() if user_id.is_empty(): continue requests.append({ "user_id": user_id, - "username": str(request.get("username", "玩家")).strip_edges(), + "username": str(request.get("username", requester.get("nickname", requester.get("username", "玩家")))).strip_edges(), "created_at": str(request.get("createdAt", request.get("created_at", ""))).strip_edges() }) return requests diff --git a/_Core/managers/InteractionManager.gd b/_Core/managers/InteractionManager.gd new file mode 100644 index 0000000..4d6798b --- /dev/null +++ b/_Core/managers/InteractionManager.gd @@ -0,0 +1,263 @@ +extends Node + +# 全地图统一交互:收集角色附近的动作,通过方向键选择、E 执行。 +const SCAN_INTERVAL: float = 0.10 +const MAX_ACTIONS_VISIBLE: int = 5 +const ACCENT: Color = Color("58c7db") +const PANEL_COLOR: Color = Color(0.035, 0.071, 0.106, 0.94) +const INTERACTION_POINT_MARKERS_SCRIPT: Script = preload("res://_Core/ui/InteractionPointMarkers.gd") +const InteractionAnchorUtil = preload("res://_Core/utils/InteractionAnchor.gd") + +var _actions: Array[Dictionary] = [] +var _selected_index: int = 0 +var _last_selected_id: String = "" +var _scan_elapsed: float = SCAN_INTERVAL +var _canvas: CanvasLayer +var _panel: PanelContainer +var _list: VBoxContainer +var _hint: Label +var _executing: bool = false +var _pointMarkers: Node2D + +func _ready() -> void: + _build_hud() + +func _process(delta: float) -> void: + _scan_elapsed += delta + if _scan_elapsed < SCAN_INTERVAL: + return + _scan_elapsed = 0.0 + _refresh_actions() + +func _unhandled_input(event: InputEvent) -> void: + if _actions.is_empty() or _is_text_input_focused(): + return + if not (event is InputEventKey): + return + var key_event := event as InputEventKey + if not key_event.pressed or key_event.echo: + return + if key_event.keycode == KEY_UP: + _select_offset(-1) + get_viewport().set_input_as_handled() + elif key_event.keycode == KEY_DOWN: + _select_offset(1) + get_viewport().set_input_as_handled() + elif key_event.keycode == KEY_E: + _execute_selected() + get_viewport().set_input_as_handled() + +func is_selection_active() -> bool: + return not _actions.is_empty() and not _is_text_input_focused() + +func _refresh_actions() -> void: + if _is_text_input_focused() or SceneManager.is_changing_scene: + _set_actions([]) + return + var player := _local_player() + if player == null: + _set_actions([]) + return + var collected: Array[Dictionary] = [] + for node in get_tree().get_nodes_in_group("whaletown_interactable"): + if not is_instance_valid(node) or not node.has_method("get_interaction_actions"): + continue + var actions_variant: Variant = node.call("get_interaction_actions", player) + if not (actions_variant is Array): + continue + for item in actions_variant as Array: + if item is Dictionary: + var action: Dictionary = item + if bool(action.get("available", true)): + collected.append(action) + collected.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + var priority_a := int(a.get("priority", 100)) + var priority_b := int(b.get("priority", 100)) + if priority_a != priority_b: + return priority_a < priority_b + var distance_a := float(a.get("distance", INF)) + var distance_b := float(b.get("distance", INF)) + if not is_equal_approx(distance_a, distance_b): + return distance_a < distance_b + return str(a.get("title", "")) < str(b.get("title", "")) + ) + _set_actions(collected) + +func _set_actions(next_actions: Array[Dictionary]) -> void: + _actions = next_actions + if _actions.is_empty(): + _selected_index = 0 + _last_selected_id = "" + _render() + return + var matched_index := -1 + for index in _actions.size(): + if str(_actions[index].get("id", "")) == _last_selected_id: + matched_index = index + break + _selected_index = matched_index if matched_index >= 0 else clampi(_selected_index, 0, _actions.size() - 1) + _last_selected_id = str(_actions[_selected_index].get("id", "")) + _render() + +func _select_offset(offset: int) -> void: + if _actions.is_empty(): + return + _selected_index = posmod(_selected_index + offset, _actions.size()) + _last_selected_id = str(_actions[_selected_index].get("id", "")) + _render() + +func _execute_selected() -> void: + if _executing or _actions.is_empty(): + return + var action := _actions[_selected_index] + var callback_variant: Variant = action.get("callback", Callable()) + if not (callback_variant is Callable) or not (callback_variant as Callable).is_valid(): + return + _executing = true + _render() + (callback_variant as Callable).call() + await get_tree().create_timer(0.18).timeout + _executing = false + _refresh_actions() + +func _local_player() -> Node2D: + var players := get_tree().get_nodes_in_group("whaletown_local_player") + return players.front() as Node2D if not players.is_empty() else null + +func _build_hud() -> void: + _canvas = CanvasLayer.new() + _canvas.layer = 90 + add_child(_canvas) + _panel = PanelContainer.new() + _panel.set_anchors_preset(Control.PRESET_CENTER_BOTTOM) + _panel.position = Vector2(-250, -282) + _panel.size = Vector2(500, 214) + _panel.mouse_filter = Control.MOUSE_FILTER_IGNORE + _panel.add_theme_stylebox_override("panel", _panel_style()) + _canvas.add_child(_panel) + var margin := MarginContainer.new() + margin.add_theme_constant_override("margin_left", 14) + margin.add_theme_constant_override("margin_top", 10) + margin.add_theme_constant_override("margin_right", 14) + margin.add_theme_constant_override("margin_bottom", 10) + _panel.add_child(margin) + var content := VBoxContainer.new() + content.add_theme_constant_override("separation", 4) + margin.add_child(content) + _list = VBoxContainer.new() + _list.add_theme_constant_override("separation", 2) + content.add_child(_list) + _hint = Label.new() + _hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _hint.add_theme_color_override("font_color", Color(0.67, 0.79, 0.85, 1.0)) + _hint.add_theme_font_size_override("font_size", 14) + _hint.text = "[↑/↓] 选择 [E] 交互" + content.add_child(_hint) + _render() + +func _render() -> void: + if not is_instance_valid(_panel): + return + var show_hints: bool = _should_show_interaction_hints() + _panel.visible = show_hints and not _actions.is_empty() + _render_interaction_points() + for child in _list.get_children(): + child.queue_free() + if _actions.is_empty(): + return + var start: int = clampi(_selected_index - 2, 0, maxi(0, _actions.size() - MAX_ACTIONS_VISIBLE)) + var finish: int = mini(_actions.size(), start + MAX_ACTIONS_VISIBLE) + for index in range(start, finish): + var action := _actions[index] + var row := Label.new() + row.custom_minimum_size = Vector2(0, 29) + row.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + row.add_theme_font_size_override("font_size", 16) + var selected := index == _selected_index + row.text = ("› " if selected else " ") + str(action.get("title", "交互")) + (" …" if selected and _executing else "") + row.add_theme_color_override("font_color", ACCENT if selected else Color(0.92, 0.96, 0.98, 1.0)) + if selected: + row.add_theme_stylebox_override("normal", _selected_style()) + _list.add_child(row) + _hint.text = "[↑/↓] 选择 [E] 交互 [Esc] 关闭" + (" %d 项" % _actions.size() if _actions.size() > 1 else "") + +func _render_interaction_points() -> void: + if not _should_show_interaction_points(): + if is_instance_valid(_pointMarkers): + var empty_points: Array[Vector2] = [] + _pointMarkers.call("set_points", empty_points) + return + var markers: Node2D = _ensure_point_markers() + if markers == null: + return + var positions: Array[Vector2] = [] + for node: Node in get_tree().get_nodes_in_group("whaletown_interactable"): + if not is_instance_valid(node) or not node.has_method("get_interaction_actions"): + continue + if node.has_method("get_interaction_marker_positions"): + var marker_positions_variant: Variant = node.call("get_interaction_marker_positions") + if marker_positions_variant is Array: + for position_variant: Variant in marker_positions_variant as Array: + if position_variant is Vector2: + positions.append(position_variant as Vector2) + continue + var source_node: Node2D = node as Node2D + if source_node != null: + positions.append(InteractionAnchorUtil.get_position(source_node)) + markers.call("set_points", positions) + +func _ensure_point_markers() -> Node2D: + var current_scene: Node = get_tree().current_scene + var world_root: Node2D = current_scene as Node2D + if world_root == null: + return null + if is_instance_valid(_pointMarkers) and _pointMarkers.get_parent() == world_root: + return _pointMarkers + var markers: Node2D = INTERACTION_POINT_MARKERS_SCRIPT.new() as Node2D + if markers == null: + return null + markers.name = "InteractionPointMarkers" + markers.z_index = 4096 + markers.z_as_relative = false + world_root.add_child(markers) + _pointMarkers = markers + return _pointMarkers + +func _should_show_interaction_hints() -> bool: + return _get_setting_enabled("show_interaction_hints", true) + +func _should_show_interaction_points() -> bool: + return _get_setting_enabled("show_interaction_points", false) + +func _get_setting_enabled(setting_key: String, fallback: bool) -> bool: + var settings_manager: Node = get_node_or_null("/root/SettingsManager") + if settings_manager != null and settings_manager.has_method("get_bool"): + return bool(settings_manager.call("get_bool", setting_key)) + return fallback + +func _panel_style() -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.bg_color = PANEL_COLOR + style.border_color = Color(0.20, 0.45, 0.54, 0.86) + style.set_border_width_all(1) + 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.shadow_color = Color(0, 0, 0, 0.35) + style.shadow_size = 10 + return style + +func _selected_style() -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.bg_color = Color(0.12, 0.32, 0.38, 0.74) + style.corner_radius_top_left = 6 + style.corner_radius_top_right = 6 + style.corner_radius_bottom_left = 6 + style.corner_radius_bottom_right = 6 + style.content_margin_left = 6 + return style + +func _is_text_input_focused() -> bool: + var focus_owner := get_viewport().gui_get_focus_owner() + return focus_owner is LineEdit or focus_owner is TextEdit diff --git a/_Core/managers/InteractionManager.gd.uid b/_Core/managers/InteractionManager.gd.uid new file mode 100644 index 0000000..d4eec86 --- /dev/null +++ b/_Core/managers/InteractionManager.gd.uid @@ -0,0 +1 @@ +uid://cg46uuk3hxvqe diff --git a/_Core/managers/SceneManager.gd b/_Core/managers/SceneManager.gd index 3f9ee98..e69824a 100644 --- a/_Core/managers/SceneManager.gd +++ b/_Core/managers/SceneManager.gd @@ -38,6 +38,7 @@ var current_scene_name: String = "" # 当前场景名称 var is_changing_scene: bool = false # 是否正在切换场景 var _next_scene_position: Variant = null # 下一个场景的初始位置 (Vector2 or null) var _next_spawn_name: String = "" # 下一个场景的出生点名称 (String) +var _next_destination_id: String = "" # 快速传送目标,用于发现登记 # 场景路径映射表 # 将场景名称映射到实际的文件路径 @@ -194,6 +195,14 @@ func get_next_spawn_name() -> String: _next_spawn_name = "" return spawn_name +func set_next_destination_id(destination_id: String) -> void: + _next_destination_id = destination_id.strip_edges() + +func get_next_destination_id() -> String: + var destination_id := _next_destination_id + _next_destination_id = "" + return destination_id + # ============ 场景注册方法 ============ # 注册新场景 diff --git a/_Core/managers/SettingsManager.gd b/_Core/managers/SettingsManager.gd index 3246108..1cdb19d 100644 --- a/_Core/managers/SettingsManager.gd +++ b/_Core/managers/SettingsManager.gd @@ -15,7 +15,8 @@ const DEFAULT_SETTINGS: Dictionary = { "effects_volume": 0.90, "ui_scale": 1.00, "fullscreen": false, - "show_interaction_hints": false, + "show_interaction_hints": true, + "show_interaction_points": false, "show_name_always": false, "show_chat_bubbles": true, "world_notifications": true, @@ -23,6 +24,7 @@ const DEFAULT_SETTINGS: Dictionary = { "friend_request_notifications": true, "allow_nearby_private": true, "allow_nearby_friend_requests": true, + "allow_nearby_profile": true, "mute_ui_sfx": false, } diff --git a/_Core/managers/SocialManager.gd b/_Core/managers/SocialManager.gd new file mode 100644 index 0000000..39d0150 --- /dev/null +++ b/_Core/managers/SocialManager.gd @@ -0,0 +1,438 @@ +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": "轻松闲聊"}, + ] diff --git a/_Core/managers/SocialManager.gd.uid b/_Core/managers/SocialManager.gd.uid new file mode 100644 index 0000000..039077e --- /dev/null +++ b/_Core/managers/SocialManager.gd.uid @@ -0,0 +1 @@ +uid://cm5em8mgr5i24 diff --git a/_Core/managers/UiEscapeManager.gd b/_Core/managers/UiEscapeManager.gd new file mode 100644 index 0000000..a74c8eb --- /dev/null +++ b/_Core/managers/UiEscapeManager.gd @@ -0,0 +1,58 @@ +extends Node + +# 统一处理 UI 的 Esc 退出。可关闭界面加入 whaletown_escape_dismissible 分组, +# 并实现 is_escape_dismissible / get_escape_priority / request_escape_close。 +const ESCAPE_DISMISSIBLE_GROUP: StringName = &"whaletown_escape_dismissible" + +func _ready() -> void: + # 公告、排行榜等界面会暂停场景树,Esc 仍必须有效。 + process_mode = Node.PROCESS_MODE_ALWAYS + +func _input(event: InputEvent) -> void: + if not (event is InputEventKey): + return + var key_event: InputEventKey = event as InputEventKey + if not key_event.pressed or key_event.echo or key_event.keycode != KEY_ESCAPE: + return + if _hide_open_popup_menu() or _close_topmost_dismissible() or _release_control_focus(): + get_viewport().set_input_as_handled() + +func _hide_open_popup_menu() -> bool: + var popup_nodes: Array[Node] = get_tree().root.find_children("*", "PopupMenu", true, false) + for popup_node: Node in popup_nodes: + var popup: PopupMenu = popup_node as PopupMenu + if popup != null and popup.visible: + popup.hide() + return true + return false + +func _close_topmost_dismissible() -> bool: + var target: Node = null + var target_priority: int = -2147483648 + var dismissibles: Array[Node] = get_tree().get_nodes_in_group(ESCAPE_DISMISSIBLE_GROUP) + for dismissible: Node in dismissibles: + if not is_instance_valid(dismissible): + continue + if not dismissible.has_method("is_escape_dismissible") or not dismissible.has_method("request_escape_close"): + continue + var is_dismissible_variant: Variant = dismissible.call("is_escape_dismissible") + if not bool(is_dismissible_variant): + continue + var priority: int = 0 + if dismissible.has_method("get_escape_priority"): + var priority_variant: Variant = dismissible.call("get_escape_priority") + priority = int(priority_variant) + if target == null or priority > target_priority: + target = dismissible + target_priority = priority + if target == null: + return false + target.call("request_escape_close") + return true + +func _release_control_focus() -> bool: + var focus_owner: Control = get_viewport().gui_get_focus_owner() + if focus_owner == null: + return false + focus_owner.release_focus() + return true diff --git a/_Core/managers/UiEscapeManager.gd.uid b/_Core/managers/UiEscapeManager.gd.uid new file mode 100644 index 0000000..c093829 --- /dev/null +++ b/_Core/managers/UiEscapeManager.gd.uid @@ -0,0 +1 @@ +uid://otx02vxmfni3 diff --git a/_Core/ui/InteractionPointMarkers.gd b/_Core/ui/InteractionPointMarkers.gd new file mode 100644 index 0000000..a909200 --- /dev/null +++ b/_Core/ui/InteractionPointMarkers.gd @@ -0,0 +1,20 @@ +extends Node2D + +# 地图内可交互目标的轻量视觉标记,由 InteractionManager 提供世界坐标。 +const RING_COLOR: Color = Color(1.0, 1.0, 1.0, 0.94) +const CORE_COLOR: Color = Color(1.0, 1.0, 1.0, 0.58) +const RING_RADIUS: float = 9.0 +const RING_WIDTH: float = 2.0 + +var _points: Array[Vector2] = [] + +func set_points(points: Array[Vector2]) -> void: + if _points == points: + return + _points = points.duplicate() + queue_redraw() + +func _draw() -> void: + for point: Vector2 in _points: + draw_arc(point, RING_RADIUS, 0.0, TAU, 24, RING_COLOR, RING_WIDTH, true) + draw_circle(point, 2.0, CORE_COLOR) diff --git a/_Core/ui/InteractionPointMarkers.gd.uid b/_Core/ui/InteractionPointMarkers.gd.uid new file mode 100644 index 0000000..f8bb2f9 --- /dev/null +++ b/_Core/ui/InteractionPointMarkers.gd.uid @@ -0,0 +1 @@ +uid://de27p7wr4gj2b diff --git a/_Core/utils/InteractionAnchor.gd b/_Core/utils/InteractionAnchor.gd new file mode 100644 index 0000000..affc423 --- /dev/null +++ b/_Core/utils/InteractionAnchor.gd @@ -0,0 +1,14 @@ +class_name InteractionAnchor +extends RefCounted + +# 所有交互距离与地图标记共用的世界锚点。 +# Area2D 优先使用实际 CollisionShape2D 的中心,避免脚本节点原点与碰撞区域有偏移时 +# 出现“能交互的位置”和白圈不一致的问题。 +static func get_position(node: Node2D) -> Vector2: + var area: Area2D = node as Area2D + if area != null: + for child: Node in area.get_children(): + var collision_shape: CollisionShape2D = child as CollisionShape2D + if collision_shape != null and not collision_shape.disabled and collision_shape.shape != null: + return collision_shape.global_position + return node.global_position diff --git a/_Core/utils/InteractionAnchor.gd.uid b/_Core/utils/InteractionAnchor.gd.uid new file mode 100644 index 0000000..ed9b36f --- /dev/null +++ b/_Core/utils/InteractionAnchor.gd.uid @@ -0,0 +1 @@ +uid://dogltsticcpy2 diff --git a/project.godot b/project.godot index de0d3d1..59c2aba 100644 --- a/project.godot +++ b/project.godot @@ -21,6 +21,7 @@ config/icon="res://icon.svg" [autoload] +UiEscapeManager="*res://_Core/managers/UiEscapeManager.gd" SceneManager="*res://_Core/managers/SceneManager.gd" EventSystem="*res://_Core/systems/EventSystem.gd" ApiClient="*res://_Core/managers/ApiClient.gd" @@ -32,6 +33,8 @@ CafeCompanionManager="*res://_Core/managers/CafeCompanionManager.gd" AppearanceManager="*res://_Core/managers/AppearanceManager.gd" SettingsManager="*res://_Core/managers/SettingsManager.gd" NotificationSoundManager="*res://_Core/managers/NotificationSoundManager.gd" +SocialManager="*res://_Core/managers/SocialManager.gd" +InteractionManager="*res://_Core/managers/InteractionManager.gd" [display] @@ -71,11 +74,6 @@ interact={ "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null) ] } -friend_request={ -"deadzone": 0.5, -"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":70,"key_label":0,"unicode":102,"location":0,"echo":false,"script":null) -] -} [rendering] diff --git a/scenes/Maps/CafeInterior.gd b/scenes/Maps/CafeInterior.gd index db11e34..a9cce9a 100644 --- a/scenes/Maps/CafeInterior.gd +++ b/scenes/Maps/CafeInterior.gd @@ -23,6 +23,7 @@ const COMPANION_NAMEPLATE_VISUAL_MAX_WIDTH: int = 118 const COMPANION_NAMEPLATE_VISUAL_CHAR_WIDTH: int = 12 const NPC_NAMEPLATE_OFFSET_Y: float = -136.0 const HIRED_PLAYER_NAMEPLATE_OFFSET_Y: float = -96.0 +const InteractionAnchorUtil = preload("res://_Core/utils/InteractionAnchor.gd") @onready var player: PlayerController = $YSortWorld/Characters/Players/Player @onready var playerCamera: Camera2D = $YSortWorld/Characters/Players/Player/Camera2D @@ -38,6 +39,7 @@ var _isChangingScene: bool = false var _lastRecruitmentClickMsec: int = 0 func _ready() -> void: + add_to_group("whaletown_interactable") _align_service_occupants() _configure_static_companion_nameplates() _apply_spawn_point() @@ -45,6 +47,15 @@ func _ready() -> void: _connect_exit_area() _connect_recruitment_area() _connect_cafe_companion_events() + _discover_destination() + +func _discover_destination() -> void: + var destination_id := SceneManager.get_next_destination_id() + if destination_id.is_empty(): + destination_id = "cafe_entrance" + var socialManager := get_node_or_null("/root/SocialManager") + if socialManager != null and socialManager.has_method("discover_destination"): + socialManager.call("discover_destination", destination_id) func _exit_tree() -> void: var eventSystem := get_node_or_null("/root/EventSystem") @@ -56,6 +67,8 @@ func _align_service_occupants() -> void: cafeWhaleBaristaNpc.global_position = serviceIdlePoint01.global_position func _apply_spawn_point() -> void: + if player.has_scene_position_override: + return var spawnName: String = SceneManager.get_next_spawn_name() var markerName: String = spawnName if not spawnName.is_empty() else "DefaultSpawn" var marker := $Markers.get_node_or_null(markerName) as Marker2D @@ -75,8 +88,7 @@ func _configure_camera() -> void: playerCamera.limit_smoothed = true func _connect_exit_area() -> void: - if not exitToWorkZoneArea.body_entered.is_connected(_on_exit_area_body_entered): - exitToWorkZoneArea.body_entered.connect(_on_exit_area_body_entered) + exitToWorkZoneArea.collision_mask = 0 func _connect_recruitment_area() -> void: cafeRecruitmentLogoArea.input_pickable = true @@ -92,7 +104,30 @@ func _connect_cafe_companion_events() -> void: eventSystem.call("connect_event", EventNames.CAFE_COMPANION_EMPLOYMENT_RESIGNED, _on_cafe_companion_employment_resigned, self) func _on_exit_area_body_entered(body: Node2D) -> void: - if _isChangingScene or body != player: + return + +func get_interaction_actions(local_player: Node2D) -> Array[Dictionary]: + if _isChangingScene or exitToWorkZoneArea == null: + return [] + var anchor_position: Vector2 = InteractionAnchorUtil.get_position(exitToWorkZoneArea) + var distance: float = anchor_position.distance_to(local_player.global_position) + if distance > 160.0: + return [] + return [{ + "id": "cafe_exit", + "title": "离开咖啡馆", + "distance": distance, + "priority": 20, + "callback": Callable(self, "_leave_to_work_zone"), + }] + +func get_interaction_marker_positions() -> Array[Vector2]: + if exitToWorkZoneArea == null: + return [] + return [InteractionAnchorUtil.get_position(exitToWorkZoneArea)] + +func _leave_to_work_zone() -> void: + if _isChangingScene: return _isChangingScene = true SceneManager.set_next_scene_position(WORK_ZONE_CAFE_RETURN_POSITION) diff --git a/scenes/Maps/MapMultiplayerController.gd b/scenes/Maps/MapMultiplayerController.gd index 804f837..d985acf 100644 --- a/scenes/Maps/MapMultiplayerController.gd +++ b/scenes/Maps/MapMultiplayerController.gd @@ -56,19 +56,10 @@ func _ready() -> void: call_deferred("_send_world_ready") func _process(_delta: float) -> void: - if Input.is_action_just_pressed("interact"): - _try_start_private_chat() - if Input.is_action_just_pressed("friend_request"): - _try_request_friend() + return func _unhandled_input(event: InputEvent) -> void: - if event.is_action_pressed("interact"): - if _try_start_private_chat(): - get_viewport().set_input_as_handled() - return - - if event.is_action_pressed("friend_request") and _try_request_friend(): - get_viewport().set_input_as_handled() + return func _exit_tree() -> void: var eventSystem := _get_event_system() @@ -117,7 +108,7 @@ func _is_text_input_focused() -> bool: return false func _on_interact_pressed(_data: Dictionary = {}) -> void: - _try_start_private_chat() + return func _try_start_private_chat() -> bool: if _is_text_input_focused(): diff --git a/scenes/Maps/PersonalSpace.gd b/scenes/Maps/PersonalSpace.gd index f2c584f..03699fb 100644 --- a/scenes/Maps/PersonalSpace.gd +++ b/scenes/Maps/PersonalSpace.gd @@ -46,6 +46,7 @@ var _inventoryRequestInFlight: bool = false var _inventoryRequestReportsErrors: bool = false func _ready() -> void: + add_to_group("whaletown_escape_dismissible") _leave_multiplayer_world() _apply_spawn_point() _configure_camera() @@ -54,8 +55,17 @@ func _ready() -> void: _build_room_decor_ui() _connect_room_decor_events() _fetch_room_decor_inventory() + _discover_destination() set_process(false) +func _discover_destination() -> void: + var destination_id := SceneManager.get_next_destination_id() + if destination_id.is_empty(): + destination_id = "personal_room" + var socialManager := get_node_or_null("/root/SocialManager") + if socialManager != null and socialManager.has_method("discover_destination"): + socialManager.call("discover_destination", destination_id) + func _leave_multiplayer_world() -> void: var chatManager := get_node_or_null("/root/ChatManager") if chatManager != null and chatManager.has_method("leave_world"): @@ -73,6 +83,8 @@ func _exit_tree() -> void: saveManager.decor_save_failed.disconnect(_on_decor_save_failed) func _input(event: InputEvent) -> void: + if get_viewport().is_input_handled(): + return if event is InputEventMouseButton: var mouseEvent := event as InputEventMouseButton if mouseEvent.button_index != MOUSE_BUTTON_LEFT: @@ -91,7 +103,22 @@ func _process(_delta: float) -> void: if _draggedDecor != null: _update_dragged_decor_position() +func is_escape_dismissible() -> bool: + return _draggedDecor != null or (is_instance_valid(_inventoryPanel) and _inventoryPanel.visible) + +func get_escape_priority() -> int: + return 500 + +func request_escape_close() -> void: + if _draggedDecor != null: + _finish_decor_drag() + return + if is_instance_valid(_inventoryPanel): + _inventoryPanel.visible = false + func _apply_spawn_point() -> void: + if player.has_scene_position_override: + return var spawnName: String = SceneManager.get_next_spawn_name() var markerName: String = spawnName if not spawnName.is_empty() else "DefaultSpawn" var marker := $Markers.get_node_or_null(markerName) as Marker2D diff --git a/scenes/Maps/ScenePortal.gd b/scenes/Maps/ScenePortal.gd index dced73e..8b2e2b4 100644 --- a/scenes/Maps/ScenePortal.gd +++ b/scenes/Maps/ScenePortal.gd @@ -1,6 +1,8 @@ class_name ScenePortal extends Area2D +const InteractionAnchorUtil = preload("res://_Core/utils/InteractionAnchor.gd") + # ============================================================================ # ScenePortal.gd - 场景入口传送组件 # ============================================================================ @@ -10,14 +12,30 @@ extends Area2D @export var targetSceneName: String = "" @export var targetSpawnName: String = "" @export var targetPosition: Vector2 = Vector2.ZERO +@export var interactionTitle: String = "进入" +@export var interactionDistance: float = 160.0 func _ready() -> void: - body_entered.connect(_on_body_entered) + add_to_group("whaletown_interactable") func _on_body_entered(body: Node2D) -> void: - if not (body is PlayerController): - return - _change_scene() + return + +func get_interaction_actions(player: Node2D) -> Array[Dictionary]: + var anchor_position: Vector2 = InteractionAnchorUtil.get_position(self) + var distance: float = anchor_position.distance_to(player.global_position) + if distance > interactionDistance: + return [] + var label := interactionTitle.strip_edges() + if label.is_empty(): + label = "进入 %s" % targetSceneName + return [{ + "id": "portal:%s" % get_instance_id(), + "title": label, + "distance": distance, + "priority": 20, + "callback": Callable(self, "_change_scene"), + }] func _change_scene() -> void: if targetSceneName.is_empty(): diff --git a/scenes/Maps/Square.gd b/scenes/Maps/Square.gd index 2cde95c..b73c233 100644 --- a/scenes/Maps/Square.gd +++ b/scenes/Maps/Square.gd @@ -12,6 +12,8 @@ const CAMERA_LIMIT_LEFT: int = -1280 const CAMERA_LIMIT_TOP: int = -960 const CAMERA_LIMIT_RIGHT: int = 1280 const CAMERA_LIMIT_BOTTOM: int = 960 +const WELCOME_DIALOG_SCENE: PackedScene = preload("res://scenes/ui/welcome_dialog.tscn") +const WELCOME_DIALOG_NAME: String = "WelcomeDialog" @onready var player: PlayerController = $YSortWorld/Characters/Players/Player @onready var playerCamera: Camera2D = $YSortWorld/Characters/Players/Player/Camera2D @@ -20,8 +22,35 @@ const CAMERA_LIMIT_BOTTOM: int = 960 func _ready() -> void: _apply_spawn_point() _configure_camera() + _discover_destination() + call_deferred("_show_registration_welcome") + +func _show_registration_welcome() -> void: + var auth_manager: Node = get_node_or_null("/root/AuthManager") + if auth_manager == null or not auth_manager.has_method("consume_registration_welcome"): + return + if not bool(auth_manager.call("consume_registration_welcome")): + return + var root: Window = get_tree().root + if root.has_node(WELCOME_DIALOG_NAME): + return + var dialog: CanvasLayer = WELCOME_DIALOG_SCENE.instantiate() as CanvasLayer + if dialog == null: + return + dialog.name = WELCOME_DIALOG_NAME + root.add_child(dialog) + +func _discover_destination() -> void: + var destination_id := SceneManager.get_next_destination_id() + if destination_id.is_empty(): + destination_id = "square_center" + var socialManager := get_node_or_null("/root/SocialManager") + if socialManager != null and socialManager.has_method("discover_destination"): + socialManager.call("discover_destination", destination_id) func _apply_spawn_point() -> void: + if player.has_scene_position_override: + return var spawnName: String = SceneManager.get_next_spawn_name() var markerName: String = spawnName if not spawnName.is_empty() else "DefaultSpawn" var marker := $Markers.get_node_or_null(markerName) as Marker2D diff --git a/scenes/Maps/WorkZone.gd b/scenes/Maps/WorkZone.gd index 189e5f7..f721b60 100644 --- a/scenes/Maps/WorkZone.gd +++ b/scenes/Maps/WorkZone.gd @@ -31,6 +31,7 @@ var _isInsideMall: bool = false var _mallCanEnter: bool = true func _ready() -> void: + add_to_group("whaletown_interactable") _apply_spawn_point() _configure_camera() _ensure_mall_panel() @@ -38,12 +39,23 @@ func _ready() -> void: _ensure_mall_entrance_area() EventSystem.connect_event(EventNames.OBJECT_INTERACTED, _on_object_interacted, self) EventSystem.connect_event(EventNames.MALL_CLOSED, _on_mall_closed, self) + _discover_destination() + +func _discover_destination() -> void: + var destination_id := SceneManager.get_next_destination_id() + if destination_id.is_empty(): + destination_id = "work_entrance" + var socialManager := get_node_or_null("/root/SocialManager") + if socialManager != null and socialManager.has_method("discover_destination"): + socialManager.call("discover_destination", destination_id) func _exit_tree() -> void: EventSystem.disconnect_event(EventNames.OBJECT_INTERACTED, _on_object_interacted, self) EventSystem.disconnect_event(EventNames.MALL_CLOSED, _on_mall_closed, self) func _apply_spawn_point() -> void: + if player.has_scene_position_override: + return var spawnName: String = SceneManager.get_next_spawn_name() var markerName: String = spawnName if not spawnName.is_empty() else "DefaultSpawn" var marker := $Markers.get_node_or_null(markerName) as Marker2D @@ -112,15 +124,14 @@ func _ensure_mall_entrance_area() -> void: func _setup_mall_entrance_trigger(entrance: Area2D) -> void: _mallEntranceArea = entrance - _mallEntranceArea.collision_mask = PLAYER_COLLISION_LAYER - if not _mallEntranceArea.body_entered.is_connected(_on_mall_entrance_body_entered): - _mallEntranceArea.body_entered.connect(_on_mall_entrance_body_entered) + _mallEntranceArea.collision_mask = 0 func _on_object_interacted(data: Dictionary) -> void: var buildingId := str(data.get("buildingId", "")) if buildingId.is_empty(): return if buildingId == "whale_super_mall": + _enter_mall() return if buildingId == "virtual_whale_recruitment_board": _open_course_board() @@ -142,9 +153,7 @@ func _open_course_board() -> void: _courseBoardPanel.call("show_panel") func _on_mall_entrance_body_entered(body: Node2D) -> void: - if body != player or not _mallCanEnter: - return - _enter_mall() + return func _enter_mall() -> void: if _isInsideMall: diff --git a/scenes/Maps/WorkZoneBuildingArea.gd b/scenes/Maps/WorkZoneBuildingArea.gd index a76447a..b882bb5 100644 --- a/scenes/Maps/WorkZoneBuildingArea.gd +++ b/scenes/Maps/WorkZoneBuildingArea.gd @@ -8,14 +8,30 @@ extends Area2D # ============================================================================ const INTERACTION_COLLISION_LAYER: int = 2 +const InteractionAnchorUtil = preload("res://_Core/utils/InteractionAnchor.gd") @export var buildingId: String = "" @export var buildingTitle: String = "" @export var buildingRole: String = "" +@export var interactionDistance: float = 150.0 func _ready() -> void: collision_layer = INTERACTION_COLLISION_LAYER collision_mask = 0 + add_to_group("whaletown_interactable") + +func get_interaction_actions(player: Node2D) -> Array[Dictionary]: + var anchor_position: Vector2 = InteractionAnchorUtil.get_position(self) + var distance: float = anchor_position.distance_to(player.global_position) + if distance > interactionDistance: + return [] + return [{ + "id": "building:%s" % buildingId, + "title": "使用 %s" % (buildingTitle if not buildingTitle.is_empty() else "设施"), + "distance": distance, + "priority": 30, + "callback": Callable(self, "interact"), + }] func interact() -> void: var payload := { diff --git a/scenes/Maps/personal_space.tscn b/scenes/Maps/personal_space.tscn index cbc5d6c..14e6dd2 100644 --- a/scenes/Maps/personal_space.tscn +++ b/scenes/Maps/personal_space.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=12 format=4] +[gd_scene load_steps=13 format=4] [ext_resource type="Script" path="res://scenes/Maps/PersonalSpace.gd" id="1_personal_space"] [ext_resource type="Texture2D" path="res://assets/maps/personal_space/v1/base/personal_room_25d_sidewalls_wider_not_longer_v1.png" id="2_room_base"] @@ -8,6 +8,7 @@ [ext_resource type="PackedScene" path="res://scenes/ui/PlayerHud.tscn" id="6_playerhud"] [ext_resource type="PackedScene" path="res://scenes/ui/FriendListPanel.tscn" id="7_friendpanel"] [ext_resource type="PackedScene" path="res://scenes/ui/SettingsPanel.tscn" id="8_settingspanel"] +[ext_resource type="PackedScene" path="res://scenes/ui/MapPanel.tscn" id="9_mappanel"] [sub_resource type="RectangleShape2D" id="Shape_TopWall"] size = Vector2(882, 76) @@ -35,6 +36,8 @@ layer = 10 [node name="SettingsPanel" parent="UILayer" instance=ExtResource("8_settingspanel")] +[node name="MapPanel" parent="UILayer" instance=ExtResource("9_mappanel")] + [node name="StageBackdrop" type="ColorRect" parent="."] z_index = -200 offset_left = -2000.0 diff --git a/scenes/characters/NPCController.gd b/scenes/characters/NPCController.gd index 2a0142d..6c28d8a 100644 --- a/scenes/characters/NPCController.gd +++ b/scenes/characters/NPCController.gd @@ -18,6 +18,7 @@ class_name NPCController signal interaction_happened(text: String) const CHAT_BUBBLE_SCENE: PackedScene = preload("res://scenes/ui/ChatBubble.tscn") +const InteractionAnchorUtil = preload("res://_Core/utils/InteractionAnchor.gd") const CHAT_BUBBLE_LAYER_NAME: String = "WorldChatBubbleLayer" const CHAT_BUBBLE_TARGET_OFFSET: Vector2 = Vector2(0, -84) const NPC_TALKED_EVENT: String = "npc_talked" @@ -34,12 +35,14 @@ const NAMEPLATE_VISUAL_CHAR_WIDTH: int = 12 @export_multiline var dialogue: String = "欢迎来到WhaleTown,我是镇长范鲸晶" @export var showNameplate: bool = false @export var nameplateOffsetY: float = -112.0 +@export var interactionDistance: float = 150.0 @onready var animation_player: AnimationPlayer = $AnimationPlayer var _nameplate: Label func _ready() -> void: + add_to_group("whaletown_interactable") # 播放场景里配置好的待机动画,让不同 NPC 可以复用同一个控制器。 if animation_player.has_animation("idle"): animation_player.play("idle") @@ -63,7 +66,20 @@ func interact() -> void: "npc_name": npcName, "dialogue": dialogue }) - interaction_happened.emit(dialogue) + interaction_happened.emit(dialogue) + +func get_interaction_actions(player: Node2D) -> Array[Dictionary]: + var anchor_position: Vector2 = InteractionAnchorUtil.get_position(self) + var distance: float = anchor_position.distance_to(player.global_position) + if distance > interactionDistance: + return [] + return [{ + "id": "npc:%s" % get_instance_id(), + "title": "与 %s 交谈" % npcName, + "distance": distance, + "priority": 40, + "callback": Callable(self, "interact"), + }] # 在 NPC 头顶生成一次性聊天气泡。 # diff --git a/scenes/characters/PlayerController.gd b/scenes/characters/PlayerController.gd index f46e862..1c35397 100644 --- a/scenes/characters/PlayerController.gd +++ b/scenes/characters/PlayerController.gd @@ -25,8 +25,11 @@ const DIRECTION_ROWS: Dictionary = { var lastDirection: String = "down" var _nameLabel: Label var _movementLocked: bool = false +var has_scene_position_override: bool = false +var _discoveryElapsed: float = 0.0 func _ready() -> void: + add_to_group("whaletown_local_player") _reset_movement_input_state() _apply_current_appearance() _subscribe_to_appearance_events() @@ -71,28 +74,40 @@ func _release_movement_actions() -> void: func _check_spawn_position() -> void: var spawnPos: Variant = SceneManager.get_next_scene_position() - if spawnPos != null: + if spawnPos is Vector2: global_position = spawnPos - _update_world_sort_z() + has_scene_position_override = true + _update_world_sort_z() func _physics_process(delta: float) -> void: _handle_movement(delta) _update_world_sort_z() _handle_interaction() + _report_nearby_discoveries(delta) func _handle_interaction() -> void: - if _is_text_input_focused(): + # 统一交互由 /root/InteractionManager 收集附近候选并处理 E 键。 + return + +func _report_nearby_discoveries(delta: float) -> void: + _discoveryElapsed += delta + if _discoveryElapsed < 0.35: return - if Input.is_action_just_pressed("interact"): - EventSystem.emit_event(EventNames.INTERACT_PRESSED, { - "player": self, - "position": global_position, - "direction": lastDirection - }) - if ray_cast.is_colliding(): - var collider := ray_cast.get_collider() - if collider and collider.has_method("interact"): - collider.interact() + _discoveryElapsed = 0.0 + var scene := get_tree().current_scene + if scene == null: + return + var map_id: String = str({ + "Square": "whale_port", + "WorkZone": "work_zone", + "CafeInterior": "whale_cafe", + "PersonalSpace": "personal_space", + }.get(str(scene.name), "")) + if map_id.is_empty(): + return + var social_manager := get_node_or_null("/root/SocialManager") + if social_manager != null and social_manager.has_method("discover_nearby_destinations"): + social_manager.call("discover_nearby_destinations", map_id, global_position) func _handle_movement(_delta: float) -> void: if _movementLocked: @@ -111,10 +126,7 @@ func _handle_movement(_delta: float) -> void: return # 获取移动向量 (参考 docs/02-开发规范/输入映射配置.md) - var direction := Input.get_vector( - "move_left", "move_right", - "move_up", "move_down" - ) + var direction := _movement_direction() # 应用移动 if direction != Vector2.ZERO: @@ -133,6 +145,16 @@ func _handle_movement(_delta: float) -> void: "position": global_position }) +func _movement_direction() -> Vector2: + var interactionManager := get_node_or_null("/root/InteractionManager") + if interactionManager != null and interactionManager.has_method("is_selection_active") and bool(interactionManager.call("is_selection_active")): + # 有交互候选时方向键用于选择,保留 WASD 移动。 + return Vector2( + (-1.0 if Input.is_key_pressed(KEY_A) else 0.0) + (1.0 if Input.is_key_pressed(KEY_D) else 0.0), + (-1.0 if Input.is_key_pressed(KEY_W) else 0.0) + (1.0 if Input.is_key_pressed(KEY_S) else 0.0) + ).normalized() + return Input.get_vector("move_left", "move_right", "move_up", "move_down") + func _update_animation_state(direction: Vector2) -> void: if not animation_player: return diff --git a/scenes/characters/RemotePlayer.gd b/scenes/characters/RemotePlayer.gd index 3395d27..044f563 100644 --- a/scenes/characters/RemotePlayer.gd +++ b/scenes/characters/RemotePlayer.gd @@ -26,6 +26,7 @@ const CAFE_NAME_LABEL_VISUAL_MAX_WIDTH: int = 118 const CAFE_NAME_LABEL_VISUAL_CHAR_WIDTH: int = 12 const CAFE_NAME_LABEL_OFFSET_Y: float = -96.0 const WORLD_TEXT_THEME = preload("res://assets/ui/world_text_theme.tres") +const InteractionAnchorUtil = preload("res://_Core/utils/InteractionAnchor.gd") const DIRECTION_ROWS: Dictionary = { "down": 0, "up": 1, @@ -38,6 +39,7 @@ var _nameLabel: Label var _cafeCompanionTarget: CafeCompanionTarget func _ready() -> void: + add_to_group("whaletown_interactable") # 初始化时确保无物理处理 set_physics_process(false) # 初始位置设为当前位置 @@ -52,6 +54,53 @@ func _ready() -> void: if has_node("CollisionShape2D"): $CollisionShape2D.disabled = true +func get_interaction_actions(player: Node2D) -> Array[Dictionary]: + if userId.strip_edges().is_empty() or player == null: + return [] + var anchor_position: Vector2 = InteractionAnchorUtil.get_position(self) + var distance: float = anchor_position.distance_to(player.global_position) + if distance > 160.0: + return [] + var display_name := username if not username.strip_edges().is_empty() else "玩家" + return [ + { + "id": "player_card:%s" % userId, + "title": "查看 %s 的社区名片" % display_name, + "distance": distance, + "priority": 60, + "callback": Callable(self, "_show_community_profile"), + }, + { + "id": "player_dm:%s" % userId, + "title": "私聊 %s" % display_name, + "distance": distance, + "priority": 61, + "callback": Callable(self, "_open_private_chat"), + }, + { + "id": "player_friend:%s" % userId, + "title": "申请添加 %s 为好友" % display_name, + "distance": distance, + "priority": 62, + "callback": Callable(self, "_request_friend"), + }, + ] + +func _show_community_profile() -> void: + var socialManager := get_node_or_null("/root/SocialManager") + if socialManager != null and socialManager.has_method("show_profile"): + socialManager.call("show_profile", userId) + +func _open_private_chat() -> void: + var socialManager := get_node_or_null("/root/SocialManager") + if socialManager != null and socialManager.has_method("open_private_chat"): + socialManager.call("open_private_chat", userId, username) + +func _request_friend() -> void: + var socialManager := get_node_or_null("/root/SocialManager") + if socialManager != null and socialManager.has_method("request_friend"): + socialManager.call("request_friend", userId, username) + func _exit_tree() -> void: var eventSystem := get_node_or_null("/root/EventSystem") if eventSystem != null: diff --git a/scenes/prefabs/items/DatawhaleHonorBoard.gd b/scenes/prefabs/items/DatawhaleHonorBoard.gd index 21e0692..c59359b 100644 --- a/scenes/prefabs/items/DatawhaleHonorBoard.gd +++ b/scenes/prefabs/items/DatawhaleHonorBoard.gd @@ -4,10 +4,25 @@ class_name DatawhaleHonorBoard const RANKING_PANEL_SCENE: PackedScene = preload("res://scenes/ui/datawhale_honor_ranking_panel.tscn") const RANKING_PANEL_NAME: String = "DatawhaleHonorRankingPanel" const INTERACTION_COLLISION_LAYER: int = 2 +const InteractionAnchorUtil = preload("res://_Core/utils/InteractionAnchor.gd") func _ready() -> void: collision_layer = INTERACTION_COLLISION_LAYER collision_mask = 0 + add_to_group("whaletown_interactable") + +func get_interaction_actions(player: Node2D) -> Array[Dictionary]: + var anchor_position: Vector2 = InteractionAnchorUtil.get_position(self) + var distance: float = anchor_position.distance_to(player.global_position) + if distance > 150.0: + return [] + return [{ + "id": "honor_board:%s" % get_instance_id(), + "title": "查看荣誉榜", + "distance": distance, + "priority": 35, + "callback": Callable(self, "interact"), + }] func interact() -> void: var root: Window = get_tree().root diff --git a/scenes/prefabs/items/NoticeBoard.gd b/scenes/prefabs/items/NoticeBoard.gd index 411c223..ad2c9d3 100644 --- a/scenes/prefabs/items/NoticeBoard.gd +++ b/scenes/prefabs/items/NoticeBoard.gd @@ -4,10 +4,25 @@ class_name NoticeBoard const NOTICE_DIALOG_SCENE: PackedScene = preload("res://scenes/ui/notice_dialog.tscn") const NOTICE_DIALOG_NAME: String = "NoticeDialog" const INTERACTION_COLLISION_LAYER: int = 2 +const InteractionAnchorUtil = preload("res://_Core/utils/InteractionAnchor.gd") func _ready() -> void: collision_layer = INTERACTION_COLLISION_LAYER collision_mask = 0 + add_to_group("whaletown_interactable") + +func get_interaction_actions(player: Node2D) -> Array[Dictionary]: + var anchor_position: Vector2 = InteractionAnchorUtil.get_position(self) + var distance: float = anchor_position.distance_to(player.global_position) + if distance > 150.0: + return [] + return [{ + "id": "notice_board:%s" % get_instance_id(), + "title": "查看公告栏", + "distance": distance, + "priority": 35, + "callback": Callable(self, "interact"), + }] func interact() -> void: var root: Window = get_tree().root diff --git a/scenes/prefabs/items/WelcomeBoard.gd b/scenes/prefabs/items/WelcomeBoard.gd index 5ddddad..cd08b59 100644 --- a/scenes/prefabs/items/WelcomeBoard.gd +++ b/scenes/prefabs/items/WelcomeBoard.gd @@ -4,10 +4,25 @@ class_name WelcomeBoard const WELCOME_DIALOG_SCENE: PackedScene = preload("res://scenes/ui/welcome_dialog.tscn") const WELCOME_DIALOG_NAME: String = "WelcomeDialog" const INTERACTION_COLLISION_LAYER: int = 2 +const InteractionAnchorUtil = preload("res://_Core/utils/InteractionAnchor.gd") func _ready() -> void: collision_layer = INTERACTION_COLLISION_LAYER collision_mask = 0 + add_to_group("whaletown_interactable") + +func get_interaction_actions(player: Node2D) -> Array[Dictionary]: + var anchor_position: Vector2 = InteractionAnchorUtil.get_position(self) + var distance: float = anchor_position.distance_to(player.global_position) + if distance > 150.0: + return [] + return [{ + "id": "welcome_board:%s" % get_instance_id(), + "title": "查看新人引导", + "distance": distance, + "priority": 35, + "callback": Callable(self, "interact"), + }] func interact() -> void: var root: Window = get_tree().root diff --git a/scenes/ui/AuthScene.gd b/scenes/ui/AuthScene.gd index b130238..c6590ef 100644 --- a/scenes/ui/AuthScene.gd +++ b/scenes/ui/AuthScene.gd @@ -680,6 +680,7 @@ func _registration_texture_rect(nodeName: String, texturePath: String, rect: Rec return textureRect func _ready() -> void: + add_to_group("whaletown_escape_dismissible") _auth_manager = get_node_or_null("/root/AuthManager") _chat_manager = get_node_or_null("/root/ChatManager") _scene_manager = get_node_or_null("/root/SceneManager") @@ -1625,6 +1626,15 @@ func _hide_skin_workshop() -> void: if is_instance_valid(skin_workshop_overlay): skin_workshop_overlay.hide() +func is_escape_dismissible() -> bool: + return is_instance_valid(skin_workshop_overlay) and skin_workshop_overlay.visible + +func get_escape_priority() -> int: + return 900 + +func request_escape_close() -> void: + _hide_skin_workshop() + func _on_workshop_source_pressed() -> void: var err := DisplayServer.file_dialog_show( "选择角色参考图片", diff --git a/scenes/ui/CafeCompanionPanel.gd b/scenes/ui/CafeCompanionPanel.gd index 788483c..39c346a 100644 --- a/scenes/ui/CafeCompanionPanel.gd +++ b/scenes/ui/CafeCompanionPanel.gd @@ -30,6 +30,7 @@ var _resignConfirmButton: Button var _resignCancelButton: Button func _ready() -> void: + add_to_group("whaletown_escape_dismissible") chatFrame.visible = false purchaseButton.pressed.connect(_on_purchase_pressed) sendButton.pressed.connect(_on_send_pressed) @@ -238,6 +239,18 @@ func _on_close_pressed() -> void: _waitingForReply = false chatInput.release_focus() +func is_escape_dismissible() -> bool: + return (is_instance_valid(_resignDialog) and _resignDialog.visible) or chatFrame.visible + +func get_escape_priority() -> int: + return 850 + +func request_escape_close() -> void: + if is_instance_valid(_resignDialog) and _resignDialog.visible: + _hide_resign_dialog() + return + _on_close_pressed() + func _confirm_resign() -> void: var servicePointId := str(_resignTarget.get("service_point_id", "")).strip_edges() if servicePointId.is_empty(): diff --git a/scenes/ui/CafeCompanionRecruitmentPanel.gd b/scenes/ui/CafeCompanionRecruitmentPanel.gd index 2896cb4..126916e 100644 --- a/scenes/ui/CafeCompanionRecruitmentPanel.gd +++ b/scenes/ui/CafeCompanionRecruitmentPanel.gd @@ -32,6 +32,7 @@ var _isSubmitting: bool = false var _isFetchingModels: bool = false func _ready() -> void: + add_to_group("whaletown_escape_dismissible") recruitmentFrame.visible = false _setup_protocol_options() _setup_employment_duration_options() @@ -398,6 +399,15 @@ func _on_close_pressed() -> void: fetchModelsButton.disabled = false tokenInput.clear() +func is_escape_dismissible() -> bool: + return recruitmentFrame.visible + +func get_escape_priority() -> int: + return 900 + +func request_escape_close() -> void: + _on_close_pressed() + 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)) diff --git a/scenes/ui/ChatUI.gd b/scenes/ui/ChatUI.gd index 1f5b47e..99a27b8 100644 --- a/scenes/ui/ChatUI.gd +++ b/scenes/ui/ChatUI.gd @@ -125,6 +125,7 @@ var _send_failure_handled_by_ui: bool = false # 准备就绪 func _ready() -> void: + add_to_group("whaletown_escape_dismissible") _configure_mouse_focus() # 初始隐藏聊天框 @@ -182,6 +183,8 @@ func _get_settings_manager() -> Node: # 处理全局输入 func _input(event: InputEvent) -> void: + if get_viewport().is_input_handled(): + return if event is InputEventMouseButton: _handle_global_mouse_button_input(event as InputEventMouseButton) return @@ -366,6 +369,15 @@ func hide_chat(immediate: bool = false) -> void: _transition_tween.tween_property(chat_panel, "modulate:a", 0.0, CHAT_TRANSITION_DURATION) _transition_tween.finished.connect(_on_hide_transition_finished) +func is_escape_dismissible() -> bool: + return _is_chat_visible + +func get_escape_priority() -> int: + return 480 + +func request_escape_close() -> void: + hide_chat() + # 创建隐藏计时器 func _create_hide_timer() -> void: _hide_timer = Timer.new() diff --git a/scenes/ui/CourseBoardPanel.gd b/scenes/ui/CourseBoardPanel.gd index 1945c09..3aa22d1 100644 --- a/scenes/ui/CourseBoardPanel.gd +++ b/scenes/ui/CourseBoardPanel.gd @@ -29,6 +29,7 @@ var _isOpen: bool = false var _transitionTween: Tween func _ready() -> void: + add_to_group("whaletown_escape_dismissible") visible = false mouse_filter = Control.MOUSE_FILTER_IGNORE set_process(false) @@ -39,6 +40,8 @@ func _notification(what: int) -> void: _positionPanel() func _input(event: InputEvent) -> void: + if get_viewport().is_input_handled(): + return if not _isOpen: return if event is InputEventKey: @@ -72,6 +75,15 @@ func hide_panel() -> void: func is_panel_open() -> bool: return _isOpen +func is_escape_dismissible() -> bool: + return _isOpen + +func get_escape_priority() -> int: + return 700 + +func request_escape_close() -> void: + hide_panel() + func _buildUi() -> void: _overlay = ColorRect.new() _overlay.name = "courseBoardDimOverlay" diff --git a/scenes/ui/DatawhaleHonorRankingPanel.gd b/scenes/ui/DatawhaleHonorRankingPanel.gd index dfa3bd0..120ba4e 100644 --- a/scenes/ui/DatawhaleHonorRankingPanel.gd +++ b/scenes/ui/DatawhaleHonorRankingPanel.gd @@ -96,6 +96,7 @@ var _isLoading: bool = false var _debugShowRankSlots: bool = false func _ready() -> void: + add_to_group("whaletown_escape_dismissible") get_tree().paused = true _disableChatUiMouseInput() _buildUi() @@ -109,12 +110,23 @@ func _exit_tree() -> void: _restoreChatUiMouseInput() func _input(event: InputEvent) -> void: + if get_viewport().is_input_handled(): + return if event is InputEventKey: var keyEvent := event as InputEventKey if keyEvent.pressed and not keyEvent.echo and keyEvent.keycode == KEY_ESCAPE: _onClosePressed() get_viewport().set_input_as_handled() +func is_escape_dismissible() -> bool: + return is_inside_tree() + +func get_escape_priority() -> int: + return 1000 + +func request_escape_close() -> void: + _onClosePressed() + func _buildUi() -> void: _rootControl = Control.new() _rootControl.name = "RankingPanelRoot" diff --git a/scenes/ui/FriendListPanel.gd b/scenes/ui/FriendListPanel.gd index 3095b56..68ff732 100644 --- a/scenes/ui/FriendListPanel.gd +++ b/scenes/ui/FriendListPanel.gd @@ -38,6 +38,7 @@ var _isOpen: bool = false var _hasRequestedFriendList: bool = false func _ready() -> void: + add_to_group("whaletown_escape_dismissible") mouse_filter = Control.MOUSE_FILTER_IGNORE _build_ui() set_panel_open(false) @@ -60,6 +61,15 @@ func set_panel_open(open: bool) -> void: func is_panel_open() -> bool: return _isOpen +func is_escape_dismissible() -> bool: + return _isOpen + +func get_escape_priority() -> int: + return 500 + +func request_escape_close() -> void: + set_panel_open(false) + func toggle_panel() -> void: set_panel_open(not _isOpen) diff --git a/scenes/ui/MapPanel.gd b/scenes/ui/MapPanel.gd index 5163ae5..24e8054 100644 --- a/scenes/ui/MapPanel.gd +++ b/scenes/ui/MapPanel.gd @@ -37,13 +37,13 @@ const MAP_CONFIGS: Dictionary = { "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"}, + {"label": "码头", "destinationId": "square_dock", "icon": "anchor", "pos": Vector2(0.160, 0.395), "side": "right"}, + {"label": "总部", "destinationId": "square_headquarters", "icon": "home", "pos": Vector2(0.505, 0.188), "side": "right"}, + {"label": "广场", "destinationId": "square_center", "icon": "whale", "pos": Vector2(0.505, 0.515), "side": "right"}, + {"label": "小屋", "destinationId": "square_cottage", "icon": "home", "pos": Vector2(0.830, 0.400), "side": "right"}, + {"label": "工坊", "destinationId": "square_workshop", "icon": "tool", "pos": Vector2(0.752, 0.760), "side": "left"}, + {"label": "公告", "destinationId": "square_notice", "icon": "notice", "pos": Vector2(0.288, 0.838), "side": "right"}, + {"label": "入口", "destinationId": "square_work_zone_gate", "icon": "gate", "pos": Vector2(0.500, 0.900), "side": "right"}, ], }, "WorkZone": { @@ -57,13 +57,13 @@ const MAP_CONFIGS: Dictionary = { "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"}, + {"label": "商城", "destinationId": "work_mall", "icon": "home", "pos": Vector2(0.500, 0.180), "side": "right"}, + {"label": "咖啡店", "destinationId": "work_cafe_gate", "icon": "home", "pos": Vector2(0.092, 0.620), "side": "right"}, + {"label": "任务", "destinationId": "work_jobs", "icon": "notice", "pos": Vector2(0.304, 0.600), "side": "right"}, + {"label": "课程", "destinationId": "work_courses", "icon": "notice", "pos": Vector2(0.694, 0.500), "side": "left"}, + {"label": "AI站", "destinationId": "work_ai", "icon": "tool", "pos": Vector2(0.676, 0.785), "side": "left"}, + {"label": "鲸币", "destinationId": "work_exchange", "icon": "whale", "pos": Vector2(0.920, 0.785), "side": "left"}, + {"label": "入口", "destinationId": "work_entrance", "icon": "gate", "pos": Vector2(0.500, 0.900), "side": "right"}, ], }, "CafeInterior": { @@ -74,11 +74,19 @@ const MAP_CONFIGS: Dictionary = { "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"}, + {"label": "服务台", "destinationId": "cafe_counter", "icon": "whale", "pos": Vector2(0.500, 0.465), "side": "right"}, + {"label": "陪伴区", "destinationId": "cafe_companion", "icon": "notice", "pos": Vector2(0.240, 0.280), "side": "right"}, + {"label": "出口", "destinationId": "cafe_entrance", "icon": "gate", "pos": Vector2(0.500, 0.855), "side": "right"}, ], }, + "PersonalSpace": { + "title": "我的房间", + "worldSize": Vector2(1024, 768), + "sourceNodes": [ + "RoomBase", + ], + "markers": [], + }, } var _panel: PanelContainer @@ -90,8 +98,13 @@ var _mapCamera: Camera2D var _titleLabel: Label var _markerNodes: Array[Control] = [] var _isOpen: bool = false +var _travelDestinations: Dictionary = {} +var _travelLocked: bool = false +var _destinationMenu: PopupMenu +var _destinationMenuIds: Dictionary = {} func _ready() -> void: + add_to_group("whaletown_escape_dismissible") mouse_filter = Control.MOUSE_FILTER_IGNORE _build_ui() set_panel_open(false) @@ -107,6 +120,8 @@ func _notification(what: int) -> void: _anchor_panel() func _input(event: InputEvent) -> void: + if get_viewport().is_input_handled(): + return if not _isOpen: return if event is InputEventKey: @@ -123,10 +138,20 @@ func set_panel_open(open: bool) -> void: _panel.visible = _isOpen if _isOpen: _rebuild_minimap_world() + _load_travel_destinations() func is_panel_open() -> bool: return _isOpen +func is_escape_dismissible() -> bool: + return _isOpen + +func get_escape_priority() -> int: + return 600 + +func request_escape_close() -> void: + set_panel_open(false) + func toggle_panel() -> void: set_panel_open(not _isOpen) @@ -152,6 +177,9 @@ func _build_ui() -> void: content.add_child(_build_header()) content.add_child(_build_map_view()) + _destinationMenu = PopupMenu.new() + _destinationMenu.id_pressed.connect(_on_destination_menu_selected) + add_child(_destinationMenu) func _build_header() -> Control: var header := HBoxContainer.new() @@ -173,6 +201,20 @@ func _build_header() -> Control: title.add_theme_font_size_override("font_size", 24) header.add_child(title) + var destinationButton := Button.new() + destinationButton.text = "目的地" + destinationButton.tooltip_text = "查看全部快速传送目的地" + destinationButton.custom_minimum_size = Vector2(76, 38) + destinationButton.focus_mode = Control.FOCUS_NONE + destinationButton.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND + destinationButton.add_theme_font_size_override("font_size", 14) + destinationButton.add_theme_color_override("font_color", ACCENT_COLOR) + destinationButton.add_theme_stylebox_override("normal", _create_round_style(Color(0.902, 0.962, 0.995, 1.0), 14)) + destinationButton.add_theme_stylebox_override("hover", _create_round_style(Color(0.818, 0.925, 0.980, 1.0), 14)) + destinationButton.add_theme_stylebox_override("focus", StyleBoxEmpty.new()) + destinationButton.pressed.connect(func() -> void: _show_destination_menu(destinationButton)) + header.add_child(destinationButton) + var closeButton := Button.new() closeButton.text = "×" closeButton.tooltip_text = "关闭地图" @@ -263,6 +305,16 @@ func _create_marker(marker: Dictionary) -> Control: 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 destinationId := str(marker.get("destinationId", "")).strip_edges() + var destination: Dictionary = _travelDestinations.get(destinationId, {}) + var unlocked := destinationId.is_empty() or _travelDestinations.is_empty() or bool(destination.get("unlocked", false)) + button.disabled = not unlocked or _travelLocked + button.tooltip_text = "点击快速传送" if unlocked else "首次到访后解锁" + if not unlocked: + button.modulate = Color(0.66, 0.69, 0.72, 0.72) + button.pressed.connect(func() -> void: return) + else: + button.pressed.connect(func() -> void: _request_travel(destinationId)) var row := HBoxContainer.new() row.mouse_filter = Control.MOUSE_FILTER_IGNORE @@ -281,7 +333,7 @@ func _create_marker(marker: Dictionary) -> Control: var label := Label.new() label.mouse_filter = Control.MOUSE_FILTER_IGNORE - label.text = str(marker.get("label", "地点")) + label.text = ("🔒 " if not unlocked else "") + 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) @@ -295,6 +347,117 @@ func _create_marker(marker: Dictionary) -> Control: ) return button +func _load_travel_destinations() -> void: + var api := get_node_or_null("/root/ApiClient") + if api == null: + return + api.call("get_json", "/world/travel-destinations", func(success: bool, response: Dictionary, _error: Dictionary) -> void: + if not success: + return + var data_variant: Variant = response.get("data", []) + if not (data_variant is Array): + return + _travelDestinations.clear() + for entry in data_variant as Array: + if entry is Dictionary: + var destination: Dictionary = entry + _travelDestinations[str(destination.get("id", ""))] = destination + _render_markers() + , true) + +func _show_destination_menu(origin: Control) -> void: + if not is_instance_valid(_destinationMenu): + return + _destinationMenu.clear() + _destinationMenuIds.clear() + if _travelDestinations.is_empty(): + _destinationMenu.add_item("正在读取目的地…", 0) + _destinationMenu.set_item_disabled(0, true) + else: + var destinations: Array[Dictionary] = [] + for destination_variant in _travelDestinations.values(): + if destination_variant is Dictionary: + destinations.append(destination_variant as Dictionary) + destinations.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + var map_order := {"whale_port": 0, "work_zone": 1, "whale_cafe": 2, "personal_space": 3} + var order_a := int(map_order.get(str(a.get("mapId", "")), 9)) + var order_b := int(map_order.get(str(b.get("mapId", "")), 9)) + if order_a != order_b: + return order_a < order_b + return str(a.get("label", "")).naturalnocasecmp_to(str(b.get("label", ""))) < 0 + ) + var current_map := "" + var menu_id := 1 + for destination in destinations: + var map_id := str(destination.get("mapId", "")) + if map_id != current_map: + if not current_map.is_empty(): + _destinationMenu.add_separator() + _destinationMenu.add_item("— %s —" % _map_label(map_id), menu_id) + _destinationMenu.set_item_disabled(_destinationMenu.item_count - 1, true) + menu_id += 1 + current_map = map_id + var unlocked := bool(destination.get("unlocked", false)) + _destinationMenu.add_item(("" if unlocked else "🔒 ") + str(destination.get("label", "地点")), menu_id) + _destinationMenu.set_item_disabled(_destinationMenu.item_count - 1, not unlocked or _travelLocked) + _destinationMenuIds[menu_id] = str(destination.get("id", "")) + menu_id += 1 + _destinationMenu.reset_size() + _destinationMenu.position = Vector2i(origin.get_screen_position() + Vector2(0.0, origin.size.y)) + _destinationMenu.popup() + +func _on_destination_menu_selected(menu_id: int) -> void: + var destination_id := str(_destinationMenuIds.get(menu_id, "")) + if not destination_id.is_empty(): + _request_travel(destination_id) + +func _request_travel(destinationId: String) -> void: + if destinationId.is_empty() or _travelLocked: + return + var socialManager := get_node_or_null("/root/SocialManager") + if socialManager == null or not socialManager.has_method("request_travel"): + return + _travelLocked = true + _render_markers() + socialManager.call("request_travel", destinationId, Callable(self, "_on_travel_authorized")) + +func _on_travel_authorized(success: bool, destination: Dictionary) -> void: + if not success: + await get_tree().create_timer(1.0).timeout + _travelLocked = false + _render_markers() + return + var mapId := str(destination.get("mapId", "")) + var position := _scene_position_for_destination(mapId, Vector2(float(destination.get("x", 0.0)), float(destination.get("y", 0.0)))) + var sceneName: String = str({"whale_port": "square", "work_zone": "work_zone", "whale_cafe": "cafe_interior", "personal_space": "personal_space"}.get(mapId, "")) + if sceneName.is_empty(): + await get_tree().create_timer(1.0).timeout + _travelLocked = false + _render_markers() + return + set_panel_open(false) + SceneManager.set_next_destination_id(str(destination.get("id", ""))) + SceneManager.set_next_scene_position(position) + SceneManager.change_scene(sceneName) + +func _scene_position_for_destination(map_id: String, map_position: Vector2) -> Vector2: + # 服务端旅行目录使用小地图坐标;游戏场景则以地图中心为原点。 + var origin := { + "whale_port": Vector2(1280, 960), + "work_zone": Vector2(1280, 960), + "whale_cafe": Vector2(768, 512), + "personal_space": Vector2(768, 512), + }.get(map_id, Vector2.ZERO) as Vector2 + return map_position - origin + +func _map_label(map_id: String) -> String: + return { + "whale_port": "中心广场", + "work_zone": "打工区", + "whale_cafe": "鲸鱼咖啡馆", + "personal_space": "我的房间", + }.get(map_id, map_id) + func _anchor_panel() -> void: _panel.set_anchors_preset(Control.PRESET_TOP_RIGHT) _panel.offset_left = -PANEL_SIZE.x - PANEL_MARGIN_RIGHT diff --git a/scenes/ui/NoticeDialog.gd b/scenes/ui/NoticeDialog.gd index 7fccdab..b539061 100644 --- a/scenes/ui/NoticeDialog.gd +++ b/scenes/ui/NoticeDialog.gd @@ -24,6 +24,7 @@ var _chatUiPrevMouseFilter: Control.MouseFilter = Control.MOUSE_FILTER_STOP var _chatUiMouseDisabled: bool = false func _ready() -> void: + add_to_group("whaletown_escape_dismissible") get_tree().paused = true _disableChatUiMouseInput() @@ -39,6 +40,15 @@ func _ready() -> void: func _exit_tree() -> void: _restoreChatUiMouseInput() +func is_escape_dismissible() -> bool: + return is_inside_tree() + +func get_escape_priority() -> int: + return 1000 + +func request_escape_close() -> void: + _onClosePressed() + func _setupDots() -> void: for child in dotsContainer.get_children(): child.queue_free() diff --git a/scenes/ui/PlayerHud.gd b/scenes/ui/PlayerHud.gd index 28a5df8..9f213f5 100644 --- a/scenes/ui/PlayerHud.gd +++ b/scenes/ui/PlayerHud.gd @@ -8,7 +8,7 @@ extends Control # ============================================================================ const HUD_MARGIN: Vector2 = Vector2(16, 16) -const SHORTCUT_BAR_SIZE: Vector2 = Vector2(350, 86) +const SHORTCUT_BAR_SIZE: Vector2 = Vector2(406, 86) const PLAYER_PROFILE_BUTTON_SIZE: Vector2 = Vector2(210, 78) const PLAYER_AVATAR_SIZE: Vector2 = Vector2(58, 58) const HUD_SEPARATION: int = 18 @@ -95,6 +95,7 @@ func _build_shortcut_bar() -> PanelContainer: 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("activity", "通知", _on_notifications_pressed)) row.add_child(_create_shortcut_button("settings", "设置", _on_settings_pressed)) return panel @@ -379,6 +380,11 @@ func _on_map_pressed() -> void: if eventSystem != null: eventSystem.call("emit_event", EventNames.HUD_MAP_TOGGLE, {}) +func _on_notifications_pressed() -> void: + var socialManager := get_node_or_null("/root/SocialManager") + if socialManager != null and socialManager.has_method("toggle_notifications"): + socialManager.call("toggle_notifications") + func _on_task_pressed() -> void: _emit_status_message("任务入口稍后接入") diff --git a/scenes/ui/SettingsPanel.gd b/scenes/ui/SettingsPanel.gd index 9078531..3442461 100644 --- a/scenes/ui/SettingsPanel.gd +++ b/scenes/ui/SettingsPanel.gd @@ -38,6 +38,7 @@ const DEFAULT_SETTINGS: Dictionary = { "ui_scale": 1.00, "fullscreen": false, "show_interaction_hints": true, + "show_interaction_points": false, "show_name_always": false, "show_chat_bubbles": true, "world_notifications": true, @@ -45,6 +46,7 @@ const DEFAULT_SETTINGS: Dictionary = { "friend_request_notifications": true, "allow_nearby_private": true, "allow_nearby_friend_requests": true, + "allow_nearby_profile": true, "mute_ui_sfx": false, } @@ -73,6 +75,7 @@ var _transitionTween: Tween var _avatarUploadPending: bool = false func _ready() -> void: + add_to_group("whaletown_escape_dismissible") visible = false mouse_filter = Control.MOUSE_FILTER_IGNORE set_process(false) @@ -95,6 +98,8 @@ func _notification(what: int) -> void: _position_panel() func _input(event: InputEvent) -> void: + if get_viewport().is_input_handled(): + return if not _isOpen: return if event is InputEventKey: @@ -136,6 +141,15 @@ func hide_panel(saveBeforeClose: bool = false) -> void: func is_panel_open() -> bool: return _isOpen +func is_escape_dismissible() -> bool: + return _isOpen + +func get_escape_priority() -> int: + return 700 + +func request_escape_close() -> void: + hide_panel(true) + func _build_ui() -> void: _overlay = ColorRect.new() _overlay.name = "SettingsDimOverlay" @@ -344,6 +358,7 @@ func _render_basic_content() -> void: _content.add_child(_create_section("基础设置", [ _create_toggle_row("window", "全屏显示", "fullscreen", "适合专注游玩,窗口模式方便调试"), _create_toggle_row("eye", "显示互动提示", "show_interaction_hints", "靠近 NPC、好友或公告板时显示按键提示"), + _create_toggle_row("eye", "显示交互点", "show_interaction_points", "用白色圆圈直接标出当前地图内的交互目标"), _create_toggle_row("account", "始终显示名字", "show_name_always", "关闭时只在需要时显示玩家名称"), ])) _content.add_child(_create_section("音频设置", [ @@ -365,25 +380,27 @@ func _render_chat_content() -> void: _content.add_child(_create_section("聊天与社交", [ _create_toggle_row("chat", "世界频道提醒", "world_notifications", "收到世界频道消息时保留轻提示"), _create_toggle_row("chat", "私聊提醒", "private_notifications", "好友或附近玩家私聊时提示"), - _create_toggle_row("account", "好友请求提醒", "friend_request_notifications", "对方按 F 发起好友申请时显示在好友列表"), + _create_toggle_row("account", "好友请求提醒", "friend_request_notifications", "附近玩家发起好友申请时显示在好友列表"), _create_toggle_row("chat", "显示聊天气泡", "show_chat_bubbles", "气泡发送会同时进入世界频道"), ])) _content.add_child(_create_section("附近玩家权限", [ + _create_toggle_row("eye", "允许查看我的名片", "allow_nearby_profile", "关闭后陌生玩家无法在附近打开你的社区名片"), _create_toggle_row("chat", "允许附近私聊", "allow_nearby_private", "附近玩家按 E 可以发起悄悄话"), - _create_toggle_row("account", "允许好友申请", "allow_nearby_friend_requests", "附近玩家按 F 可以发送好友申请"), + _create_toggle_row("account", "允许好友申请", "allow_nearby_friend_requests", "附近玩家可通过互动列表发送好友申请"), ])) func _render_controls_content() -> void: _content.add_child(_create_section("操作说明", [ _create_key_row("W A S D", "移动角色"), - _create_key_row("方向键", "备用移动"), - _create_key_row("E", "互动 / 附近私聊"), - _create_key_row("F", "发送好友申请"), + _create_key_row("方向键", "附近互动列表选择"), + _create_key_row("E", "执行选中的互动"), + _create_key_row("Esc", "关闭当前界面或结束当前互动"), _create_key_row("T", "打开聊天输入"), _create_key_row("Enter", "发送聊天消息"), ])) _content.add_child(_create_section("操作辅助", [ _create_toggle_row("eye", "显示互动提示", "show_interaction_hints", "靠近可交互目标时显示按键提示"), + _create_toggle_row("eye", "显示交互点", "show_interaction_points", "用白色圆圈直接标出当前地图内的交互目标"), _create_hint_row("当前版本使用固定键位;这里的开关会控制地图内的互动提示显示。"), ])) diff --git a/scenes/ui/WelcomeDialog.gd b/scenes/ui/WelcomeDialog.gd index d488a35..7b42a84 100644 --- a/scenes/ui/WelcomeDialog.gd +++ b/scenes/ui/WelcomeDialog.gd @@ -11,7 +11,7 @@ const GUIDE_PAGES: Array[Dictionary] = [ "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- 输入框获得焦点时,角色移动会暂停响应。", + "text": "操作提示:\n\n- 按 [color=#ffaa00]E[/color] 键可以与 NPC、公告板和信息板互动。\n- 靠近目标后面向它,再按互动键。\n- 按 [color=#ffaa00]Esc[/color] 可以关闭当前界面或结束当前互动。\n- 输入框获得焦点时,角色移动会暂停响应。", "image_path": "res://assets/maps/square/v1/props/bottom_entrance_right_service_props_v2_hd_clean.png", }, ] @@ -38,6 +38,7 @@ var _guideCurrentPage: int = 0 var _guideTween: Tween func _ready() -> void: + add_to_group("whaletown_escape_dismissible") _disableChatUiMouseInput() var closeButton := find_child("CloseButton", true, false) as Button @@ -52,8 +53,20 @@ func _exit_tree() -> void: _restoreChatUiMouseInput() func _input(event: InputEvent) -> void: + if get_viewport().is_input_handled(): + return if event.is_action_pressed("ui_cancel"): queue_free() + get_viewport().set_input_as_handled() + +func is_escape_dismissible() -> bool: + return is_inside_tree() + +func get_escape_priority() -> int: + return 980 + +func request_escape_close() -> void: + queue_free() func _onClosePressed() -> void: queue_free() diff --git a/scenes/ui/mall/MallPanel.gd b/scenes/ui/mall/MallPanel.gd index 27860d9..af57141 100644 --- a/scenes/ui/mall/MallPanel.gd +++ b/scenes/ui/mall/MallPanel.gd @@ -62,6 +62,7 @@ var _transitionTween: Tween var _toastTween: Tween func _ready() -> void: + add_to_group("whaletown_escape_dismissible") visible = false mouse_filter = Control.MOUSE_FILTER_IGNORE set_process(false) @@ -82,6 +83,8 @@ func _notification(what: int) -> void: _position_panel() func _input(event: InputEvent) -> void: + if get_viewport().is_input_handled(): + return if not _isOpen: return if event is InputEventKey: @@ -120,6 +123,18 @@ func hide_panel() -> void: func is_panel_open() -> bool: return _isOpen +func is_escape_dismissible() -> bool: + return _isOpen + +func get_escape_priority() -> int: + return 700 + +func request_escape_close() -> void: + if is_instance_valid(_confirmDialog) and _confirmDialog.visible: + _hide_confirm_dialog() + return + hide_panel() + func _build_ui() -> void: _overlay = ColorRect.new() _overlay.name = "mallDimOverlay"