diff --git a/_Core/managers/ChatManager.gd b/_Core/managers/ChatManager.gd index e8fb6ad..8fa9deb 100644 --- a/_Core/managers/ChatManager.gd +++ b/_Core/managers/ChatManager.gd @@ -1153,7 +1153,8 @@ func _handle_friend_list(data: Dictionary) -> void: _friends.append({ "user_id": str(friend.get("userId", friend.get("user_id", ""))).strip_edges(), "username": str(friend.get("username", "")), - "online": bool(friend.get("online", false)) + "online": bool(friend.get("online", false)), + "room_visitable": bool(friend.get("room_visitable", false)) }) _emit_event(EventNames.CHAT_FRIENDS_UPDATED, { @@ -1172,7 +1173,8 @@ func _handle_friend_added(data: Dictionary) -> void: _upsert_friend({ "user_id": friend_user_id, "username": str(friend.get("username", "")), - "online": bool(friend.get("online", false)) + "online": bool(friend.get("online", false)), + "room_visitable": bool(friend.get("room_visitable", false)) }) request_friend_list() diff --git a/_Core/managers/RoomDecorSaveManager.gd b/_Core/managers/RoomDecorSaveManager.gd index b177892..771d6df 100644 --- a/_Core/managers/RoomDecorSaveManager.gd +++ b/_Core/managers/RoomDecorSaveManager.gd @@ -69,6 +69,7 @@ func _process_next_save() -> void: "position_x": float(item.get("position_x", 0.0)), "position_y": float(item.get("position_y", 0.0)), "scale": float(item.get("scale", item.get("default_scale", 1.0))), + "rotation_degrees": int(item.get("rotation_degrees", item.get("default_rotation_degrees", 0))), "z_index": int(item.get("z_index", item.get("default_z_index", 0))), } var decorId := str(item.get("decor_id", "")).uri_encode() diff --git a/_Core/managers/SceneManager.gd b/_Core/managers/SceneManager.gd index e69824a..0666bef 100644 --- a/_Core/managers/SceneManager.gd +++ b/_Core/managers/SceneManager.gd @@ -39,6 +39,7 @@ 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 = "" # 快速传送目标,用于发现登记 +var _room_visit_context: Dictionary = {} # 访客房间的房主与返回位置 # 场景路径映射表 # 将场景名称映射到实际的文件路径 @@ -203,6 +204,39 @@ func get_next_destination_id() -> String: _next_destination_id = "" return destination_id +# ============ 访客房间上下文 ============ + +func begin_room_visit(owner_user_id: String, owner_nickname: String, return_scene: String, return_position: Vector2) -> void: + _room_visit_context = { + "owner_user_id": owner_user_id.strip_edges(), + "owner_nickname": owner_nickname.strip_edges(), + "return_scene": return_scene.strip_edges(), + "return_position": return_position, + } + +func get_room_visit_context() -> Dictionary: + return _room_visit_context.duplicate(true) + +func has_room_visit_context() -> bool: + return not str(_room_visit_context.get("owner_user_id", "")).strip_edges().is_empty() + +func clear_room_visit_context() -> void: + _room_visit_context.clear() + +func return_from_room_visit() -> bool: + if not has_room_visit_context(): + return false + var context: Dictionary = _room_visit_context.duplicate(true) + clear_room_visit_context() + var return_scene: String = str(context.get("return_scene", "square")).strip_edges() + if not scene_paths.has(return_scene): + return_scene = "square" + var return_position: Variant = context.get("return_position", null) + if return_position is Vector2: + set_next_scene_position(return_position as Vector2) + change_scene(return_scene) + return true + # ============ 场景注册方法 ============ # 注册新场景 diff --git a/_Core/managers/SettingsManager.gd b/_Core/managers/SettingsManager.gd index 1cdb19d..8f4f4c8 100644 --- a/_Core/managers/SettingsManager.gd +++ b/_Core/managers/SettingsManager.gd @@ -25,6 +25,7 @@ const DEFAULT_SETTINGS: Dictionary = { "allow_nearby_private": true, "allow_nearby_friend_requests": true, "allow_nearby_profile": true, + "room_visit_policy": "friends", "mute_ui_sfx": false, } diff --git a/_Core/managers/SocialManager.gd b/_Core/managers/SocialManager.gd index 39d0150..3436e71 100644 --- a/_Core/managers/SocialManager.gd +++ b/_Core/managers/SocialManager.gd @@ -82,6 +82,38 @@ func open_private_chat(user_id: String, username: String) -> void: event_system.call("emit_event", EventNames.CHAT_PRIVATE_TARGET_SELECTED, {"userId": user_id, "username": username}) _close_profile() +func visit_room(user_id: String, nickname: String = "") -> void: + var owner_id: String = user_id.strip_edges() + if owner_id.is_empty(): + return + _request_get("/social/profiles/%s" % owner_id.uri_encode(), func(success: bool, response: Dictionary, error: Dictionary) -> void: + if not success: + _show_status("无法访问房间:%s" % str(error.get("message", "请求失败"))) + return + var profile_variant: Variant = response.get("data", {}) + if not (profile_variant is Dictionary) or not bool((profile_variant as Dictionary).get("room_visitable", false)): + _show_status("该玩家当前不开放个人空间访问") + return + var profile: Dictionary = profile_variant as Dictionary + _begin_room_visit(owner_id, str(profile.get("nickname", nickname))) + ) + +func _begin_room_visit(owner_id: String, nickname: String) -> void: + var current_scene: Node = get_tree().current_scene + var return_scene: String = SceneManager.get_current_scene_name().strip_edges() + if return_scene.is_empty() and current_scene != null: + return_scene = _scene_name_to_id(current_scene.name) + if return_scene.is_empty() or return_scene == "personal_space": + return_scene = "square" + var return_position := Vector2.ZERO + if current_scene != null: + var player_node := current_scene.get_node_or_null("YSortWorld/Characters/Players/Player") as Node2D + if player_node != null: + return_position = player_node.global_position + SceneManager.begin_room_visit(owner_id, nickname, return_scene, return_position) + _close_profile() + SceneManager.change_scene("personal_space") + 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: @@ -218,6 +250,8 @@ func _render_profile() -> void: 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))) + if bool(_current_profile.get("room_visitable", false)): + actions.add_child(_button("访问房间", func() -> void: visit_room(user_id, nickname))) _profile_content.add_child(actions) var safety := HBoxContainer.new() safety.add_theme_constant_override("separation", 8) @@ -427,6 +461,14 @@ func _current_user_id() -> String: func _area_label(map_id: String) -> String: return {"whale_port": "中心广场", "work_zone": "打工区", "whale_cafe": "鲸鱼咖啡馆", "personal_space": "个人空间"}.get(map_id, map_id) +func _scene_name_to_id(scene_name: String) -> String: + return { + "Square": "square", + "WorkZone": "work_zone", + "CafeInterior": "cafe_interior", + "PersonalSpace": "personal_space", + }.get(scene_name, "") + func _interest_catalog() -> Array[Dictionary]: return [ {"id": "ai", "label": "AI/大模型"}, {"id": "programming", "label": "编程开发"}, diff --git a/scenes/Maps/PersonalSpace.gd b/scenes/Maps/PersonalSpace.gd index 03699fb..a616616 100644 --- a/scenes/Maps/PersonalSpace.gd +++ b/scenes/Maps/PersonalSpace.gd @@ -22,6 +22,8 @@ const MUTED_COLOR: Color = Color(0.400, 0.500, 0.620) const DECOR_TEXTURE_FILTER: int = CanvasItem.TEXTURE_FILTER_LINEAR const DECOR_COLLISION_LAYER: int = 1 const DECOR_COLLISION_MASK: int = 1 +const ROOM_GRID_SIZE: float = 16.0 +const ROOM_PLACE_BOUNDS: Rect2 = Rect2(-426.0, -186.0, 852.0, 398.0) @onready var player: PlayerController = $YSortWorld/Characters/Players/Player @onready var playerCamera: Camera2D = $YSortWorld/Characters/Players/Player/Camera2D @@ -35,18 +37,48 @@ var _inventoryPanel: PanelContainer var _inventoryGrid: GridContainer var _dragSurface: Control var _toastLabel: Label +var _roomToolbar: PanelContainer +var _roomTitleLabel: Label +var _layoutStatusLabel: Label +var _escHintLabel: Label +var _editButton: Button +var _backpackButton: Button +var _undoButton: Button +var _redoButton: Button +var _rotateButton: Button +var _collectButton: Button +var _resetButton: Button +var _saveButton: Button +var _finishEditButton: Button +var _unsavedDialog: PanelContainer var _selectedDecorId: String = "" var _draggedDecor: Sprite2D var _dragOffset: Vector2 = Vector2.ZERO +var _dragOriginalItem: Dictionary = {} var _decorItems: Dictionary = {} +var _serverDecorItems: Dictionary = {} var _decorNodes: Dictionary = {} var _decorCollisionBodies: Dictionary = {} var _remoteDecorDefinitions: Dictionary = {} var _inventoryRequestInFlight: bool = false var _inventoryRequestReportsErrors: bool = false +var _inventoryLoaded: bool = false +var _visitorMode: bool = false +var _visitorOwnerId: String = "" +var _visitorOwnerName: String = "" +var _editMode: bool = false +var _undoHistory: Array[Dictionary] = [] +var _redoHistory: Array[Dictionary] = [] +var _dirtyDecorIds: Dictionary = {} +var _saveAwaitingIds: Dictionary = {} +var _saveFailedIds: Dictionary = {} +var _saveInProgress: bool = false +var _exitAfterSave: bool = false +var _resetArmed: bool = false func _ready() -> void: add_to_group("whaletown_escape_dismissible") + _configure_visit_mode() _leave_multiplayer_world() _apply_spawn_point() _configure_camera() @@ -59,6 +91,8 @@ func _ready() -> void: set_process(false) func _discover_destination() -> void: + if _visitorMode: + return var destination_id := SceneManager.get_next_destination_id() if destination_id.is_empty(): destination_id = "personal_room" @@ -81,11 +115,15 @@ func _exit_tree() -> void: saveManager.decor_save_succeeded.disconnect(_on_decor_save_succeeded) if saveManager.decor_save_failed.is_connected(_on_decor_save_failed): saveManager.decor_save_failed.disconnect(_on_decor_save_failed) + if is_instance_valid(_inventoryRequest): + _inventoryRequest.cancel_request() func _input(event: InputEvent) -> void: if get_viewport().is_input_handled(): return if event is InputEventMouseButton: + if not _can_edit_layout(): + return var mouseEvent := event as InputEventMouseButton if mouseEvent.button_index != MOUSE_BUTTON_LEFT: return @@ -104,17 +142,23 @@ func _process(_delta: float) -> void: _update_dragged_decor_position() func is_escape_dismissible() -> bool: - return _draggedDecor != null or (is_instance_valid(_inventoryPanel) and _inventoryPanel.visible) + return _draggedDecor != null or (is_instance_valid(_inventoryPanel) and _inventoryPanel.visible) or (is_instance_valid(_unsavedDialog) and _unsavedDialog.visible) or _editMode func get_escape_priority() -> int: return 500 func request_escape_close() -> void: if _draggedDecor != null: - _finish_decor_drag() + _cancel_decor_drag() return - if is_instance_valid(_inventoryPanel): + if is_instance_valid(_inventoryPanel) and _inventoryPanel.visible: _inventoryPanel.visible = false + return + if is_instance_valid(_unsavedDialog) and _unsavedDialog.visible: + _unsavedDialog.visible = false + return + if _editMode: + _request_finish_edit() func _apply_spawn_point() -> void: if player.has_scene_position_override: @@ -126,6 +170,12 @@ func _apply_spawn_point() -> void: marker = $Markers/DefaultSpawn player.global_position = marker.global_position +func _configure_visit_mode() -> void: + var context: Dictionary = SceneManager.get_room_visit_context() + _visitorOwnerId = str(context.get("owner_user_id", "")).strip_edges() + _visitorOwnerName = str(context.get("owner_nickname", "")).strip_edges() + _visitorMode = not _visitorOwnerId.is_empty() + func _configure_camera() -> void: playerCamera.zoom = CAMERA_ZOOM playerCamera.position_smoothing_enabled = true @@ -150,6 +200,8 @@ func _build_room_decor_requests() -> void: add_child(_inventoryRequest) func _build_room_decor_ui() -> void: + _build_room_toolbar() + _build_unsaved_exit_dialog() _inventoryPanel = PanelContainer.new() _inventoryPanel.visible = false _inventoryPanel.custom_minimum_size = Vector2(560, 560) @@ -223,19 +275,6 @@ func _build_room_decor_ui() -> void: _inventoryGrid.add_theme_constant_override("v_separation", 14) scroll.add_child(_inventoryGrid) - var removeButton := Button.new() - removeButton.text = "收回选中家具" - removeButton.custom_minimum_size = Vector2(0, 46) - removeButton.add_theme_font_size_override("font_size", 17) - removeButton.add_theme_color_override("font_color", TEXT_COLOR) - removeButton.add_theme_color_override("font_hover_color", ACCENT_COLOR) - removeButton.add_theme_color_override("font_pressed_color", Color(0.060, 0.270, 0.540, 1.0)) - removeButton.add_theme_color_override("font_disabled_color", Color(0.520, 0.600, 0.680, 0.55)) - removeButton.add_theme_stylebox_override("normal", _create_panel_style(Color(0.900, 0.960, 1.0, 0.92), 16, false)) - removeButton.add_theme_stylebox_override("hover", _create_panel_style(Color(0.820, 0.925, 1.0, 1.0), 16, false)) - removeButton.pressed.connect(_remove_selected_decor) - root.add_child(removeButton) - _toastLabel = Label.new() _toastLabel.visible = false _toastLabel.offset_left = 360 @@ -257,6 +296,146 @@ func _build_room_decor_ui() -> void: _dragSurface.gui_input.connect(_on_drag_surface_gui_input) uiLayer.add_child(_dragSurface) +func _build_room_toolbar() -> void: + _roomToolbar = PanelContainer.new() + _roomToolbar.mouse_filter = Control.MOUSE_FILTER_STOP + _roomToolbar.add_theme_stylebox_override("panel", _create_panel_style(Color(0.970, 0.992, 1.0, 0.94), 18, true)) + if _visitorMode: + _roomToolbar.set_anchors_preset(Control.PRESET_CENTER_TOP) + _roomToolbar.offset_left = -430 + _roomToolbar.offset_top = 26 + _roomToolbar.offset_right = 430 + _roomToolbar.offset_bottom = 88 + else: + _roomToolbar.set_anchors_preset(Control.PRESET_CENTER_BOTTOM) + _roomToolbar.offset_left = -540 + _roomToolbar.offset_top = -96 + _roomToolbar.offset_right = 540 + _roomToolbar.offset_bottom = -28 + uiLayer.add_child(_roomToolbar) + + var margin := MarginContainer.new() + margin.add_theme_constant_override("margin_left", 16) + margin.add_theme_constant_override("margin_top", 8) + margin.add_theme_constant_override("margin_right", 16) + margin.add_theme_constant_override("margin_bottom", 8) + _roomToolbar.add_child(margin) + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 8) + margin.add_child(row) + + _roomTitleLabel = Label.new() + _roomTitleLabel.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _roomTitleLabel.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _roomTitleLabel.add_theme_font_size_override("font_size", 19) + _roomTitleLabel.add_theme_color_override("font_color", TEXT_COLOR) + _roomTitleLabel.text = "%s的个人空间 · 只读参观" % (_visitorOwnerName if not _visitorOwnerName.is_empty() else "这位玩家") if _visitorMode else "我的个人空间" + row.add_child(_roomTitleLabel) + + if _visitorMode: + row.add_child(_create_toolbar_button("离开参观", _leave_visited_room)) + return + + _layoutStatusLabel = Label.new() + _layoutStatusLabel.custom_minimum_size = Vector2(128, 0) + _layoutStatusLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + _layoutStatusLabel.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _layoutStatusLabel.add_theme_font_size_override("font_size", 14) + _layoutStatusLabel.add_theme_color_override("font_color", MUTED_COLOR) + row.add_child(_layoutStatusLabel) + + _escHintLabel = Label.new() + _escHintLabel.text = "Esc 结束编辑" + _escHintLabel.custom_minimum_size = Vector2(92, 0) + _escHintLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _escHintLabel.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _escHintLabel.add_theme_font_size_override("font_size", 13) + _escHintLabel.add_theme_color_override("font_color", MUTED_COLOR) + row.add_child(_escHintLabel) + + _editButton = _create_toolbar_button("编辑布局", func() -> void: _set_edit_mode(true)) + _backpackButton = _create_toolbar_button("背包", _toggle_inventory_panel) + _undoButton = _create_toolbar_button("撤销", _undo_last_change) + _redoButton = _create_toolbar_button("重做", _redo_last_change) + _rotateButton = _create_toolbar_button("旋转", _rotate_selected_decor) + _collectButton = _create_toolbar_button("收回", _remove_selected_decor) + _resetButton = _create_toolbar_button("重置布局", _request_reset_layout) + _saveButton = _create_toolbar_button("保存", _save_layout) + _finishEditButton = _create_toolbar_button("完成编辑", _request_finish_edit) + row.add_child(_editButton) + row.add_child(_backpackButton) + row.add_child(_undoButton) + row.add_child(_redoButton) + row.add_child(_rotateButton) + row.add_child(_collectButton) + row.add_child(_resetButton) + row.add_child(_saveButton) + row.add_child(_finishEditButton) + _update_editor_toolbar() + +func _build_unsaved_exit_dialog() -> void: + _unsavedDialog = PanelContainer.new() + _unsavedDialog.name = "UnsavedLayoutDialog" + _unsavedDialog.visible = false + _unsavedDialog.mouse_filter = Control.MOUSE_FILTER_STOP + _unsavedDialog.set_anchors_preset(Control.PRESET_CENTER) + _unsavedDialog.offset_left = -250 + _unsavedDialog.offset_top = -132 + _unsavedDialog.offset_right = 250 + _unsavedDialog.offset_bottom = 132 + _unsavedDialog.add_theme_stylebox_override("panel", _create_panel_style(Color(0.965, 0.990, 1.0, 0.99), 24, true)) + uiLayer.add_child(_unsavedDialog) + + var margin := MarginContainer.new() + margin.add_theme_constant_override("margin_left", 28) + margin.add_theme_constant_override("margin_top", 26) + margin.add_theme_constant_override("margin_right", 28) + margin.add_theme_constant_override("margin_bottom", 24) + _unsavedDialog.add_child(margin) + var content := VBoxContainer.new() + content.add_theme_constant_override("separation", 14) + margin.add_child(content) + var title := Label.new() + title.text = "布局还没有保存" + title.add_theme_font_size_override("font_size", 24) + title.add_theme_color_override("font_color", TEXT_COLOR) + content.add_child(title) + var detail := Label.new() + detail.text = "你可以先保存本次摆放,或者放弃未保存的改动。" + detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + detail.add_theme_font_size_override("font_size", 16) + detail.add_theme_color_override("font_color", MUTED_COLOR) + content.add_child(detail) + var actions := HBoxContainer.new() + actions.alignment = BoxContainer.ALIGNMENT_END + actions.add_theme_constant_override("separation", 10) + content.add_child(actions) + var continueButton := _create_toolbar_button("继续编辑", func() -> void: _hide_unsaved_dialog()) + var discardButton := _create_toolbar_button("放弃改动", _discard_and_finish_edit) + discardButton.add_theme_color_override("font_hover_color", Color(0.850, 0.300, 0.340)) + var saveExitButton := _create_toolbar_button("保存并退出", func() -> void: _save_layout(true)) + saveExitButton.add_theme_color_override("font_color", Color.WHITE) + saveExitButton.add_theme_stylebox_override("normal", _create_panel_style(ACCENT_COLOR, 12, false)) + saveExitButton.add_theme_stylebox_override("hover", _create_panel_style(Color(0.150, 0.680, 1.0, 1.0), 12, false)) + actions.add_child(continueButton) + actions.add_child(discardButton) + actions.add_child(saveExitButton) + +func _create_toolbar_button(label_text: String, callback: Callable) -> Button: + var button := Button.new() + button.text = label_text + button.custom_minimum_size = Vector2(74, 38) + button.focus_mode = Control.FOCUS_NONE + button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND + button.add_theme_font_size_override("font_size", 15) + button.add_theme_color_override("font_color", TEXT_COLOR) + button.add_theme_color_override("font_hover_color", ACCENT_COLOR) + button.add_theme_color_override("font_disabled_color", Color(0.52, 0.60, 0.68, 0.65)) + button.add_theme_stylebox_override("normal", _create_panel_style(Color(0.900, 0.960, 1.0, 0.92), 12, false)) + button.add_theme_stylebox_override("hover", _create_panel_style(Color(0.820, 0.925, 1.0, 1.0), 12, false)) + button.pressed.connect(callback) + return button + func _connect_room_decor_events() -> void: var eventSystem := _get_event_system() if eventSystem == null: @@ -264,18 +443,27 @@ func _connect_room_decor_events() -> void: else: eventSystem.call("connect_event", EventNames.HUD_BACKPACK_TOGGLE, _on_backpack_toggle_requested, self) var saveManager := get_node_or_null("/root/RoomDecorSaveManager") - if saveManager != null: + if not _visitorMode and saveManager != null: if not saveManager.decor_save_succeeded.is_connected(_on_decor_save_succeeded): saveManager.decor_save_succeeded.connect(_on_decor_save_succeeded) if not saveManager.decor_save_failed.is_connected(_on_decor_save_failed): saveManager.decor_save_failed.connect(_on_decor_save_failed) func _on_backpack_toggle_requested(_data: Variant = null) -> void: + if _visitorMode: + _show_toast("参观模式不能编辑布局") + return + if not _editMode: + _show_toast("请先点击“编辑布局”") + return _toggle_inventory_panel() func _toggle_inventory_panel() -> void: + if not _can_edit_layout(): + _show_toast("请先进入布局编辑") + return _inventoryPanel.visible = not _inventoryPanel.visible - if _inventoryPanel.visible: + if _inventoryPanel.visible and not _inventoryLoaded: _fetch_room_decor_inventory(true) func _fetch_room_decor_inventory(reportErrors: bool = false) -> void: @@ -289,7 +477,8 @@ func _fetch_room_decor_inventory(reportErrors: bool = false) -> void: return _inventoryRequestInFlight = true _inventoryRequestReportsErrors = reportErrors - var err := _inventoryRequest.request("%s/rooms/me/decor-placements" % NetworkConfig.get_api_base_url(), _auth_headers(), HTTPClient.METHOD_GET, "") + var endpoint: String = "/rooms/%s/decor-placements" % _visitorOwnerId if _visitorMode else "/rooms/me/decor-placements" + var err := _inventoryRequest.request("%s%s" % [NetworkConfig.get_api_base_url(), endpoint], _auth_headers(), HTTPClient.METHOD_GET, "") if err != OK: _inventoryRequestInFlight = false if _inventoryRequestReportsErrors: @@ -324,7 +513,16 @@ func _on_inventory_request_completed(result: int, responseCode: int, _headers: P _apply_inventory_payload(dataVariant as Dictionary) func _apply_inventory_payload(data: Dictionary) -> void: + if _editMode and not _dirtyDecorIds.is_empty(): + return _decorItems.clear() + if _visitorMode: + var owner_variant: Variant = data.get("owner", {}) + if owner_variant is Dictionary: + var owner: Dictionary = owner_variant + _visitorOwnerName = str(owner.get("nickname", owner.get("username", _visitorOwnerName))).strip_edges() + if is_instance_valid(_roomTitleLabel): + _roomTitleLabel.text = "%s的个人空间 · 只读参观" % (_visitorOwnerName if not _visitorOwnerName.is_empty() else "这位玩家") _apply_remote_decor_definitions(data.get("definitions", [])) var itemsVariant: Variant = data.get("items", []) if itemsVariant is Array: @@ -334,13 +532,23 @@ func _apply_inventory_payload(data: Dictionary) -> void: var decorId := str(item.get("decor_id", "")).strip_edges() if not decorId.is_empty(): _decorItems[decorId] = _merge_decor_definition(item) + if not _visitorMode: + _serverDecorItems = _duplicate_item_map(_decorItems) + _dirtyDecorIds.clear() + _saveFailedIds.clear() + _inventoryLoaded = true _render_inventory_list() _render_placed_decors() + _update_editor_toolbar() func _apply_local_empty_inventory() -> void: _decorItems.clear() + _serverDecorItems.clear() + _dirtyDecorIds.clear() + _inventoryLoaded = true _render_inventory_list() _render_placed_decors() + _update_editor_toolbar() func _merge_decor_definition(item: Dictionary) -> Dictionary: var decorId := str(item.get("decor_id", "")).strip_edges() @@ -370,6 +578,8 @@ func _apply_remote_decor_definitions(definitionsVariant: Variant) -> void: _remoteDecorDefinitions[decorId] = definition func _render_inventory_list() -> void: + if _visitorMode: + return _clear_children(_inventoryGrid) if _decorItems.is_empty(): var empty := _create_empty_inventory_card() @@ -471,11 +681,13 @@ func _render_placed_decors() -> void: var item: Dictionary = _decorItems[decorId] if bool(item.get("placed", false)): _ensure_decor_node(str(decorId), item) + _refresh_decor_selection() func _begin_inventory_decor_drag(decorId: String) -> void: - if not _decorItems.has(decorId): + if not _can_edit_layout() or not _decorItems.has(decorId): return _selectedDecorId = decorId + _dragOriginalItem = (_decorItems[decorId] as Dictionary).duplicate(true) var item := _ensure_decor_placed_for_drag(decorId) var node := _ensure_decor_node(decorId, item) if node == null: @@ -487,6 +699,7 @@ func _begin_inventory_decor_drag(decorId: String) -> void: _inventoryPanel.visible = false _enable_drag_surface() _update_dragged_decor_position() + _refresh_decor_selection() _pulse_node(_draggedDecor) func _ensure_decor_placed_for_drag(decorId: String) -> Dictionary: @@ -497,6 +710,7 @@ func _ensure_decor_placed_for_drag(decorId: String) -> Dictionary: item["position_x"] = defaultPosition.x item["position_y"] = defaultPosition.y item["scale"] = _get_item_float(item, "default_scale", 1.0) + item["rotation_degrees"] = _get_item_int(item, "default_rotation_degrees", 0) item["z_index"] = _get_item_int(item, "default_z_index", 0) _decorItems[decorId] = item return item @@ -517,11 +731,14 @@ func _ensure_decor_node(decorId: String, item: Dictionary) -> Sprite2D: node.global_position = Vector2(_to_float(item.get("position_x", 0.0), 0.0), _to_float(item.get("position_y", 0.0), 0.0)) var itemScale := _get_item_float(item, "scale", _get_item_float(item, "default_scale", 1.0)) node.scale = Vector2(itemScale, itemScale) + node.rotation_degrees = _get_item_float(item, "rotation_degrees", _get_item_float(item, "default_rotation_degrees", 0.0)) node.z_index = _get_item_int(item, "z_index", _get_item_int(item, "default_z_index", 0)) _sync_decor_collision(decorId, item) return node func _try_begin_decor_drag(worldPosition: Vector2) -> bool: + if not _can_edit_layout(): + return false var bestNode: Sprite2D = null var bestZ := -100000 for decorId in _decorNodes.keys(): @@ -537,10 +754,13 @@ func _try_begin_decor_drag(worldPosition: Vector2) -> bool: return false _draggedDecor = bestNode _selectedDecorId = str(bestNode.get_meta("decor_id", "")) + _dragOriginalItem = (_decorItems.get(_selectedDecorId, {}) as Dictionary).duplicate(true) _dragOffset = bestNode.global_position - worldPosition _set_decor_collision_disabled(_selectedDecorId, true) _draggedDecor.z_index = max(_draggedDecor.z_index, 50) _enable_drag_surface() + _refresh_decor_selection() + _update_editor_toolbar() return true func _finish_decor_drag() -> void: @@ -550,23 +770,30 @@ func _finish_decor_drag() -> void: var decorId := str(_draggedDecor.get_meta("decor_id", "")) if _decorItems.has(decorId): var item: Dictionary = _decorItems[decorId] - item["position_x"] = _draggedDecor.global_position.x - item["position_y"] = _draggedDecor.global_position.y + var snapped_position := _snap_to_room_grid(_draggedDecor.global_position) + _draggedDecor.global_position = snapped_position + item["position_x"] = snapped_position.x + item["position_y"] = snapped_position.y item["placed"] = true item["scale"] = _draggedDecor.scale.x + item["rotation_degrees"] = _draggedDecor.rotation_degrees item["z_index"] = _get_item_int(item, "default_z_index", 0) _decorItems[decorId] = item _draggedDecor.z_index = _get_item_int(item, "z_index", 0) _sync_decor_collision(decorId, item) _set_decor_collision_disabled(decorId, false) - _save_decor_item(item) + _record_item_change(_dragOriginalItem, item) + _mark_decor_dirty(decorId) _render_inventory_list() _draggedDecor = null + _dragOriginalItem.clear() _disable_drag_surface() + _refresh_decor_selection() + _update_editor_toolbar() func _update_dragged_decor_position() -> void: if _draggedDecor != null: - _draggedDecor.global_position = get_global_mouse_position() + _dragOffset + _draggedDecor.global_position = _constrain_room_position(get_global_mouse_position() + _dragOffset) var decorId := str(_draggedDecor.get_meta("decor_id", "")) if not decorId.is_empty() and _decorItems.has(decorId): var item: Dictionary = _decorItems[decorId] @@ -574,6 +801,21 @@ func _update_dragged_decor_position() -> void: item["position_y"] = _draggedDecor.global_position.y _sync_decor_collision(decorId, item) +func _cancel_decor_drag() -> void: + if _draggedDecor == null: + _disable_drag_surface() + return + var decor_id: String = str(_draggedDecor.get_meta("decor_id", "")) + if not decor_id.is_empty() and not _dragOriginalItem.is_empty(): + _decorItems[decor_id] = _dragOriginalItem.duplicate(true) + _render_placed_decors() + _render_inventory_list() + _draggedDecor = null + _dragOriginalItem.clear() + _disable_drag_surface() + _refresh_decor_selection() + _update_editor_toolbar() + func _enable_drag_surface() -> void: if is_instance_valid(_dragSurface): _dragSurface.visible = true @@ -618,10 +860,14 @@ func _control_blocks_pointer(control: Control, mousePosition: Vector2) -> bool: return false func _remove_selected_decor() -> void: + if not _can_edit_layout(): + _show_toast("请先进入编辑布局") + return if _selectedDecorId.is_empty() or not _decorItems.has(_selectedDecorId): _show_toast("请先选择一个家具") return var item: Dictionary = _decorItems[_selectedDecorId] + var before: Dictionary = item.duplicate(true) item["placed"] = false _decorItems[_selectedDecorId] = item if _decorNodes.has(_selectedDecorId): @@ -630,28 +876,433 @@ func _remove_selected_decor() -> void: node.queue_free() _decorNodes.erase(_selectedDecorId) _remove_decor_collision(_selectedDecorId) - _save_decor_item(item) + _record_item_change(before, item) + _mark_decor_dirty(_selectedDecorId) _render_inventory_list() + _refresh_decor_selection() + _update_editor_toolbar() _show_toast("已收回家具") -func _save_decor_item(item: Dictionary) -> void: - if not _is_authenticated(): - _show_toast("请先登录后保存摆放") - return - var saveManager := get_node_or_null("/root/RoomDecorSaveManager") - if saveManager == null or not saveManager.has_method("enqueue_save"): - _show_toast("家具保存服务不可用") - return - saveManager.call("enqueue_save", item) - func _on_decor_save_succeeded(savedItem: Dictionary) -> void: var item := _merge_decor_definition(savedItem.duplicate(true)) var decorId := str(item.get("decor_id", "")) - if not decorId.is_empty(): - _decorItems[decorId] = item + if decorId.is_empty() or not _saveAwaitingIds.has(decorId): + return + _serverDecorItems[decorId] = item.duplicate(true) + _saveAwaitingIds.erase(decorId) + if _decorItems.has(decorId) and _placement_signature(_decorItems[decorId]) == _placement_signature(item): + _dirtyDecorIds.erase(decorId) + else: + _dirtyDecorIds[decorId] = true + _finalize_save_if_ready() -func _on_decor_save_failed(_item: Dictionary, message: String) -> void: - _show_toast(message) +func _can_edit_layout() -> bool: + return not _visitorMode and _editMode and not _saveInProgress + +func _set_edit_mode(enabled: bool) -> void: + if _visitorMode: + return + if not enabled: + _request_finish_edit() + return + if _editMode: + return + _editMode = true + _selectedDecorId = "" + _undoHistory.clear() + _redoHistory.clear() + _resetArmed = false + _refresh_decor_selection() + _update_editor_toolbar() + _show_toast("布局编辑已开启;改动会在点击保存后提交") + +func _request_finish_edit() -> void: + if not _editMode: + return + if _draggedDecor != null: + _finish_decor_drag() + if _saveInProgress: + _show_toast("正在保存布局,请稍候") + return + if _has_unsaved_changes(): + _show_unsaved_exit_dialog() + return + _end_edit_session() + +func _end_edit_session() -> void: + _editMode = false + _selectedDecorId = "" + _undoHistory.clear() + _redoHistory.clear() + _resetArmed = false + _exitAfterSave = false + if is_instance_valid(_inventoryPanel): + _inventoryPanel.visible = false + _hide_unsaved_dialog() + _refresh_decor_selection() + _update_editor_toolbar() + _show_toast("布局编辑已完成") + +func _show_unsaved_exit_dialog() -> void: + if is_instance_valid(_unsavedDialog): + _unsavedDialog.visible = true + +func _hide_unsaved_dialog() -> void: + if is_instance_valid(_unsavedDialog): + _unsavedDialog.visible = false + +func _discard_and_finish_edit() -> void: + if _saveInProgress: + return + _decorItems = _duplicate_item_map(_serverDecorItems) + _dirtyDecorIds.clear() + _saveFailedIds.clear() + _render_inventory_list() + _render_placed_decors() + _end_edit_session() + +func _has_unsaved_changes() -> bool: + return not _dirtyDecorIds.is_empty() + +func _update_editor_toolbar() -> void: + if _visitorMode: + return + var has_selection := not _selectedDecorId.is_empty() and _decorItems.has(_selectedDecorId) + var selected_is_placed := has_selection and bool((_decorItems[_selectedDecorId] as Dictionary).get("placed", false)) + var dirty_count := _dirtyDecorIds.size() + if is_instance_valid(_editButton): + _editButton.visible = not _editMode + if is_instance_valid(_backpackButton): + _backpackButton.visible = _editMode + _backpackButton.disabled = _saveInProgress + if is_instance_valid(_finishEditButton): + _finishEditButton.visible = _editMode + _finishEditButton.disabled = _saveInProgress + if is_instance_valid(_undoButton): + _undoButton.visible = _editMode + _undoButton.disabled = not _can_edit_layout() or _undoHistory.is_empty() + if is_instance_valid(_redoButton): + _redoButton.visible = _editMode + _redoButton.disabled = not _can_edit_layout() or _redoHistory.is_empty() + if is_instance_valid(_rotateButton): + _rotateButton.visible = _editMode + _rotateButton.disabled = not _can_edit_layout() or not selected_is_placed + if is_instance_valid(_collectButton): + _collectButton.visible = _editMode + _collectButton.disabled = not _can_edit_layout() or not selected_is_placed + if is_instance_valid(_resetButton): + _resetButton.visible = _editMode + _resetButton.disabled = not _can_edit_layout() + if is_instance_valid(_saveButton): + _saveButton.visible = _editMode + _saveButton.text = "保存" if dirty_count == 0 else "保存(%d)" % dirty_count + _saveButton.disabled = not _editMode or _saveInProgress or dirty_count == 0 + if is_instance_valid(_layoutStatusLabel): + _layoutStatusLabel.visible = true + var selected_name := str((_decorItems.get(_selectedDecorId, {}) as Dictionary).get("name", "")) if has_selection else "" + var selected_prefix := ("选中:%s · " % selected_name) if not selected_name.is_empty() else "" + if _saveInProgress: + _layoutStatusLabel.text = "正在保存 %d 项…" % _saveAwaitingIds.size() + elif not _saveFailedIds.is_empty(): + _layoutStatusLabel.text = "%d 项保存失败,可重试" % _saveFailedIds.size() + elif dirty_count > 0: + _layoutStatusLabel.text = "%s%d 项未保存" % [selected_prefix, dirty_count] + elif _editMode: + _layoutStatusLabel.text = "%s草稿已保存" % selected_prefix + else: + _layoutStatusLabel.text = "布局已保存" + if is_instance_valid(_escHintLabel): + _escHintLabel.visible = _editMode + _escHintLabel.text = "Esc 结束编辑" if _editMode else "" + +func _record_item_change(before: Dictionary, after: Dictionary) -> void: + if before == after: + return + var decor_id: String = str(after.get("decor_id", before.get("decor_id", ""))).strip_edges() + if decor_id.is_empty(): + return + _undoHistory.append({ + "decor_id": decor_id, + "before": before.duplicate(true), + "after": after.duplicate(true), + }) + _redoHistory.clear() + _update_editor_toolbar() + +func _record_bulk_change(before_items: Dictionary, after_items: Dictionary) -> void: + if before_items == after_items: + return + _undoHistory.append({ + "before_items": _duplicate_item_map(before_items), + "after_items": _duplicate_item_map(after_items), + }) + _redoHistory.clear() + _update_editor_toolbar() + +func _undo_last_change() -> void: + if not _can_edit_layout() or _undoHistory.is_empty(): + return + var entry: Dictionary = _undoHistory.pop_back() + _apply_history_entry(entry, false) + _redoHistory.append(entry) + _update_editor_toolbar() + +func _redo_last_change() -> void: + if not _can_edit_layout() or _redoHistory.is_empty(): + return + var entry: Dictionary = _redoHistory.pop_back() + _apply_history_entry(entry, true) + _undoHistory.append(entry) + _update_editor_toolbar() + +func _apply_history_entry(entry: Dictionary, use_after: bool) -> void: + var key := "after_items" if use_after else "before_items" + var bulk_variant: Variant = entry.get(key, null) + if bulk_variant is Dictionary: + var bulk_items: Dictionary = bulk_variant as Dictionary + for decor_id_variant in bulk_items.keys(): + var decor_id := str(decor_id_variant) + var item_variant: Variant = bulk_items.get(decor_id_variant, {}) + if item_variant is Dictionary: + _decorItems[decor_id] = (item_variant as Dictionary).duplicate(true) + _mark_decor_dirty(decor_id) + _selectedDecorId = "" + else: + var item_key := "after" if use_after else "before" + var item_variant: Variant = entry.get(item_key, {}) + if not (item_variant is Dictionary): + return + var item: Dictionary = (item_variant as Dictionary).duplicate(true) + var decor_id: String = str(item.get("decor_id", "")).strip_edges() + if decor_id.is_empty(): + return + _decorItems[decor_id] = item + _selectedDecorId = decor_id + _mark_decor_dirty(decor_id) + _render_placed_decors() + _render_inventory_list() + _refresh_decor_selection() + +func _rotate_selected_decor() -> void: + if not _can_edit_layout() or _selectedDecorId.is_empty() or not _decorItems.has(_selectedDecorId): + return + var item: Dictionary = _decorItems[_selectedDecorId] + if not bool(item.get("placed", false)): + _show_toast("请先摆放选中的家具") + return + var steps_variant: Variant = item.get("rotation_steps", [0]) + if not (steps_variant is Array): + return + var steps: Array = steps_variant as Array + if steps.size() <= 1: + _show_toast("该家具的方向已锁定") + return + var current_rotation: int = _get_item_int(item, "rotation_degrees", _get_item_int(item, "default_rotation_degrees", 0)) + var current_index: int = 0 + for index in range(steps.size()): + if _to_int(steps[index], 0) == current_rotation: + current_index = index + break + var before: Dictionary = item.duplicate(true) + item["rotation_degrees"] = _to_int(steps[(current_index + 1) % steps.size()], 0) + _decorItems[_selectedDecorId] = item + _render_placed_decors() + _record_item_change(before, item) + _mark_decor_dirty(_selectedDecorId) + _refresh_decor_selection() + +func _request_reset_layout() -> void: + if not _can_edit_layout(): + return + if not _resetArmed: + _resetArmed = true + _show_toast("再次点击“重置布局”确认;家具不会丢失") + get_tree().create_timer(3.0).timeout.connect(func() -> void: _resetArmed = false) + return + _resetArmed = false + var before_items := _duplicate_item_map(_decorItems) + for decor_id_variant in _decorItems.keys(): + var decor_id := str(decor_id_variant) + var item: Dictionary = (_decorItems[decor_id] as Dictionary).duplicate(true) + item["placed"] = false + var default_position := _variant_to_vector2(item.get("default_position", Vector2.ZERO)) + item["position_x"] = default_position.x + item["position_y"] = default_position.y + item["scale"] = _get_item_float(item, "default_scale", 1.0) + item["rotation_degrees"] = _get_item_int(item, "default_rotation_degrees", 0) + item["z_index"] = _get_item_int(item, "default_z_index", 0) + _decorItems[decor_id] = item + _mark_decor_dirty(decor_id) + var after_items := _duplicate_item_map(_decorItems) + _record_bulk_change(before_items, after_items) + _selectedDecorId = "" + _render_placed_decors() + _render_inventory_list() + _refresh_decor_selection() + _update_editor_toolbar() + _show_toast("布局已重置为草稿;点击保存后生效") + +func _save_layout(exit_after_save: bool = false) -> void: + if not _editMode: + return + if _saveInProgress: + _show_toast("正在保存布局,请稍候") + return + if _dirtyDecorIds.is_empty(): + _show_toast("当前没有需要保存的改动") + if exit_after_save: + _end_edit_session() + return + if not _is_authenticated(): + _show_toast("请先登录后保存摆放") + return + var save_manager := get_node_or_null("/root/RoomDecorSaveManager") + if save_manager == null or not save_manager.has_method("enqueue_save"): + _show_toast("家具保存服务不可用") + return + var save_ids: Array[String] = [] + for decor_id_variant in _dirtyDecorIds.keys(): + var decor_id := str(decor_id_variant) + if _decorItems.has(decor_id): + save_ids.append(decor_id) + if save_ids.is_empty(): + _dirtyDecorIds.clear() + _update_editor_toolbar() + return + _saveInProgress = true + _exitAfterSave = exit_after_save + _saveAwaitingIds.clear() + _saveFailedIds.clear() + for decor_id in save_ids: + _saveAwaitingIds[decor_id] = true + _update_editor_toolbar() + for decor_id in save_ids: + var item: Dictionary = (_decorItems[decor_id] as Dictionary).duplicate(true) + var accepted: bool = bool(save_manager.call("enqueue_save", item)) + if not accepted and _saveAwaitingIds.has(decor_id): + _saveAwaitingIds.erase(decor_id) + _saveFailedIds[decor_id] = "保存请求未能加入队列" + _finalize_save_if_ready() + +func _on_decor_save_failed(item: Dictionary, message: String) -> void: + var decor_id := str(item.get("decor_id", "")).strip_edges() + if decor_id.is_empty() or not _saveAwaitingIds.has(decor_id): + if not message.is_empty(): + _show_toast(message) + return + _saveAwaitingIds.erase(decor_id) + _saveFailedIds[decor_id] = message + _dirtyDecorIds[decor_id] = true + _finalize_save_if_ready() + +func _finalize_save_if_ready() -> void: + if not _saveInProgress or not _saveAwaitingIds.is_empty(): + return + _saveInProgress = false + var exit_when_saved := _exitAfterSave and _saveFailedIds.is_empty() and _dirtyDecorIds.is_empty() + _exitAfterSave = false + _update_editor_toolbar() + if not _saveFailedIds.is_empty(): + _show_toast("%d 项家具未保存,可再次点击保存重试" % _saveFailedIds.size()) + return + _show_toast("布局已保存") + if exit_when_saved: + _end_edit_session() + +func _mark_decor_dirty(decor_id: String) -> void: + if decor_id.is_empty() or not _decorItems.has(decor_id): + return + var server_variant: Variant = _serverDecorItems.get(decor_id, null) + if server_variant is Dictionary and _placement_signature(_decorItems[decor_id]) == _placement_signature(server_variant as Dictionary): + _dirtyDecorIds.erase(decor_id) + _saveFailedIds.erase(decor_id) + else: + _dirtyDecorIds[decor_id] = true + _update_editor_toolbar() + +func _placement_signature(item: Dictionary) -> String: + return "%d|%.3f|%.3f|%.3f|%d|%d" % [ + 1 if bool(item.get("placed", false)) else 0, + _get_item_float(item, "position_x", 0.0), + _get_item_float(item, "position_y", 0.0), + _get_item_float(item, "scale", _get_item_float(item, "default_scale", 1.0)), + _get_item_int(item, "rotation_degrees", _get_item_int(item, "default_rotation_degrees", 0)), + _get_item_int(item, "z_index", _get_item_int(item, "default_z_index", 0)), + ] + +func _duplicate_item_map(source: Dictionary) -> Dictionary: + var duplicate: Dictionary = {} + for decor_id_variant in source.keys(): + var item_variant: Variant = source.get(decor_id_variant, {}) + if item_variant is Dictionary: + duplicate[str(decor_id_variant)] = (item_variant as Dictionary).duplicate(true) + return duplicate + +func _refresh_decor_selection() -> void: + for decor_id_variant in _decorNodes.keys(): + var decor_id := str(decor_id_variant) + var node := _decorNodes.get(decor_id, null) as Sprite2D + if node == null or not is_instance_valid(node): + continue + var frame := node.get_node_or_null("SelectionFrame") as Line2D + var collision_preview := node.get_node_or_null("CollisionPreview") as Line2D + var show_frame := _editMode and decor_id == _selectedDecorId and node.texture != null + if not show_frame: + if frame != null: + frame.queue_free() + if collision_preview != null: + collision_preview.queue_free() + continue + if frame == null: + frame = Line2D.new() + frame.name = "SelectionFrame" + frame.antialiased = true + frame.default_color = Color(0.040, 0.740, 1.0, 0.96) + frame.z_index = 20 + node.add_child(frame) + var half_size: Vector2 = node.texture.get_size() * 0.5 + frame.points = PackedVector2Array([ + Vector2(-half_size.x, -half_size.y), Vector2(half_size.x, -half_size.y), + Vector2(half_size.x, half_size.y), Vector2(-half_size.x, half_size.y), Vector2(-half_size.x, -half_size.y), + ]) + frame.width = 3.0 / maxf(absf(node.scale.x), 0.1) + frame.default_color = Color(1.0, 0.750, 0.180, 0.98) if node == _draggedDecor else Color(0.040, 0.740, 1.0, 0.96) + var item: Dictionary = _decorItems.get(decor_id, {}) as Dictionary + var collision_size := _variant_to_vector2(item.get("collision_size", Vector2.ZERO)) + if node == _draggedDecor and collision_size != Vector2.ZERO: + if collision_preview == null: + collision_preview = Line2D.new() + collision_preview.name = "CollisionPreview" + collision_preview.antialiased = true + collision_preview.z_index = 21 + node.add_child(collision_preview) + var collision_half := collision_size * 0.5 + collision_preview.position = _variant_to_vector2(item.get("collision_offset", Vector2.ZERO)) + collision_preview.points = PackedVector2Array([ + Vector2(-collision_half.x, -collision_half.y), Vector2(collision_half.x, -collision_half.y), + Vector2(collision_half.x, collision_half.y), Vector2(-collision_half.x, collision_half.y), Vector2(-collision_half.x, -collision_half.y), + ]) + collision_preview.width = 2.0 / maxf(absf(node.scale.x), 0.1) + collision_preview.default_color = Color(1.0, 0.860, 0.260, 0.92) + elif collision_preview != null: + collision_preview.queue_free() + +func _leave_visited_room() -> void: + if not SceneManager.return_from_room_visit(): + SceneManager.change_scene("square") + +func _constrain_room_position(position: Vector2) -> Vector2: + return Vector2( + clampf(position.x, ROOM_PLACE_BOUNDS.position.x, ROOM_PLACE_BOUNDS.end.x), + clampf(position.y, ROOM_PLACE_BOUNDS.position.y, ROOM_PLACE_BOUNDS.end.y) + ) + +func _snap_to_room_grid(position: Vector2) -> Vector2: + var constrained := _constrain_room_position(position) + return Vector2( + roundf(constrained.x / ROOM_GRID_SIZE) * ROOM_GRID_SIZE, + roundf(constrained.y / ROOM_GRID_SIZE) * ROOM_GRID_SIZE + ) func _is_point_inside_sprite(node: Sprite2D, worldPosition: Vector2) -> bool: if node.texture == null: @@ -689,7 +1340,9 @@ func _sync_decor_collision(decorId: String, item: Dictionary) -> void: shapeNode.shape = RectangleShape2D.new() body.add_child(shapeNode) var scale := _get_item_float(item, "scale", _get_item_float(item, "default_scale", 1.0)) - body.global_position = Vector2(_to_float(item.get("position_x", 0.0), 0.0), _to_float(item.get("position_y", 0.0), 0.0)) + collisionOffset * scale + var rotation_degrees := _get_item_float(item, "rotation_degrees", _get_item_float(item, "default_rotation_degrees", 0.0)) + body.rotation_degrees = rotation_degrees + body.global_position = Vector2(_to_float(item.get("position_x", 0.0), 0.0), _to_float(item.get("position_y", 0.0), 0.0)) + collisionOffset.rotated(deg_to_rad(rotation_degrees)) * scale shapeNode.position = Vector2.ZERO var rectShape := shapeNode.shape as RectangleShape2D if rectShape == null: diff --git a/scenes/Maps/ScenePortal.gd b/scenes/Maps/ScenePortal.gd index ca3e743..147abea 100644 --- a/scenes/Maps/ScenePortal.gd +++ b/scenes/Maps/ScenePortal.gd @@ -32,6 +32,11 @@ func _change_scene() -> void: if targetSceneName.is_empty(): push_warning("ScenePortal: targetSceneName is empty.") return + if targetSceneName == "personal_space": + SceneManager.clear_room_visit_context() + elif targetSceneName == "square" and SceneManager.has_room_visit_context(): + SceneManager.return_from_room_visit() + return if not targetSpawnName.is_empty(): SceneManager.set_next_spawn_name(targetSpawnName) diff --git a/scenes/ui/FriendListPanel.gd b/scenes/ui/FriendListPanel.gd index 68ff732..199beae 100644 --- a/scenes/ui/FriendListPanel.gd +++ b/scenes/ui/FriendListPanel.gd @@ -371,6 +371,19 @@ func _create_friend_row(friend: Dictionary) -> Control: dot.add_theme_stylebox_override("panel", _create_dot_style(ONLINE_COLOR if online else OFFLINE_COLOR)) row.add_child(dot) + if bool(friend.get("room_visitable", false)): + var visit_button := _create_small_button("房间", ACCENT_COLOR) + visit_button.name = "VisitRoomButton" + visit_button.custom_minimum_size = Vector2(56, 30) + visit_button.set_anchors_preset(Control.PRESET_CENTER_RIGHT) + visit_button.offset_left = -68 + visit_button.offset_top = -15 + visit_button.offset_right = -12 + visit_button.offset_bottom = 15 + visit_button.z_index = 2 + visit_button.pressed.connect(func() -> void: _visit_friend_room(userId, username)) + button.add_child(visit_button) + return button func _create_request_row(request: Dictionary) -> Control: @@ -417,6 +430,13 @@ func _select_friend(userId: String, username: String, _online: bool) -> void: "username": username }) +func _visit_friend_room(userId: String, username: String) -> void: + if userId.strip_edges().is_empty(): + return + var social_manager := get_node_or_null("/root/SocialManager") + if social_manager != null and social_manager.has_method("visit_room"): + social_manager.call("visit_room", userId, username) + func _respond_request(userId: String, username: String, accepted: bool) -> void: if userId.strip_edges().is_empty(): return diff --git a/scenes/ui/MapPanel.gd b/scenes/ui/MapPanel.gd index 24e8054..7efadec 100644 --- a/scenes/ui/MapPanel.gd +++ b/scenes/ui/MapPanel.gd @@ -436,6 +436,8 @@ func _on_travel_authorized(success: bool, destination: Dictionary) -> void: _render_markers() return set_panel_open(false) + if sceneName == "personal_space": + SceneManager.clear_room_visit_context() SceneManager.set_next_destination_id(str(destination.get("id", ""))) SceneManager.set_next_scene_position(position) SceneManager.change_scene(sceneName) diff --git a/scenes/ui/SettingsPanel.gd b/scenes/ui/SettingsPanel.gd index 3442461..85b37d2 100644 --- a/scenes/ui/SettingsPanel.gd +++ b/scenes/ui/SettingsPanel.gd @@ -47,6 +47,7 @@ const DEFAULT_SETTINGS: Dictionary = { "allow_nearby_private": true, "allow_nearby_friend_requests": true, "allow_nearby_profile": true, + "room_visit_policy": "friends", "mute_ui_sfx": false, } @@ -388,6 +389,9 @@ func _render_chat_content() -> void: _create_toggle_row("chat", "允许附近私聊", "allow_nearby_private", "附近玩家按 E 可以发起悄悄话"), _create_toggle_row("account", "允许好友申请", "allow_nearby_friend_requests", "附近玩家可通过互动列表发送好友申请"), ])) + _content.add_child(_create_section("个人空间访问", [ + _create_room_visit_policy_row(), + ])) func _render_controls_content() -> void: _content.add_child(_create_section("操作说明", [ @@ -526,6 +530,58 @@ func _create_toggle_row(iconName: String, labelText: String, key: String, detail return row +func _create_room_visit_policy_row() -> Control: + var row := HBoxContainer.new() + row.custom_minimum_size = Vector2(0, 54) + row.add_theme_constant_override("separation", 16) + row.alignment = BoxContainer.ALIGNMENT_CENTER + + var icon := ICON_SCRIPT.new() as Control + icon.set("iconName", "account") + icon.set("active", true) + icon.custom_minimum_size = Vector2(34, 34) + row.add_child(icon) + + var text_box := VBoxContainer.new() + text_box.size_flags_horizontal = Control.SIZE_EXPAND_FILL + text_box.alignment = BoxContainer.ALIGNMENT_CENTER + text_box.add_theme_constant_override("separation", 2) + row.add_child(text_box) + + var label := Label.new() + label.text = "谁可以参观我的房间" + label.add_theme_color_override("font_color", TEXT_COLOR) + label.add_theme_font_size_override("font_size", 17) + text_box.add_child(label) + + var detail := Label.new() + detail.text = "拉黑关系始终无法访问;房主始终可编辑。" + detail.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + detail.add_theme_color_override("font_color", MUTED_COLOR) + detail.add_theme_font_size_override("font_size", 13) + text_box.add_child(detail) + + var selector := OptionButton.new() + selector.custom_minimum_size = Vector2(126, 38) + selector.focus_mode = Control.FOCUS_NONE + selector.add_item("仅好友") + selector.set_item_metadata(0, "friends") + selector.add_item("公开") + selector.set_item_metadata(1, "public") + selector.add_item("关闭访问") + selector.set_item_metadata(2, "closed") + var current_policy: String = str(_settings.get("room_visit_policy", "friends")) + for index in range(selector.item_count): + if str(selector.get_item_metadata(index)) == current_policy: + selector.select(index) + break + selector.item_selected.connect(func(index: int) -> void: + _settings["room_visit_policy"] = str(selector.get_item_metadata(index)) + _apply_preview_settings() + ) + row.add_child(selector) + return row + func _create_scale_row() -> Control: var row := HBoxContainer.new() row.custom_minimum_size = Vector2(0, 48)