Files
whale-town-front-v2/scenes/Maps/PersonalSpace.gd
2026-07-22 14:20:18 +08:00

1587 lines
62 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
class_name PersonalSpace
extends Node2D
# ============================================================================
# PersonalSpace.gd - 个人空间场景控制器
# ============================================================================
# 独立于广场的私人房间空间。当前版本负责固定镜头、玩家出生点和
# 后续房间 manifest/装修系统的场景承载。
# ============================================================================
const CAMERA_ZOOM: Vector2 = Vector2(1.65, 1.65)
const CAMERA_LIMIT_LEFT: int = -538
const CAMERA_LIMIT_TOP: int = -358
const CAMERA_LIMIT_RIGHT: int = 538
const CAMERA_LIMIT_BOTTOM: int = 358
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
const TEXT_COLOR: Color = Color(0.102, 0.224, 0.376)
const PANEL_COLOR: Color = Color(0.970, 0.992, 1.0, 0.96)
const ACCENT_COLOR: Color = Color(0.086, 0.608, 0.922)
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 FLOOR_PLACE_BOUNDS: Rect2 = Rect2(-426.0, -176.0, 852.0, 388.0)
const WALL_PLACE_BOUNDS: Rect2 = Rect2(-378.0, -258.0, 756.0, 66.0)
@onready var player: PlayerController = $YSortWorld/Characters/Players/Player
@onready var playerCamera: Camera2D = $YSortWorld/Characters/Players/Player/Camera2D
@onready var uiLayer: CanvasLayer = $UILayer
@onready var ySortWorld: Node2D = $YSortWorld
@onready var staticCollision: Node2D = $StaticCollision
var _decorLayer: Node2D
var _gridOverlay: Node2D
var _inventoryRequest: HTTPRequest
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 _gridButton: 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 _showPlacementGrid: 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()
_build_decor_layer()
_build_room_decor_requests()
_build_room_decor_ui()
_connect_room_decor_events()
_fetch_room_decor_inventory()
_discover_destination()
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"
var socialManager := get_node_or_null("/root/SocialManager")
if socialManager != null and socialManager.has_method("discover_destination"):
socialManager.call("discover_destination", destination_id)
func _leave_multiplayer_world() -> void:
var chatManager := get_node_or_null("/root/ChatManager")
if chatManager != null and chatManager.has_method("leave_world"):
chatManager.call("leave_world", "personal_space")
func _exit_tree() -> void:
var eventSystem := _get_event_system()
if eventSystem != null:
eventSystem.call("disconnect_event", EventNames.HUD_BACKPACK_TOGGLE, _on_backpack_toggle_requested, self)
var saveManager := get_node_or_null("/root/RoomDecorSaveManager")
if saveManager != null:
if saveManager.decor_save_succeeded.is_connected(_on_decor_save_succeeded):
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
if _draggedDecor != null and not mouseEvent.pressed:
_finish_decor_drag()
get_viewport().set_input_as_handled()
elif _draggedDecor == null and mouseEvent.pressed and not _is_pointer_over_blocking_ui():
if _try_begin_decor_drag(get_global_mouse_position()):
get_viewport().set_input_as_handled()
elif event is InputEventMouseMotion and _draggedDecor != null:
_update_dragged_decor_position()
get_viewport().set_input_as_handled()
func _process(_delta: float) -> void:
if _draggedDecor != null:
_update_dragged_decor_position()
func is_escape_dismissible() -> bool:
return _draggedDecor != null or (is_instance_valid(_inventoryPanel) and _inventoryPanel.visible) 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:
_cancel_decor_drag()
return
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:
return
var spawnName: String = SceneManager.get_next_spawn_name()
var markerName: String = spawnName if not spawnName.is_empty() else "DefaultSpawn"
var marker := $Markers.get_node_or_null(markerName) as Marker2D
if marker == null:
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
playerCamera.limit_left = CAMERA_LIMIT_LEFT
playerCamera.limit_top = CAMERA_LIMIT_TOP
playerCamera.limit_right = CAMERA_LIMIT_RIGHT
playerCamera.limit_bottom = CAMERA_LIMIT_BOTTOM
playerCamera.limit_smoothed = true
func _build_decor_layer() -> void:
_decorLayer = Node2D.new()
_decorLayer.name = "RoomDecorLayer"
_decorLayer.y_sort_enabled = true
ySortWorld.add_child(_decorLayer)
ySortWorld.move_child(_decorLayer, 0)
_build_placement_grid()
func _build_placement_grid() -> void:
_gridOverlay = Node2D.new()
_gridOverlay.name = "RoomPlacementGrid"
_gridOverlay.z_index = -30
_gridOverlay.visible = false
ySortWorld.add_child(_gridOverlay)
ySortWorld.move_child(_gridOverlay, 0)
_rebuild_placement_grid()
func _rebuild_placement_grid() -> void:
if _gridOverlay == null:
return
_clear_children(_gridOverlay)
_add_grid_zone(_placement_bounds_for_surface("floor"), Color(0.050, 0.560, 0.900, 0.20), Color(0.050, 0.560, 0.900, 0.62))
_add_grid_zone(_placement_bounds_for_surface("wall"), Color(0.620, 0.330, 0.860, 0.20), Color(0.620, 0.330, 0.860, 0.62))
func _add_grid_zone(bounds: Rect2, line_color: Color, edge_color: Color) -> void:
if _gridOverlay == null:
return
var x: float = ceilf(bounds.position.x / ROOM_GRID_SIZE) * ROOM_GRID_SIZE
while x < bounds.end.x:
_add_grid_line(Vector2(x, bounds.position.y), Vector2(x, bounds.end.y), line_color, 1.0)
x += ROOM_GRID_SIZE
var y: float = ceilf(bounds.position.y / ROOM_GRID_SIZE) * ROOM_GRID_SIZE
while y < bounds.end.y:
_add_grid_line(Vector2(bounds.position.x, y), Vector2(bounds.end.x, y), line_color, 1.0)
y += ROOM_GRID_SIZE
_add_grid_line(bounds.position, Vector2(bounds.end.x, bounds.position.y), edge_color, 2.0)
_add_grid_line(Vector2(bounds.end.x, bounds.position.y), bounds.end, edge_color, 2.0)
_add_grid_line(bounds.end, Vector2(bounds.position.x, bounds.end.y), edge_color, 2.0)
_add_grid_line(Vector2(bounds.position.x, bounds.end.y), bounds.position, edge_color, 2.0)
func _add_grid_line(from: Vector2, to: Vector2, color: Color, width: float) -> void:
var line := Line2D.new()
line.points = PackedVector2Array([from, to])
line.default_color = color
line.width = width
line.antialiased = true
_gridOverlay.add_child(line)
func _build_room_decor_requests() -> void:
_inventoryRequest = HTTPRequest.new()
_inventoryRequest.name = "roomDecorInventoryRequest"
_inventoryRequest.timeout = 12.0
_inventoryRequest.request_completed.connect(_on_inventory_request_completed)
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)
_inventoryPanel.offset_left = 24
_inventoryPanel.offset_top = 116
_inventoryPanel.offset_right = 584
_inventoryPanel.offset_bottom = 676
_inventoryPanel.mouse_filter = Control.MOUSE_FILTER_STOP
_inventoryPanel.add_theme_stylebox_override("panel", _create_panel_style(PANEL_COLOR, 26, true))
uiLayer.add_child(_inventoryPanel)
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 24)
margin.add_theme_constant_override("margin_top", 22)
margin.add_theme_constant_override("margin_right", 24)
margin.add_theme_constant_override("margin_bottom", 22)
_inventoryPanel.add_child(margin)
var root := VBoxContainer.new()
root.add_theme_constant_override("separation", 14)
margin.add_child(root)
var header := HBoxContainer.new()
header.alignment = BoxContainer.ALIGNMENT_CENTER
header.add_theme_constant_override("separation", 12)
root.add_child(header)
var titleBox := VBoxContainer.new()
titleBox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
titleBox.add_theme_constant_override("separation", 2)
header.add_child(titleBox)
var title := Label.new()
title.text = "背包"
title.add_theme_font_size_override("font_size", 28)
title.add_theme_color_override("font_color", TEXT_COLOR)
titleBox.add_child(title)
var hint := Label.new()
hint.text = "按住装饰品,拖到房间里松手摆放。"
hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
hint.add_theme_font_size_override("font_size", 16)
hint.add_theme_color_override("font_color", MUTED_COLOR)
titleBox.add_child(hint)
var closeButton := Button.new()
closeButton.text = "×"
closeButton.custom_minimum_size = Vector2(48, 48)
closeButton.focus_mode = Control.FOCUS_NONE
closeButton.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
closeButton.add_theme_font_size_override("font_size", 30)
closeButton.add_theme_color_override("font_color", Color.WHITE)
closeButton.add_theme_stylebox_override("normal", _create_panel_style(ACCENT_COLOR, 18, false))
closeButton.add_theme_stylebox_override("hover", _create_panel_style(Color(0.150, 0.680, 1.0, 1.0), 18, false))
closeButton.add_theme_stylebox_override("pressed", _create_panel_style(Color(0.060, 0.420, 0.780, 1.0), 18, false))
closeButton.pressed.connect(func() -> void:
_inventoryPanel.visible = false
)
header.add_child(closeButton)
var scroll := ScrollContainer.new()
scroll.custom_minimum_size = Vector2(0, 370)
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
root.add_child(scroll)
_inventoryGrid = GridContainer.new()
_inventoryGrid.columns = 2
_inventoryGrid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_inventoryGrid.add_theme_constant_override("h_separation", 14)
_inventoryGrid.add_theme_constant_override("v_separation", 14)
scroll.add_child(_inventoryGrid)
_toastLabel = Label.new()
_toastLabel.visible = false
_toastLabel.offset_left = 360
_toastLabel.offset_top = 100
_toastLabel.offset_right = 760
_toastLabel.offset_bottom = 148
_toastLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_toastLabel.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_toastLabel.add_theme_font_size_override("font_size", 18)
_toastLabel.add_theme_color_override("font_color", Color.WHITE)
_toastLabel.add_theme_stylebox_override("normal", _create_panel_style(Color(0.028, 0.160, 0.310, 0.90), 18, false))
uiLayer.add_child(_toastLabel)
_dragSurface = Control.new()
_dragSurface.name = "RoomDecorDragSurface"
_dragSurface.visible = false
_dragSurface.set_anchors_preset(Control.PRESET_FULL_RECT)
_dragSurface.mouse_filter = Control.MOUSE_FILTER_STOP
_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)
_gridButton = _create_toolbar_button("网格:关", _toggle_placement_grid)
_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(_gridButton)
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:
push_warning("PersonalSpace: EventSystem autoload is not available.")
else:
eventSystem.call("connect_event", EventNames.HUD_BACKPACK_TOGGLE, _on_backpack_toggle_requested, self)
var saveManager := get_node_or_null("/root/RoomDecorSaveManager")
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 and not _inventoryLoaded:
_fetch_room_decor_inventory(true)
func _fetch_room_decor_inventory(reportErrors: bool = false) -> void:
if _inventoryRequestInFlight:
_inventoryRequestReportsErrors = _inventoryRequestReportsErrors or reportErrors
return
if not _is_authenticated():
_apply_local_empty_inventory()
if reportErrors:
_show_toast("请先登录后使用家具背包")
return
_inventoryRequestInFlight = true
_inventoryRequestReportsErrors = reportErrors
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:
_show_toast("家具背包请求发送失败")
_inventoryRequestReportsErrors = false
func _on_inventory_request_completed(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
_inventoryRequestInFlight = false
var reportErrors := _inventoryRequestReportsErrors
_inventoryRequestReportsErrors = false
if result != HTTPRequest.RESULT_SUCCESS or responseCode < 200 or responseCode >= 300:
if reportErrors:
_show_toast("家具背包读取失败")
return
var json := JSON.new()
if json.parse(body.get_string_from_utf8()) != OK:
if reportErrors:
_show_toast("家具背包响应解析失败")
return
var responseVariant: Variant = json.data
if not (responseVariant is Dictionary):
if reportErrors:
_show_toast("家具背包响应格式错误")
return
var response: Dictionary = responseVariant
if not bool(response.get("success", true)):
if reportErrors:
_show_toast(str(response.get("message", "家具背包读取失败")))
return
var dataVariant: Variant = response.get("data", {})
if dataVariant is Dictionary:
_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:
for itemVariant in itemsVariant:
if itemVariant is Dictionary:
var item: Dictionary = (itemVariant as Dictionary).duplicate(true)
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()
var definition: Dictionary = _remoteDecorDefinitions.get(decorId, {})
for key in definition.keys():
if not item.has(key):
item[key] = definition[key]
return item
func _apply_remote_decor_definitions(definitionsVariant: Variant) -> void:
_remoteDecorDefinitions.clear()
if not (definitionsVariant is Array):
return
for definitionVariant in definitionsVariant:
if not (definitionVariant is Dictionary):
continue
var definition: Dictionary = (definitionVariant as Dictionary).duplicate(true)
var decorId := str(definition.get("decor_id", "")).strip_edges()
if decorId.is_empty():
continue
if definition.get("default_position", null) is Dictionary:
var position: Dictionary = definition.get("default_position")
definition["default_position"] = Vector2(
_to_float(position.get("x", 0.0), 0.0),
_to_float(position.get("y", 0.0), 0.0)
)
_remoteDecorDefinitions[decorId] = definition
_rebuild_placement_grid()
func _render_inventory_list() -> void:
if _visitorMode:
return
_clear_children(_inventoryGrid)
if _decorItems.is_empty():
var empty := _create_empty_inventory_card()
_inventoryGrid.add_child(empty)
return
for decorId in _decorItems.keys():
_inventoryGrid.add_child(_create_inventory_card(str(decorId)))
func _create_empty_inventory_card() -> Control:
var card := PanelContainer.new()
card.custom_minimum_size = Vector2(500, 132)
card.add_theme_stylebox_override("panel", _create_panel_style(Color(1, 1, 1, 0.76), 20, false))
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 18)
margin.add_theme_constant_override("margin_top", 18)
margin.add_theme_constant_override("margin_right", 18)
margin.add_theme_constant_override("margin_bottom", 18)
card.add_child(margin)
var label := Label.new()
label.text = "背包里还没有装饰品。去商城的空间分类购买后会出现在这里。"
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
label.add_theme_font_size_override("font_size", 17)
label.add_theme_color_override("font_color", MUTED_COLOR)
margin.add_child(label)
return card
func _create_inventory_card(decorId: String) -> Button:
var item: Dictionary = _decorItems[decorId]
var card := Button.new()
card.custom_minimum_size = Vector2(240, 176)
card.focus_mode = Control.FOCUS_NONE
card.mouse_default_cursor_shape = Control.CURSOR_DRAG
card.add_theme_stylebox_override("normal", _create_inventory_card_style(false))
card.add_theme_stylebox_override("hover", _create_inventory_card_style(true))
card.add_theme_stylebox_override("pressed", _create_inventory_card_style(true))
card.add_theme_stylebox_override("focus", StyleBoxEmpty.new())
card.button_down.connect(func() -> void:
_begin_inventory_decor_drag(decorId)
)
var margin := MarginContainer.new()
margin.set_anchors_preset(Control.PRESET_FULL_RECT)
margin.add_theme_constant_override("margin_left", 12)
margin.add_theme_constant_override("margin_top", 12)
margin.add_theme_constant_override("margin_right", 12)
margin.add_theme_constant_override("margin_bottom", 12)
margin.mouse_filter = Control.MOUSE_FILTER_IGNORE
card.add_child(margin)
var content := VBoxContainer.new()
content.alignment = BoxContainer.ALIGNMENT_CENTER
content.add_theme_constant_override("separation", 6)
content.mouse_filter = Control.MOUSE_FILTER_IGNORE
margin.add_child(content)
var status := Label.new()
status.custom_minimum_size = Vector2(0, 22)
var surface_label := "墙面挂饰" if str(item.get("placement_surface", "floor")) == "wall" else "地面家具"
status.text = "%s · %s" % [surface_label, "已摆放" if bool(item.get("placed", false)) else "未摆放"]
status.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
status.add_theme_font_size_override("font_size", 14)
status.add_theme_color_override("font_color", ACCENT_COLOR if bool(item.get("placed", false)) else MUTED_COLOR)
status.mouse_filter = Control.MOUSE_FILTER_IGNORE
content.add_child(status)
var icon := TextureRect.new()
icon.texture = _load_texture(str(item.get("icon", "")))
icon.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
icon.custom_minimum_size = Vector2(0, 82)
icon.expand_mode = TextureRect.EXPAND_FIT_WIDTH_PROPORTIONAL
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
content.add_child(icon)
var nameLabel := Label.new()
nameLabel.text = str(item.get("name", decorId))
nameLabel.custom_minimum_size = Vector2(0, 36)
nameLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
nameLabel.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
nameLabel.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
nameLabel.add_theme_font_size_override("font_size", 17)
nameLabel.add_theme_color_override("font_color", TEXT_COLOR)
nameLabel.mouse_filter = Control.MOUSE_FILTER_IGNORE
content.add_child(nameLabel)
return card
func _render_placed_decors() -> void:
for decorIdVariant in _decorNodes.keys().duplicate():
var decorId := str(decorIdVariant)
var item: Dictionary = _decorItems.get(decorId, {})
if item.is_empty() or not bool(item.get("placed", false)):
var node := _decorNodes[decorId] as Node
if is_instance_valid(node):
node.queue_free()
_decorNodes.erase(decorId)
_remove_decor_collision(decorId)
for decorId in _decorItems.keys():
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 _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:
return
_draggedDecor = node
_dragOffset = Vector2.ZERO
_set_decor_collision_disabled(decorId, true)
_draggedDecor.z_index = max(_draggedDecor.z_index, 50)
_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:
var item: Dictionary = _decorItems[decorId]
if not bool(item.get("placed", false)):
item["placed"] = true
var defaultPosition := _variant_to_vector2(item.get("default_position", Vector2.ZERO))
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
func _ensure_decor_node(decorId: String, item: Dictionary) -> Sprite2D:
var node := _decorNodes.get(decorId, null) as Sprite2D
if node == null or not is_instance_valid(node):
node = Sprite2D.new()
node.name = "Decor_%s" % decorId
node.texture_filter = DECOR_TEXTURE_FILTER
node.centered = true
node.set_meta("decor_id", decorId)
_decorLayer.add_child(node)
_decorNodes[decorId] = node
else:
node.texture_filter = DECOR_TEXTURE_FILTER
node.texture = _load_texture(str(item.get("texture", item.get("icon", ""))))
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():
var node := _decorNodes[decorId] as Sprite2D
if node == null or not is_instance_valid(node) or node.texture == null:
continue
if not _is_point_inside_sprite(node, worldPosition):
continue
if node.z_index >= bestZ:
bestNode = node
bestZ = node.z_index
if bestNode == null:
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:
if _draggedDecor == null:
_disable_drag_surface()
return
var decorId := str(_draggedDecor.get_meta("decor_id", ""))
if _decorItems.has(decorId):
var item: Dictionary = _decorItems[decorId]
var snapped_position := _snap_to_room_grid(_draggedDecor.global_position, item)
_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)
_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:
var decorId := str(_draggedDecor.get_meta("decor_id", ""))
if not decorId.is_empty() and _decorItems.has(decorId):
var item: Dictionary = _decorItems[decorId]
_draggedDecor.global_position = _constrain_room_position(get_global_mouse_position() + _dragOffset, item)
item["position_x"] = _draggedDecor.global_position.x
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
uiLayer.move_child(_dragSurface, uiLayer.get_child_count() - 1)
set_process(true)
func _disable_drag_surface() -> void:
if is_instance_valid(_dragSurface):
_dragSurface.visible = false
set_process(false)
func _on_drag_surface_gui_input(event: InputEvent) -> void:
if _draggedDecor == null:
_disable_drag_surface()
return
if event is InputEventMouseMotion:
_update_dragged_decor_position()
get_viewport().set_input_as_handled()
elif event is InputEventMouseButton:
var mouseEvent := event as InputEventMouseButton
if mouseEvent.button_index == MOUSE_BUTTON_LEFT and not mouseEvent.pressed:
_finish_decor_drag()
get_viewport().set_input_as_handled()
func _is_pointer_over_blocking_ui() -> bool:
var mousePosition := get_viewport().get_mouse_position()
for child in uiLayer.get_children():
if child == _dragSurface:
continue
if child is Control and _control_blocks_pointer(child as Control, mousePosition):
return true
return false
func _control_blocks_pointer(control: Control, mousePosition: Vector2) -> bool:
if not control.visible or not control.is_visible_in_tree():
return false
if control.mouse_filter != Control.MOUSE_FILTER_IGNORE and control.get_global_rect().has_point(mousePosition):
return true
for child in control.get_children():
if child is Control and _control_blocks_pointer(child as Control, mousePosition):
return true
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):
var node := _decorNodes[_selectedDecorId] as Node
if is_instance_valid(node):
node.queue_free()
_decorNodes.erase(_selectedDecorId)
_remove_decor_collision(_selectedDecorId)
_record_item_change(before, item)
_mark_decor_dirty(_selectedDecorId)
_render_inventory_list()
_refresh_decor_selection()
_update_editor_toolbar()
_show_toast("已收回家具")
func _on_decor_save_succeeded(savedItem: Dictionary) -> void:
var item := _merge_decor_definition(savedItem.duplicate(true))
var decorId := str(item.get("decor_id", ""))
if decorId.is_empty() or not _saveAwaitingIds.has(decorId):
return
_serverDecorItems[decorId] = item.duplicate(true)
_decorItems[decorId] = item.duplicate(true)
_saveAwaitingIds.erase(decorId)
_dirtyDecorIds.erase(decorId)
_saveFailedIds.erase(decorId)
_render_placed_decors()
_render_inventory_list()
_finalize_save_if_ready()
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
_showPlacementGrid = false
_update_placement_grid_visibility()
_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
_showPlacementGrid = false
_update_placement_grid_visibility()
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(_gridButton):
_gridButton.visible = _editMode
_gridButton.disabled = _saveInProgress
_gridButton.text = "网格:开" if _showPlacementGrid else "网格:关"
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 _toggle_placement_grid() -> void:
if not _can_edit_layout():
return
_showPlacementGrid = not _showPlacementGrid
_update_placement_grid_visibility()
_update_editor_toolbar()
_show_toast("已显示摆放网格(蓝色:地面,紫色:墙面)" if _showPlacementGrid else "已隐藏摆放网格")
func _update_placement_grid_visibility() -> void:
if is_instance_valid(_gridOverlay):
_gridOverlay.visible = _editMode and _showPlacementGrid
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
_hide_unsaved_dialog()
_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 _placement_bounds_for_item(item: Dictionary) -> Rect2:
var surface := str(item.get("placement_surface", "floor"))
return _placement_bounds_from_variant(item.get("placement_bounds", null), WALL_PLACE_BOUNDS if surface == "wall" else FLOOR_PLACE_BOUNDS)
func _placement_bounds_for_surface(surface: String) -> Rect2:
for definition_variant in _remoteDecorDefinitions.values():
if not (definition_variant is Dictionary):
continue
var definition: Dictionary = definition_variant as Dictionary
if str(definition.get("placement_surface", "floor")) == surface:
return _placement_bounds_from_variant(definition.get("placement_bounds", null), WALL_PLACE_BOUNDS if surface == "wall" else FLOOR_PLACE_BOUNDS)
return WALL_PLACE_BOUNDS if surface == "wall" else FLOOR_PLACE_BOUNDS
func _placement_bounds_from_variant(value: Variant, fallback: Rect2) -> Rect2:
if not (value is Dictionary):
return fallback
var bounds: Dictionary = value as Dictionary
var min_x := _to_float(bounds.get("min_x", fallback.position.x), fallback.position.x)
var max_x := _to_float(bounds.get("max_x", fallback.end.x), fallback.end.x)
var min_y := _to_float(bounds.get("min_y", fallback.position.y), fallback.position.y)
var max_y := _to_float(bounds.get("max_y", fallback.end.y), fallback.end.y)
if min_x >= max_x or min_y >= max_y:
return fallback
return Rect2(min_x, min_y, max_x - min_x, max_y - min_y)
func _constrain_room_position(position: Vector2, item: Dictionary) -> Vector2:
var bounds := _placement_bounds_for_item(item)
return Vector2(
clampf(position.x, bounds.position.x, bounds.end.x),
clampf(position.y, bounds.position.y, bounds.end.y)
)
func _snap_to_room_grid(position: Vector2, item: Dictionary) -> Vector2:
var constrained := _constrain_room_position(position, item)
var snapped := Vector2(
roundf(constrained.x / ROOM_GRID_SIZE) * ROOM_GRID_SIZE,
roundf(constrained.y / ROOM_GRID_SIZE) * ROOM_GRID_SIZE
)
return _constrain_room_position(snapped, item)
func _is_point_inside_sprite(node: Sprite2D, worldPosition: Vector2) -> bool:
if node.texture == null:
return false
var localPoint := node.to_local(worldPosition)
var size := node.texture.get_size()
var rect := Rect2(-size * 0.5, size)
return rect.has_point(localPoint)
func _sync_decor_collision(decorId: String, item: Dictionary) -> void:
var collisionSize := _variant_to_vector2(item.get("collision_size", Vector2.ZERO))
if collisionSize == Vector2.ZERO:
_remove_decor_collision(decorId)
return
var collisionOffset := _variant_to_vector2(item.get("collision_offset", Vector2.ZERO))
var body := _decorCollisionBodies.get(decorId, null) as StaticBody2D
var shapeNode: CollisionShape2D
if body == null or not is_instance_valid(body):
body = StaticBody2D.new()
body.name = "DecorCollision_%s" % decorId
body.collision_layer = DECOR_COLLISION_LAYER
body.collision_mask = DECOR_COLLISION_MASK
shapeNode = CollisionShape2D.new()
shapeNode.name = "CollisionShape2D"
var rectShape := RectangleShape2D.new()
shapeNode.shape = rectShape
body.add_child(shapeNode)
staticCollision.add_child(body)
_decorCollisionBodies[decorId] = body
else:
shapeNode = body.get_node_or_null("CollisionShape2D") as CollisionShape2D
if shapeNode == null:
shapeNode = CollisionShape2D.new()
shapeNode.name = "CollisionShape2D"
shapeNode.shape = RectangleShape2D.new()
body.add_child(shapeNode)
var scale := _get_item_float(item, "scale", _get_item_float(item, "default_scale", 1.0))
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:
rectShape = RectangleShape2D.new()
shapeNode.shape = rectShape
rectShape.size = Vector2(max(8.0, collisionSize.x * scale), max(8.0, collisionSize.y * scale))
func _set_decor_collision_disabled(decorId: String, disabled: bool) -> void:
var body := _decorCollisionBodies.get(decorId, null) as StaticBody2D
if body == null or not is_instance_valid(body):
return
var shapeNode := body.get_node_or_null("CollisionShape2D") as CollisionShape2D
if shapeNode != null:
shapeNode.disabled = disabled
func _remove_decor_collision(decorId: String) -> void:
var body := _decorCollisionBodies.get(decorId, null) as Node
if body != null and is_instance_valid(body):
body.queue_free()
_decorCollisionBodies.erase(decorId)
func _variant_to_vector2(value: Variant) -> Vector2:
if value is Vector2:
return value
if value is Dictionary:
var dict: Dictionary = value
return Vector2(_to_float(dict.get("x", 0.0), 0.0), _to_float(dict.get("y", 0.0), 0.0))
return Vector2.ZERO
func _to_float(value: Variant, fallback: float) -> float:
match typeof(value):
TYPE_FLOAT:
return value
TYPE_INT:
return value
TYPE_STRING:
var text := str(value).strip_edges()
return fallback if text.is_empty() else text.to_float()
TYPE_BOOL:
return 1.0 if bool(value) else 0.0
_:
return fallback
func _to_int(value: Variant, fallback: int) -> int:
match typeof(value):
TYPE_INT:
return value
TYPE_FLOAT:
return roundi(value)
TYPE_STRING:
var text := str(value).strip_edges()
return fallback if text.is_empty() else text.to_int()
TYPE_BOOL:
return 1 if bool(value) else 0
_:
return fallback
func _get_item_float(item: Dictionary, key: String, fallback: float) -> float:
return _to_float(item.get(key, fallback), fallback)
func _get_item_int(item: Dictionary, key: String, fallback: int) -> int:
return _to_int(item.get(key, fallback), fallback)
func _is_authenticated() -> bool:
var authManager := get_node_or_null("/root/AuthManager")
return authManager != null and authManager.has_method("is_authenticated") and bool(authManager.call("is_authenticated"))
func _auth_headers() -> PackedStringArray:
var authManager := get_node_or_null("/root/AuthManager")
var accessToken := str(authManager.call("get_access_token")).strip_edges() if authManager != null and authManager.has_method("get_access_token") else ""
return PackedStringArray([
"Content-Type: application/json",
"Authorization: Bearer %s" % accessToken,
])
func _load_texture(path: String) -> Texture2D:
if path.is_empty():
return null
if FileAccess.file_exists("%s.import" % path):
var texture := load(path) as Texture2D
if texture != null:
return texture
var image := Image.load_from_file(ProjectSettings.globalize_path(path))
if image == null or image.is_empty():
return null
return ImageTexture.create_from_image(image)
func _get_event_system() -> Node:
return get_node_or_null("/root/EventSystem")
func _show_toast(message: String) -> void:
if not is_instance_valid(_toastLabel):
return
_toastLabel.text = message
_toastLabel.visible = true
var tween := create_tween()
tween.tween_interval(1.5)
tween.tween_callback(func() -> void:
if is_instance_valid(_toastLabel):
_toastLabel.visible = false
)
func _pulse_node(node: Node2D) -> void:
var originalScale := node.scale
var tween := create_tween()
tween.tween_property(node, "scale", originalScale * 1.08, 0.08)
tween.tween_property(node, "scale", originalScale, 0.10)
func _clear_children(node: Node) -> void:
for child in node.get_children():
child.queue_free()
func _create_panel_style(color: Color, radius: int, shadow: bool) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = color
style.corner_radius_top_left = radius
style.corner_radius_top_right = radius
style.corner_radius_bottom_left = radius
style.corner_radius_bottom_right = radius
style.content_margin_left = 12
style.content_margin_top = 8
style.content_margin_right = 12
style.content_margin_bottom = 8
if shadow:
style.shadow_color = Color(0.082, 0.243, 0.380, 0.18)
style.shadow_size = 12
style.shadow_offset = Vector2(0, 4)
return style
func _create_inventory_card_style(hovered: bool) -> StyleBoxFlat:
var style := _create_panel_style(Color(0.982, 0.995, 1.0, 1.0), 18, true)
style.border_width_left = 2
style.border_width_top = 2
style.border_width_right = 2
style.border_width_bottom = 2
style.border_color = Color(0.650, 0.835, 0.965, 0.90) if not hovered else Color(0.086, 0.690, 0.960, 1.0)
style.shadow_color = Color(0.086, 0.314, 0.520, 0.14 if hovered else 0.08)
style.shadow_size = 14 if hovered else 8
style.content_margin_left = 0
style.content_margin_top = 0
style.content_margin_right = 0
style.content_margin_bottom = 0
return style