feat: polish personal space editor

This commit is contained in:
ANG-Server
2026-07-22 13:41:28 +08:00
parent e3accf6b71
commit d60444d2eb
10 changed files with 858 additions and 42 deletions

View File

@@ -1153,7 +1153,8 @@ func _handle_friend_list(data: Dictionary) -> void:
_friends.append({ _friends.append({
"user_id": str(friend.get("userId", friend.get("user_id", ""))).strip_edges(), "user_id": str(friend.get("userId", friend.get("user_id", ""))).strip_edges(),
"username": str(friend.get("username", "")), "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, { _emit_event(EventNames.CHAT_FRIENDS_UPDATED, {
@@ -1172,7 +1173,8 @@ func _handle_friend_added(data: Dictionary) -> void:
_upsert_friend({ _upsert_friend({
"user_id": friend_user_id, "user_id": friend_user_id,
"username": str(friend.get("username", "")), "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() request_friend_list()

View File

@@ -69,6 +69,7 @@ func _process_next_save() -> void:
"position_x": float(item.get("position_x", 0.0)), "position_x": float(item.get("position_x", 0.0)),
"position_y": float(item.get("position_y", 0.0)), "position_y": float(item.get("position_y", 0.0)),
"scale": float(item.get("scale", item.get("default_scale", 1.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))), "z_index": int(item.get("z_index", item.get("default_z_index", 0))),
} }
var decorId := str(item.get("decor_id", "")).uri_encode() var decorId := str(item.get("decor_id", "")).uri_encode()

View File

@@ -39,6 +39,7 @@ var is_changing_scene: bool = false # 是否正在切换场景
var _next_scene_position: Variant = null # 下一个场景的初始位置 (Vector2 or null) var _next_scene_position: Variant = null # 下一个场景的初始位置 (Vector2 or null)
var _next_spawn_name: String = "" # 下一个场景的出生点名称 (String) var _next_spawn_name: String = "" # 下一个场景的出生点名称 (String)
var _next_destination_id: String = "" # 快速传送目标,用于发现登记 var _next_destination_id: String = "" # 快速传送目标,用于发现登记
var _room_visit_context: Dictionary = {} # 访客房间的房主与返回位置
# 场景路径映射表 # 场景路径映射表
# 将场景名称映射到实际的文件路径 # 将场景名称映射到实际的文件路径
@@ -203,6 +204,39 @@ func get_next_destination_id() -> String:
_next_destination_id = "" _next_destination_id = ""
return 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
# ============ 场景注册方法 ============ # ============ 场景注册方法 ============
# 注册新场景 # 注册新场景

View File

@@ -25,6 +25,7 @@ const DEFAULT_SETTINGS: Dictionary = {
"allow_nearby_private": true, "allow_nearby_private": true,
"allow_nearby_friend_requests": true, "allow_nearby_friend_requests": true,
"allow_nearby_profile": true, "allow_nearby_profile": true,
"room_visit_policy": "friends",
"mute_ui_sfx": false, "mute_ui_sfx": false,
} }

View File

@@ -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}) event_system.call("emit_event", EventNames.CHAT_PRIVATE_TARGET_SELECTED, {"userId": user_id, "username": username})
_close_profile() _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: 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: _request_post("/world/travel-destinations/%s/travel" % destination_id.uri_encode(), {}, func(success: bool, response: Dictionary, error: Dictionary) -> void:
if not success: if not success:
@@ -218,6 +250,8 @@ func _render_profile() -> void:
actions.add_theme_constant_override("separation", 8) 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: open_private_chat(user_id, nickname)))
actions.add_child(_button("加好友", func() -> void: request_friend(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) _profile_content.add_child(actions)
var safety := HBoxContainer.new() var safety := HBoxContainer.new()
safety.add_theme_constant_override("separation", 8) safety.add_theme_constant_override("separation", 8)
@@ -427,6 +461,14 @@ func _current_user_id() -> String:
func _area_label(map_id: String) -> String: func _area_label(map_id: String) -> String:
return {"whale_port": "中心广场", "work_zone": "打工区", "whale_cafe": "鲸鱼咖啡馆", "personal_space": "个人空间"}.get(map_id, map_id) 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]: func _interest_catalog() -> Array[Dictionary]:
return [ return [
{"id": "ai", "label": "AI/大模型"}, {"id": "programming", "label": "编程开发"}, {"id": "ai", "label": "AI/大模型"}, {"id": "programming", "label": "编程开发"},

View File

@@ -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_TEXTURE_FILTER: int = CanvasItem.TEXTURE_FILTER_LINEAR
const DECOR_COLLISION_LAYER: int = 1 const DECOR_COLLISION_LAYER: int = 1
const DECOR_COLLISION_MASK: 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 player: PlayerController = $YSortWorld/Characters/Players/Player
@onready var playerCamera: Camera2D = $YSortWorld/Characters/Players/Player/Camera2D @onready var playerCamera: Camera2D = $YSortWorld/Characters/Players/Player/Camera2D
@@ -35,18 +37,48 @@ var _inventoryPanel: PanelContainer
var _inventoryGrid: GridContainer var _inventoryGrid: GridContainer
var _dragSurface: Control var _dragSurface: Control
var _toastLabel: Label 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 _selectedDecorId: String = ""
var _draggedDecor: Sprite2D var _draggedDecor: Sprite2D
var _dragOffset: Vector2 = Vector2.ZERO var _dragOffset: Vector2 = Vector2.ZERO
var _dragOriginalItem: Dictionary = {}
var _decorItems: Dictionary = {} var _decorItems: Dictionary = {}
var _serverDecorItems: Dictionary = {}
var _decorNodes: Dictionary = {} var _decorNodes: Dictionary = {}
var _decorCollisionBodies: Dictionary = {} var _decorCollisionBodies: Dictionary = {}
var _remoteDecorDefinitions: Dictionary = {} var _remoteDecorDefinitions: Dictionary = {}
var _inventoryRequestInFlight: bool = false var _inventoryRequestInFlight: bool = false
var _inventoryRequestReportsErrors: 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: func _ready() -> void:
add_to_group("whaletown_escape_dismissible") add_to_group("whaletown_escape_dismissible")
_configure_visit_mode()
_leave_multiplayer_world() _leave_multiplayer_world()
_apply_spawn_point() _apply_spawn_point()
_configure_camera() _configure_camera()
@@ -59,6 +91,8 @@ func _ready() -> void:
set_process(false) set_process(false)
func _discover_destination() -> void: func _discover_destination() -> void:
if _visitorMode:
return
var destination_id := SceneManager.get_next_destination_id() var destination_id := SceneManager.get_next_destination_id()
if destination_id.is_empty(): if destination_id.is_empty():
destination_id = "personal_room" destination_id = "personal_room"
@@ -81,11 +115,15 @@ func _exit_tree() -> void:
saveManager.decor_save_succeeded.disconnect(_on_decor_save_succeeded) saveManager.decor_save_succeeded.disconnect(_on_decor_save_succeeded)
if saveManager.decor_save_failed.is_connected(_on_decor_save_failed): if saveManager.decor_save_failed.is_connected(_on_decor_save_failed):
saveManager.decor_save_failed.disconnect(_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: func _input(event: InputEvent) -> void:
if get_viewport().is_input_handled(): if get_viewport().is_input_handled():
return return
if event is InputEventMouseButton: if event is InputEventMouseButton:
if not _can_edit_layout():
return
var mouseEvent := event as InputEventMouseButton var mouseEvent := event as InputEventMouseButton
if mouseEvent.button_index != MOUSE_BUTTON_LEFT: if mouseEvent.button_index != MOUSE_BUTTON_LEFT:
return return
@@ -104,17 +142,23 @@ func _process(_delta: float) -> void:
_update_dragged_decor_position() _update_dragged_decor_position()
func is_escape_dismissible() -> bool: 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: func get_escape_priority() -> int:
return 500 return 500
func request_escape_close() -> void: func request_escape_close() -> void:
if _draggedDecor != null: if _draggedDecor != null:
_finish_decor_drag() _cancel_decor_drag()
return return
if is_instance_valid(_inventoryPanel): if is_instance_valid(_inventoryPanel) and _inventoryPanel.visible:
_inventoryPanel.visible = false _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: func _apply_spawn_point() -> void:
if player.has_scene_position_override: if player.has_scene_position_override:
@@ -126,6 +170,12 @@ func _apply_spawn_point() -> void:
marker = $Markers/DefaultSpawn marker = $Markers/DefaultSpawn
player.global_position = marker.global_position 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: func _configure_camera() -> void:
playerCamera.zoom = CAMERA_ZOOM playerCamera.zoom = CAMERA_ZOOM
playerCamera.position_smoothing_enabled = true playerCamera.position_smoothing_enabled = true
@@ -150,6 +200,8 @@ func _build_room_decor_requests() -> void:
add_child(_inventoryRequest) add_child(_inventoryRequest)
func _build_room_decor_ui() -> void: func _build_room_decor_ui() -> void:
_build_room_toolbar()
_build_unsaved_exit_dialog()
_inventoryPanel = PanelContainer.new() _inventoryPanel = PanelContainer.new()
_inventoryPanel.visible = false _inventoryPanel.visible = false
_inventoryPanel.custom_minimum_size = Vector2(560, 560) _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) _inventoryGrid.add_theme_constant_override("v_separation", 14)
scroll.add_child(_inventoryGrid) 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 = Label.new()
_toastLabel.visible = false _toastLabel.visible = false
_toastLabel.offset_left = 360 _toastLabel.offset_left = 360
@@ -257,6 +296,146 @@ func _build_room_decor_ui() -> void:
_dragSurface.gui_input.connect(_on_drag_surface_gui_input) _dragSurface.gui_input.connect(_on_drag_surface_gui_input)
uiLayer.add_child(_dragSurface) 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: func _connect_room_decor_events() -> void:
var eventSystem := _get_event_system() var eventSystem := _get_event_system()
if eventSystem == null: if eventSystem == null:
@@ -264,18 +443,27 @@ func _connect_room_decor_events() -> void:
else: else:
eventSystem.call("connect_event", EventNames.HUD_BACKPACK_TOGGLE, _on_backpack_toggle_requested, self) eventSystem.call("connect_event", EventNames.HUD_BACKPACK_TOGGLE, _on_backpack_toggle_requested, self)
var saveManager := get_node_or_null("/root/RoomDecorSaveManager") 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): if not saveManager.decor_save_succeeded.is_connected(_on_decor_save_succeeded):
saveManager.decor_save_succeeded.connect(_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): if not saveManager.decor_save_failed.is_connected(_on_decor_save_failed):
saveManager.decor_save_failed.connect(_on_decor_save_failed) saveManager.decor_save_failed.connect(_on_decor_save_failed)
func _on_backpack_toggle_requested(_data: Variant = null) -> void: func _on_backpack_toggle_requested(_data: Variant = null) -> void:
if _visitorMode:
_show_toast("参观模式不能编辑布局")
return
if not _editMode:
_show_toast("请先点击“编辑布局”")
return
_toggle_inventory_panel() _toggle_inventory_panel()
func _toggle_inventory_panel() -> void: func _toggle_inventory_panel() -> void:
if not _can_edit_layout():
_show_toast("请先进入布局编辑")
return
_inventoryPanel.visible = not _inventoryPanel.visible _inventoryPanel.visible = not _inventoryPanel.visible
if _inventoryPanel.visible: if _inventoryPanel.visible and not _inventoryLoaded:
_fetch_room_decor_inventory(true) _fetch_room_decor_inventory(true)
func _fetch_room_decor_inventory(reportErrors: bool = false) -> void: func _fetch_room_decor_inventory(reportErrors: bool = false) -> void:
@@ -289,7 +477,8 @@ func _fetch_room_decor_inventory(reportErrors: bool = false) -> void:
return return
_inventoryRequestInFlight = true _inventoryRequestInFlight = true
_inventoryRequestReportsErrors = reportErrors _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: if err != OK:
_inventoryRequestInFlight = false _inventoryRequestInFlight = false
if _inventoryRequestReportsErrors: if _inventoryRequestReportsErrors:
@@ -324,7 +513,16 @@ func _on_inventory_request_completed(result: int, responseCode: int, _headers: P
_apply_inventory_payload(dataVariant as Dictionary) _apply_inventory_payload(dataVariant as Dictionary)
func _apply_inventory_payload(data: Dictionary) -> void: func _apply_inventory_payload(data: Dictionary) -> void:
if _editMode and not _dirtyDecorIds.is_empty():
return
_decorItems.clear() _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", [])) _apply_remote_decor_definitions(data.get("definitions", []))
var itemsVariant: Variant = data.get("items", []) var itemsVariant: Variant = data.get("items", [])
if itemsVariant is Array: if itemsVariant is Array:
@@ -334,13 +532,23 @@ func _apply_inventory_payload(data: Dictionary) -> void:
var decorId := str(item.get("decor_id", "")).strip_edges() var decorId := str(item.get("decor_id", "")).strip_edges()
if not decorId.is_empty(): if not decorId.is_empty():
_decorItems[decorId] = _merge_decor_definition(item) _decorItems[decorId] = _merge_decor_definition(item)
if not _visitorMode:
_serverDecorItems = _duplicate_item_map(_decorItems)
_dirtyDecorIds.clear()
_saveFailedIds.clear()
_inventoryLoaded = true
_render_inventory_list() _render_inventory_list()
_render_placed_decors() _render_placed_decors()
_update_editor_toolbar()
func _apply_local_empty_inventory() -> void: func _apply_local_empty_inventory() -> void:
_decorItems.clear() _decorItems.clear()
_serverDecorItems.clear()
_dirtyDecorIds.clear()
_inventoryLoaded = true
_render_inventory_list() _render_inventory_list()
_render_placed_decors() _render_placed_decors()
_update_editor_toolbar()
func _merge_decor_definition(item: Dictionary) -> Dictionary: func _merge_decor_definition(item: Dictionary) -> Dictionary:
var decorId := str(item.get("decor_id", "")).strip_edges() var decorId := str(item.get("decor_id", "")).strip_edges()
@@ -370,6 +578,8 @@ func _apply_remote_decor_definitions(definitionsVariant: Variant) -> void:
_remoteDecorDefinitions[decorId] = definition _remoteDecorDefinitions[decorId] = definition
func _render_inventory_list() -> void: func _render_inventory_list() -> void:
if _visitorMode:
return
_clear_children(_inventoryGrid) _clear_children(_inventoryGrid)
if _decorItems.is_empty(): if _decorItems.is_empty():
var empty := _create_empty_inventory_card() var empty := _create_empty_inventory_card()
@@ -471,11 +681,13 @@ func _render_placed_decors() -> void:
var item: Dictionary = _decorItems[decorId] var item: Dictionary = _decorItems[decorId]
if bool(item.get("placed", false)): if bool(item.get("placed", false)):
_ensure_decor_node(str(decorId), item) _ensure_decor_node(str(decorId), item)
_refresh_decor_selection()
func _begin_inventory_decor_drag(decorId: String) -> void: func _begin_inventory_decor_drag(decorId: String) -> void:
if not _decorItems.has(decorId): if not _can_edit_layout() or not _decorItems.has(decorId):
return return
_selectedDecorId = decorId _selectedDecorId = decorId
_dragOriginalItem = (_decorItems[decorId] as Dictionary).duplicate(true)
var item := _ensure_decor_placed_for_drag(decorId) var item := _ensure_decor_placed_for_drag(decorId)
var node := _ensure_decor_node(decorId, item) var node := _ensure_decor_node(decorId, item)
if node == null: if node == null:
@@ -487,6 +699,7 @@ func _begin_inventory_decor_drag(decorId: String) -> void:
_inventoryPanel.visible = false _inventoryPanel.visible = false
_enable_drag_surface() _enable_drag_surface()
_update_dragged_decor_position() _update_dragged_decor_position()
_refresh_decor_selection()
_pulse_node(_draggedDecor) _pulse_node(_draggedDecor)
func _ensure_decor_placed_for_drag(decorId: String) -> Dictionary: 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_x"] = defaultPosition.x
item["position_y"] = defaultPosition.y item["position_y"] = defaultPosition.y
item["scale"] = _get_item_float(item, "default_scale", 1.0) 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) item["z_index"] = _get_item_int(item, "default_z_index", 0)
_decorItems[decorId] = item _decorItems[decorId] = item
return 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)) 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)) var itemScale := _get_item_float(item, "scale", _get_item_float(item, "default_scale", 1.0))
node.scale = Vector2(itemScale, itemScale) 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)) node.z_index = _get_item_int(item, "z_index", _get_item_int(item, "default_z_index", 0))
_sync_decor_collision(decorId, item) _sync_decor_collision(decorId, item)
return node return node
func _try_begin_decor_drag(worldPosition: Vector2) -> bool: func _try_begin_decor_drag(worldPosition: Vector2) -> bool:
if not _can_edit_layout():
return false
var bestNode: Sprite2D = null var bestNode: Sprite2D = null
var bestZ := -100000 var bestZ := -100000
for decorId in _decorNodes.keys(): for decorId in _decorNodes.keys():
@@ -537,10 +754,13 @@ func _try_begin_decor_drag(worldPosition: Vector2) -> bool:
return false return false
_draggedDecor = bestNode _draggedDecor = bestNode
_selectedDecorId = str(bestNode.get_meta("decor_id", "")) _selectedDecorId = str(bestNode.get_meta("decor_id", ""))
_dragOriginalItem = (_decorItems.get(_selectedDecorId, {}) as Dictionary).duplicate(true)
_dragOffset = bestNode.global_position - worldPosition _dragOffset = bestNode.global_position - worldPosition
_set_decor_collision_disabled(_selectedDecorId, true) _set_decor_collision_disabled(_selectedDecorId, true)
_draggedDecor.z_index = max(_draggedDecor.z_index, 50) _draggedDecor.z_index = max(_draggedDecor.z_index, 50)
_enable_drag_surface() _enable_drag_surface()
_refresh_decor_selection()
_update_editor_toolbar()
return true return true
func _finish_decor_drag() -> void: func _finish_decor_drag() -> void:
@@ -550,23 +770,30 @@ func _finish_decor_drag() -> void:
var decorId := str(_draggedDecor.get_meta("decor_id", "")) var decorId := str(_draggedDecor.get_meta("decor_id", ""))
if _decorItems.has(decorId): if _decorItems.has(decorId):
var item: Dictionary = _decorItems[decorId] var item: Dictionary = _decorItems[decorId]
item["position_x"] = _draggedDecor.global_position.x var snapped_position := _snap_to_room_grid(_draggedDecor.global_position)
item["position_y"] = _draggedDecor.global_position.y _draggedDecor.global_position = snapped_position
item["position_x"] = snapped_position.x
item["position_y"] = snapped_position.y
item["placed"] = true item["placed"] = true
item["scale"] = _draggedDecor.scale.x item["scale"] = _draggedDecor.scale.x
item["rotation_degrees"] = _draggedDecor.rotation_degrees
item["z_index"] = _get_item_int(item, "default_z_index", 0) item["z_index"] = _get_item_int(item, "default_z_index", 0)
_decorItems[decorId] = item _decorItems[decorId] = item
_draggedDecor.z_index = _get_item_int(item, "z_index", 0) _draggedDecor.z_index = _get_item_int(item, "z_index", 0)
_sync_decor_collision(decorId, item) _sync_decor_collision(decorId, item)
_set_decor_collision_disabled(decorId, false) _set_decor_collision_disabled(decorId, false)
_save_decor_item(item) _record_item_change(_dragOriginalItem, item)
_mark_decor_dirty(decorId)
_render_inventory_list() _render_inventory_list()
_draggedDecor = null _draggedDecor = null
_dragOriginalItem.clear()
_disable_drag_surface() _disable_drag_surface()
_refresh_decor_selection()
_update_editor_toolbar()
func _update_dragged_decor_position() -> void: func _update_dragged_decor_position() -> void:
if _draggedDecor != null: 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", "")) var decorId := str(_draggedDecor.get_meta("decor_id", ""))
if not decorId.is_empty() and _decorItems.has(decorId): if not decorId.is_empty() and _decorItems.has(decorId):
var item: Dictionary = _decorItems[decorId] var item: Dictionary = _decorItems[decorId]
@@ -574,6 +801,21 @@ func _update_dragged_decor_position() -> void:
item["position_y"] = _draggedDecor.global_position.y item["position_y"] = _draggedDecor.global_position.y
_sync_decor_collision(decorId, item) _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: func _enable_drag_surface() -> void:
if is_instance_valid(_dragSurface): if is_instance_valid(_dragSurface):
_dragSurface.visible = true _dragSurface.visible = true
@@ -618,10 +860,14 @@ func _control_blocks_pointer(control: Control, mousePosition: Vector2) -> bool:
return false return false
func _remove_selected_decor() -> void: func _remove_selected_decor() -> void:
if not _can_edit_layout():
_show_toast("请先进入编辑布局")
return
if _selectedDecorId.is_empty() or not _decorItems.has(_selectedDecorId): if _selectedDecorId.is_empty() or not _decorItems.has(_selectedDecorId):
_show_toast("请先选择一个家具") _show_toast("请先选择一个家具")
return return
var item: Dictionary = _decorItems[_selectedDecorId] var item: Dictionary = _decorItems[_selectedDecorId]
var before: Dictionary = item.duplicate(true)
item["placed"] = false item["placed"] = false
_decorItems[_selectedDecorId] = item _decorItems[_selectedDecorId] = item
if _decorNodes.has(_selectedDecorId): if _decorNodes.has(_selectedDecorId):
@@ -630,28 +876,433 @@ func _remove_selected_decor() -> void:
node.queue_free() node.queue_free()
_decorNodes.erase(_selectedDecorId) _decorNodes.erase(_selectedDecorId)
_remove_decor_collision(_selectedDecorId) _remove_decor_collision(_selectedDecorId)
_save_decor_item(item) _record_item_change(before, item)
_mark_decor_dirty(_selectedDecorId)
_render_inventory_list() _render_inventory_list()
_refresh_decor_selection()
_update_editor_toolbar()
_show_toast("已收回家具") _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: func _on_decor_save_succeeded(savedItem: Dictionary) -> void:
var item := _merge_decor_definition(savedItem.duplicate(true)) var item := _merge_decor_definition(savedItem.duplicate(true))
var decorId := str(item.get("decor_id", "")) var decorId := str(item.get("decor_id", ""))
if not decorId.is_empty(): if decorId.is_empty() or not _saveAwaitingIds.has(decorId):
_decorItems[decorId] = item 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: func _can_edit_layout() -> bool:
_show_toast(message) 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: func _is_point_inside_sprite(node: Sprite2D, worldPosition: Vector2) -> bool:
if node.texture == null: if node.texture == null:
@@ -689,7 +1340,9 @@ func _sync_decor_collision(decorId: String, item: Dictionary) -> void:
shapeNode.shape = RectangleShape2D.new() shapeNode.shape = RectangleShape2D.new()
body.add_child(shapeNode) body.add_child(shapeNode)
var scale := _get_item_float(item, "scale", _get_item_float(item, "default_scale", 1.0)) 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 shapeNode.position = Vector2.ZERO
var rectShape := shapeNode.shape as RectangleShape2D var rectShape := shapeNode.shape as RectangleShape2D
if rectShape == null: if rectShape == null:

