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

@@ -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:

View File

@@ -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)