View File

@@ -32,6 +32,11 @@ func _change_scene() -> void:
if targetSceneName.is_empty(): if targetSceneName.is_empty():
push_warning("ScenePortal: targetSceneName is empty.") push_warning("ScenePortal: targetSceneName is empty.")
return 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(): if not targetSpawnName.is_empty():
SceneManager.set_next_spawn_name(targetSpawnName) SceneManager.set_next_spawn_name(targetSpawnName)

View File

@@ -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)) dot.add_theme_stylebox_override("panel", _create_dot_style(ONLINE_COLOR if online else OFFLINE_COLOR))
row.add_child(dot) 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 return button
func _create_request_row(request: Dictionary) -> Control: func _create_request_row(request: Dictionary) -> Control:
@@ -417,6 +430,13 @@ func _select_friend(userId: String, username: String, _online: bool) -> void:
"username": username "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: func _respond_request(userId: String, username: String, accepted: bool) -> void:
if userId.strip_edges().is_empty(): if userId.strip_edges().is_empty():
return return

View File

@@ -436,6 +436,8 @@ func _on_travel_authorized(success: bool, destination: Dictionary) -> void:
_render_markers() _render_markers()
return return
set_panel_open(false) 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_destination_id(str(destination.get("id", "")))
SceneManager.set_next_scene_position(position) SceneManager.set_next_scene_position(position)
SceneManager.change_scene(sceneName) SceneManager.change_scene(sceneName)

View File

@@ -47,6 +47,7 @@ const DEFAULT_SETTINGS: Dictionary = {
"allow_nearby_private": true, "allow_nearby_private": true,
"allow_nearby_friend_requests": true, "allow_nearby_friend_requests": true,
"allow_nearby_profile": true, "allow_nearby_profile": true,
"room_visit_policy": "friends",
"mute_ui_sfx": false, "mute_ui_sfx": false,
} }
@@ -388,6 +389,9 @@ func _render_chat_content() -> void:
_create_toggle_row("chat", "允许附近私聊", "allow_nearby_private", "附近玩家按 E 可以发起悄悄话"), _create_toggle_row("chat", "允许附近私聊", "allow_nearby_private", "附近玩家按 E 可以发起悄悄话"),
_create_toggle_row("account", "允许好友申请", "allow_nearby_friend_requests", "附近玩家可通过互动列表发送好友申请"), _create_toggle_row("account", "允许好友申请", "allow_nearby_friend_requests", "附近玩家可通过互动列表发送好友申请"),
])) ]))
_content.add_child(_create_section("个人空间访问", [
_create_room_visit_policy_row(),
]))
func _render_controls_content() -> void: func _render_controls_content() -> void:
_content.add_child(_create_section("操作说明", [ _content.add_child(_create_section("操作说明", [
@@ -526,6 +530,58 @@ func _create_toggle_row(iconName: String, labelText: String, key: String, detail
return row 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: func _create_scale_row() -> Control:
var row := HBoxContainer.new() var row := HBoxContainer.new()
row.custom_minimum_size = Vector2(0, 48) row.custom_minimum_size = Vector2(0, 48)