feat: add nearby social interactions
This commit is contained in:
@@ -32,6 +32,7 @@ var _session_generation: int = 0
|
||||
var _account_generation: int = 0
|
||||
var _refresh_in_flight: bool = false
|
||||
var _auth_config_path: String = DEFAULT_AUTH_CONFIG_PATH
|
||||
var _show_welcome_after_registration: bool = false
|
||||
|
||||
func _ready() -> void:
|
||||
_load_cached_session()
|
||||
@@ -70,6 +71,11 @@ func get_account_generation() -> int:
|
||||
func get_auth_config_path() -> String:
|
||||
return _auth_config_path
|
||||
|
||||
func consume_registration_welcome() -> bool:
|
||||
var should_show: bool = _show_welcome_after_registration
|
||||
_show_welcome_after_registration = false
|
||||
return should_show
|
||||
|
||||
func login(identifier: String, password: String) -> void:
|
||||
var normalized_identifier := identifier.strip_edges()
|
||||
if normalized_identifier.is_empty():
|
||||
@@ -297,6 +303,7 @@ func _on_register_response(success: bool, data: Dictionary, error_info: Dictiona
|
||||
register_failed.emit("注册响应缺少 access_token")
|
||||
return
|
||||
|
||||
_show_welcome_after_registration = true
|
||||
_emit_event(EventNames.AUTH_REGISTER_SUCCESS, {
|
||||
"user": get_current_user()
|
||||
})
|
||||
|
||||
@@ -979,6 +979,12 @@ func _on_data_received(message: String) -> void:
|
||||
_handle_chat_error(data)
|
||||
"chat_render":
|
||||
_handle_chat_render(data)
|
||||
"dm_message":
|
||||
_handle_direct_message(data)
|
||||
"dm_read":
|
||||
_emit_event(EventNames.SOCIAL_NOTIFICATION_RECEIVED, {"type": "dm_read", "data": data})
|
||||
"notification_created", "friendship_changed", "friend_presence_changed":
|
||||
_emit_event(EventNames.SOCIAL_NOTIFICATION_RECEIVED, {"type": message_type, "data": data})
|
||||
"friend_list":
|
||||
_handle_friend_list(data)
|
||||
"friend_added":
|
||||
@@ -1301,6 +1307,41 @@ func _handle_chat_render(data: Dictionary) -> void:
|
||||
"is_private": is_private
|
||||
})
|
||||
|
||||
func _handle_direct_message(data: Dictionary) -> void:
|
||||
var message_variant: Variant = data.get("message", {})
|
||||
if not (message_variant is Dictionary):
|
||||
return
|
||||
var message: Dictionary = message_variant
|
||||
var sender_variant: Variant = message.get("sender", {})
|
||||
var sender: Dictionary = sender_variant if sender_variant is Dictionary else {}
|
||||
var sender_id := str(message.get("senderId", "")).strip_edges()
|
||||
var recipient_id := str(message.get("recipientId", "")).strip_edges()
|
||||
var from_user := str(sender.get("nickname", sender.get("username", "玩家"))).strip_edges()
|
||||
var is_self := sender_id == _current_user_id()
|
||||
var payload := {
|
||||
"from": from_user,
|
||||
"fromUserId": sender_id,
|
||||
"txt": str(message.get("content", "")),
|
||||
"timestamp": message.get("createdAt", Time.get_unix_time_from_system()),
|
||||
"scope": "private",
|
||||
"toUserId": recipient_id,
|
||||
"toUsername": "",
|
||||
"privateContext": "dm",
|
||||
"bubble": false,
|
||||
}
|
||||
if is_self and _consume_pending_self_message(str(payload.get("txt", "")), "private", recipient_id):
|
||||
return
|
||||
_handle_chat_render(payload)
|
||||
_emit_event(EventNames.SOCIAL_NOTIFICATION_RECEIVED, {"type": "dm_message", "data": data})
|
||||
|
||||
func _current_user_id() -> String:
|
||||
var authManager := get_node_or_null("/root/AuthManager")
|
||||
if authManager != null and authManager.has_method("get_current_user"):
|
||||
var user_variant: Variant = authManager.call("get_current_user")
|
||||
if user_variant is Dictionary:
|
||||
return str((user_variant as Dictionary).get("id", ""))
|
||||
return ""
|
||||
|
||||
# 解析聊天消息时间戳(兼容 unix 秒 / ISO 8601 字符串)
|
||||
func _parse_chat_timestamp_to_unix(timestamp_raw: Variant) -> float:
|
||||
if typeof(timestamp_raw) == TYPE_INT or typeof(timestamp_raw) == TYPE_FLOAT:
|
||||
@@ -1511,12 +1552,14 @@ func _normalize_friend_requests(requests_variant: Variant) -> Array[Dictionary]:
|
||||
if not (request_variant is Dictionary):
|
||||
continue
|
||||
var request: Dictionary = request_variant
|
||||
var user_id := str(request.get("userId", request.get("user_id", ""))).strip_edges()
|
||||
var requester_variant: Variant = request.get("requester", {})
|
||||
var requester: Dictionary = requester_variant if requester_variant is Dictionary else {}
|
||||
var user_id := str(request.get("userId", request.get("user_id", requester.get("id", "")))).strip_edges()
|
||||
if user_id.is_empty():
|
||||
continue
|
||||
requests.append({
|
||||
"user_id": user_id,
|
||||
"username": str(request.get("username", "玩家")).strip_edges(),
|
||||
"username": str(request.get("username", requester.get("nickname", requester.get("username", "玩家")))).strip_edges(),
|
||||
"created_at": str(request.get("createdAt", request.get("created_at", ""))).strip_edges()
|
||||
})
|
||||
return requests
|
||||
|
||||
263
_Core/managers/InteractionManager.gd
Normal file
263
_Core/managers/InteractionManager.gd
Normal file
@@ -0,0 +1,263 @@
|
||||
extends Node
|
||||
|
||||
# 全地图统一交互:收集角色附近的动作,通过方向键选择、E 执行。
|
||||
const SCAN_INTERVAL: float = 0.10
|
||||
const MAX_ACTIONS_VISIBLE: int = 5
|
||||
const ACCENT: Color = Color("58c7db")
|
||||
const PANEL_COLOR: Color = Color(0.035, 0.071, 0.106, 0.94)
|
||||
const INTERACTION_POINT_MARKERS_SCRIPT: Script = preload("res://_Core/ui/InteractionPointMarkers.gd")
|
||||
const InteractionAnchorUtil = preload("res://_Core/utils/InteractionAnchor.gd")
|
||||
|
||||
var _actions: Array[Dictionary] = []
|
||||
var _selected_index: int = 0
|
||||
var _last_selected_id: String = ""
|
||||
var _scan_elapsed: float = SCAN_INTERVAL
|
||||
var _canvas: CanvasLayer
|
||||
var _panel: PanelContainer
|
||||
var _list: VBoxContainer
|
||||
var _hint: Label
|
||||
var _executing: bool = false
|
||||
var _pointMarkers: Node2D
|
||||
|
||||
func _ready() -> void:
|
||||
_build_hud()
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
_scan_elapsed += delta
|
||||
if _scan_elapsed < SCAN_INTERVAL:
|
||||
return
|
||||
_scan_elapsed = 0.0
|
||||
_refresh_actions()
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if _actions.is_empty() or _is_text_input_focused():
|
||||
return
|
||||
if not (event is InputEventKey):
|
||||
return
|
||||
var key_event := event as InputEventKey
|
||||
if not key_event.pressed or key_event.echo:
|
||||
return
|
||||
if key_event.keycode == KEY_UP:
|
||||
_select_offset(-1)
|
||||
get_viewport().set_input_as_handled()
|
||||
elif key_event.keycode == KEY_DOWN:
|
||||
_select_offset(1)
|
||||
get_viewport().set_input_as_handled()
|
||||
elif key_event.keycode == KEY_E:
|
||||
_execute_selected()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
func is_selection_active() -> bool:
|
||||
return not _actions.is_empty() and not _is_text_input_focused()
|
||||
|
||||
func _refresh_actions() -> void:
|
||||
if _is_text_input_focused() or SceneManager.is_changing_scene:
|
||||
_set_actions([])
|
||||
return
|
||||
var player := _local_player()
|
||||
if player == null:
|
||||
_set_actions([])
|
||||
return
|
||||
var collected: Array[Dictionary] = []
|
||||
for node in get_tree().get_nodes_in_group("whaletown_interactable"):
|
||||
if not is_instance_valid(node) or not node.has_method("get_interaction_actions"):
|
||||
continue
|
||||
var actions_variant: Variant = node.call("get_interaction_actions", player)
|
||||
if not (actions_variant is Array):
|
||||
continue
|
||||
for item in actions_variant as Array:
|
||||
if item is Dictionary:
|
||||
var action: Dictionary = item
|
||||
if bool(action.get("available", true)):
|
||||
collected.append(action)
|
||||
collected.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
||||
var priority_a := int(a.get("priority", 100))
|
||||
var priority_b := int(b.get("priority", 100))
|
||||
if priority_a != priority_b:
|
||||
return priority_a < priority_b
|
||||
var distance_a := float(a.get("distance", INF))
|
||||
var distance_b := float(b.get("distance", INF))
|
||||
if not is_equal_approx(distance_a, distance_b):
|
||||
return distance_a < distance_b
|
||||
return str(a.get("title", "")) < str(b.get("title", ""))
|
||||
)
|
||||
_set_actions(collected)
|
||||
|
||||
func _set_actions(next_actions: Array[Dictionary]) -> void:
|
||||
_actions = next_actions
|
||||
if _actions.is_empty():
|
||||
_selected_index = 0
|
||||
_last_selected_id = ""
|
||||
_render()
|
||||
return
|
||||
var matched_index := -1
|
||||
for index in _actions.size():
|
||||
if str(_actions[index].get("id", "")) == _last_selected_id:
|
||||
matched_index = index
|
||||
break
|
||||
_selected_index = matched_index if matched_index >= 0 else clampi(_selected_index, 0, _actions.size() - 1)
|
||||
_last_selected_id = str(_actions[_selected_index].get("id", ""))
|
||||
_render()
|
||||
|
||||
func _select_offset(offset: int) -> void:
|
||||
if _actions.is_empty():
|
||||
return
|
||||
_selected_index = posmod(_selected_index + offset, _actions.size())
|
||||
_last_selected_id = str(_actions[_selected_index].get("id", ""))
|
||||
_render()
|
||||
|
||||
func _execute_selected() -> void:
|
||||
if _executing or _actions.is_empty():
|
||||
return
|
||||
var action := _actions[_selected_index]
|
||||
var callback_variant: Variant = action.get("callback", Callable())
|
||||
if not (callback_variant is Callable) or not (callback_variant as Callable).is_valid():
|
||||
return
|
||||
_executing = true
|
||||
_render()
|
||||
(callback_variant as Callable).call()
|
||||
await get_tree().create_timer(0.18).timeout
|
||||
_executing = false
|
||||
_refresh_actions()
|
||||
|
||||
func _local_player() -> Node2D:
|
||||
var players := get_tree().get_nodes_in_group("whaletown_local_player")
|
||||
return players.front() as Node2D if not players.is_empty() else null
|
||||
|
||||
func _build_hud() -> void:
|
||||
_canvas = CanvasLayer.new()
|
||||
_canvas.layer = 90
|
||||
add_child(_canvas)
|
||||
_panel = PanelContainer.new()
|
||||
_panel.set_anchors_preset(Control.PRESET_CENTER_BOTTOM)
|
||||
_panel.position = Vector2(-250, -282)
|
||||
_panel.size = Vector2(500, 214)
|
||||
_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_panel.add_theme_stylebox_override("panel", _panel_style())
|
||||
_canvas.add_child(_panel)
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 14)
|
||||
margin.add_theme_constant_override("margin_top", 10)
|
||||
margin.add_theme_constant_override("margin_right", 14)
|
||||
margin.add_theme_constant_override("margin_bottom", 10)
|
||||
_panel.add_child(margin)
|
||||
var content := VBoxContainer.new()
|
||||
content.add_theme_constant_override("separation", 4)
|
||||
margin.add_child(content)
|
||||
_list = VBoxContainer.new()
|
||||
_list.add_theme_constant_override("separation", 2)
|
||||
content.add_child(_list)
|
||||
_hint = Label.new()
|
||||
_hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_hint.add_theme_color_override("font_color", Color(0.67, 0.79, 0.85, 1.0))
|
||||
_hint.add_theme_font_size_override("font_size", 14)
|
||||
_hint.text = "[↑/↓] 选择 [E] 交互"
|
||||
content.add_child(_hint)
|
||||
_render()
|
||||
|
||||
func _render() -> void:
|
||||
if not is_instance_valid(_panel):
|
||||
return
|
||||
var show_hints: bool = _should_show_interaction_hints()
|
||||
_panel.visible = show_hints and not _actions.is_empty()
|
||||
_render_interaction_points()
|
||||
for child in _list.get_children():
|
||||
child.queue_free()
|
||||
if _actions.is_empty():
|
||||
return
|
||||
var start: int = clampi(_selected_index - 2, 0, maxi(0, _actions.size() - MAX_ACTIONS_VISIBLE))
|
||||
var finish: int = mini(_actions.size(), start + MAX_ACTIONS_VISIBLE)
|
||||
for index in range(start, finish):
|
||||
var action := _actions[index]
|
||||
var row := Label.new()
|
||||
row.custom_minimum_size = Vector2(0, 29)
|
||||
row.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
row.add_theme_font_size_override("font_size", 16)
|
||||
var selected := index == _selected_index
|
||||
row.text = ("› " if selected else " ") + str(action.get("title", "交互")) + (" …" if selected and _executing else "")
|
||||
row.add_theme_color_override("font_color", ACCENT if selected else Color(0.92, 0.96, 0.98, 1.0))
|
||||
if selected:
|
||||
row.add_theme_stylebox_override("normal", _selected_style())
|
||||
_list.add_child(row)
|
||||
_hint.text = "[↑/↓] 选择 [E] 交互 [Esc] 关闭" + (" %d 项" % _actions.size() if _actions.size() > 1 else "")
|
||||
|
||||
func _render_interaction_points() -> void:
|
||||
if not _should_show_interaction_points():
|
||||
if is_instance_valid(_pointMarkers):
|
||||
var empty_points: Array[Vector2] = []
|
||||
_pointMarkers.call("set_points", empty_points)
|
||||
return
|
||||
var markers: Node2D = _ensure_point_markers()
|
||||
if markers == null:
|
||||
return
|
||||
var positions: Array[Vector2] = []
|
||||
for node: Node in get_tree().get_nodes_in_group("whaletown_interactable"):
|
||||
if not is_instance_valid(node) or not node.has_method("get_interaction_actions"):
|
||||
continue
|
||||
if node.has_method("get_interaction_marker_positions"):
|
||||
var marker_positions_variant: Variant = node.call("get_interaction_marker_positions")
|
||||
if marker_positions_variant is Array:
|
||||
for position_variant: Variant in marker_positions_variant as Array:
|
||||
if position_variant is Vector2:
|
||||
positions.append(position_variant as Vector2)
|
||||
continue
|
||||
var source_node: Node2D = node as Node2D
|
||||
if source_node != null:
|
||||
positions.append(InteractionAnchorUtil.get_position(source_node))
|
||||
markers.call("set_points", positions)
|
||||
|
||||
func _ensure_point_markers() -> Node2D:
|
||||
var current_scene: Node = get_tree().current_scene
|
||||
var world_root: Node2D = current_scene as Node2D
|
||||
if world_root == null:
|
||||
return null
|
||||
if is_instance_valid(_pointMarkers) and _pointMarkers.get_parent() == world_root:
|
||||
return _pointMarkers
|
||||
var markers: Node2D = INTERACTION_POINT_MARKERS_SCRIPT.new() as Node2D
|
||||
if markers == null:
|
||||
return null
|
||||
markers.name = "InteractionPointMarkers"
|
||||
markers.z_index = 4096
|
||||
markers.z_as_relative = false
|
||||
world_root.add_child(markers)
|
||||
_pointMarkers = markers
|
||||
return _pointMarkers
|
||||
|
||||
func _should_show_interaction_hints() -> bool:
|
||||
return _get_setting_enabled("show_interaction_hints", true)
|
||||
|
||||
func _should_show_interaction_points() -> bool:
|
||||
return _get_setting_enabled("show_interaction_points", false)
|
||||
|
||||
func _get_setting_enabled(setting_key: String, fallback: bool) -> bool:
|
||||
var settings_manager: Node = get_node_or_null("/root/SettingsManager")
|
||||
if settings_manager != null and settings_manager.has_method("get_bool"):
|
||||
return bool(settings_manager.call("get_bool", setting_key))
|
||||
return fallback
|
||||
|
||||
func _panel_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = PANEL_COLOR
|
||||
style.border_color = Color(0.20, 0.45, 0.54, 0.86)
|
||||
style.set_border_width_all(1)
|
||||
style.corner_radius_top_left = 10
|
||||
style.corner_radius_top_right = 10
|
||||
style.corner_radius_bottom_left = 10
|
||||
style.corner_radius_bottom_right = 10
|
||||
style.shadow_color = Color(0, 0, 0, 0.35)
|
||||
style.shadow_size = 10
|
||||
return style
|
||||
|
||||
func _selected_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.12, 0.32, 0.38, 0.74)
|
||||
style.corner_radius_top_left = 6
|
||||
style.corner_radius_top_right = 6
|
||||
style.corner_radius_bottom_left = 6
|
||||
style.corner_radius_bottom_right = 6
|
||||
style.content_margin_left = 6
|
||||
return style
|
||||
|
||||
func _is_text_input_focused() -> bool:
|
||||
var focus_owner := get_viewport().gui_get_focus_owner()
|
||||
return focus_owner is LineEdit or focus_owner is TextEdit
|
||||
1
_Core/managers/InteractionManager.gd.uid
Normal file
1
_Core/managers/InteractionManager.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cg46uuk3hxvqe
|
||||
@@ -38,6 +38,7 @@ var current_scene_name: String = "" # 当前场景名称
|
||||
var is_changing_scene: bool = false # 是否正在切换场景
|
||||
var _next_scene_position: Variant = null # 下一个场景的初始位置 (Vector2 or null)
|
||||
var _next_spawn_name: String = "" # 下一个场景的出生点名称 (String)
|
||||
var _next_destination_id: String = "" # 快速传送目标,用于发现登记
|
||||
|
||||
# 场景路径映射表
|
||||
# 将场景名称映射到实际的文件路径
|
||||
@@ -194,6 +195,14 @@ func get_next_spawn_name() -> String:
|
||||
_next_spawn_name = ""
|
||||
return spawn_name
|
||||
|
||||
func set_next_destination_id(destination_id: String) -> void:
|
||||
_next_destination_id = destination_id.strip_edges()
|
||||
|
||||
func get_next_destination_id() -> String:
|
||||
var destination_id := _next_destination_id
|
||||
_next_destination_id = ""
|
||||
return destination_id
|
||||
|
||||
# ============ 场景注册方法 ============
|
||||
|
||||
# 注册新场景
|
||||
|
||||
@@ -15,7 +15,8 @@ const DEFAULT_SETTINGS: Dictionary = {
|
||||
"effects_volume": 0.90,
|
||||
"ui_scale": 1.00,
|
||||
"fullscreen": false,
|
||||
"show_interaction_hints": false,
|
||||
"show_interaction_hints": true,
|
||||
"show_interaction_points": false,
|
||||
"show_name_always": false,
|
||||
"show_chat_bubbles": true,
|
||||
"world_notifications": true,
|
||||
@@ -23,6 +24,7 @@ const DEFAULT_SETTINGS: Dictionary = {
|
||||
"friend_request_notifications": true,
|
||||
"allow_nearby_private": true,
|
||||
"allow_nearby_friend_requests": true,
|
||||
"allow_nearby_profile": true,
|
||||
"mute_ui_sfx": false,
|
||||
}
|
||||
|
||||
|
||||
438
_Core/managers/SocialManager.gd
Normal file
438
_Core/managers/SocialManager.gd
Normal file
@@ -0,0 +1,438 @@
|
||||
extends Node
|
||||
|
||||
# 社区资料、通知与旅行 API 的客户端协调器。
|
||||
const ACCENT := Color("58c7db")
|
||||
const PANEL_COLOR := Color(0.035, 0.071, 0.106, 0.97)
|
||||
const DISCOVERY_DISTANCE: float = 180.0
|
||||
const DISCOVERY_POINTS: Array[Dictionary] = [
|
||||
{"id": "square_center", "mapId": "whale_port", "position": Vector2(0, 30)},
|
||||
{"id": "square_dock", "mapId": "whale_port", "position": Vector2(-870, -202)},
|
||||
{"id": "square_headquarters", "mapId": "whale_port", "position": Vector2(13, -600)},
|
||||
{"id": "square_cottage", "mapId": "whale_port", "position": Vector2(845, -192)},
|
||||
{"id": "square_workshop", "mapId": "whale_port", "position": Vector2(645, 500)},
|
||||
{"id": "square_notice", "mapId": "whale_port", "position": Vector2(-542, 648)},
|
||||
{"id": "square_work_zone_gate", "mapId": "whale_port", "position": Vector2(0, 775)},
|
||||
{"id": "work_entrance", "mapId": "work_zone", "position": Vector2(0, 755)},
|
||||
{"id": "work_mall", "mapId": "work_zone", "position": Vector2(0, -614)},
|
||||
{"id": "work_cafe_gate", "mapId": "work_zone", "position": Vector2(-1044, 222)},
|
||||
{"id": "work_jobs", "mapId": "work_zone", "position": Vector2(-502, 192)},
|
||||
{"id": "work_courses", "mapId": "work_zone", "position": Vector2(496, 0)},
|
||||
{"id": "work_ai", "mapId": "work_zone", "position": Vector2(452, 548)},
|
||||
{"id": "work_exchange", "mapId": "work_zone", "position": Vector2(1075, 548)},
|
||||
{"id": "cafe_entrance", "mapId": "whale_cafe", "position": Vector2(0, 363)},
|
||||
{"id": "cafe_counter", "mapId": "whale_cafe", "position": Vector2(0, -37)},
|
||||
{"id": "cafe_companion", "mapId": "whale_cafe", "position": Vector2(-398, -226)},
|
||||
{"id": "personal_room", "mapId": "personal_space", "position": Vector2.ZERO},
|
||||
]
|
||||
|
||||
var _canvas: CanvasLayer
|
||||
var _profile_panel: PanelContainer
|
||||
var _profile_content: VBoxContainer
|
||||
var _notification_panel: PanelContainer
|
||||
var _notification_content: VBoxContainer
|
||||
var _current_profile: Dictionary = {}
|
||||
var _discoveryRequests: Dictionary = {}
|
||||
var _discoveredDestinations: Dictionary = {}
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("whaletown_escape_dismissible")
|
||||
_build_ui()
|
||||
var event_system := get_node_or_null("/root/EventSystem")
|
||||
if event_system != null:
|
||||
event_system.call("connect_event", EventNames.SOCIAL_NOTIFICATION_RECEIVED, _on_social_realtime, self)
|
||||
|
||||
func _exit_tree() -> void:
|
||||
var event_system := get_node_or_null("/root/EventSystem")
|
||||
if event_system != null:
|
||||
event_system.call("disconnect_event", EventNames.SOCIAL_NOTIFICATION_RECEIVED, _on_social_realtime, self)
|
||||
|
||||
func show_profile(user_id: String) -> void:
|
||||
var normalized := user_id.strip_edges()
|
||||
if normalized.is_empty():
|
||||
return
|
||||
_request_get("/social/profiles/%s" % normalized, func(success: bool, response: Dictionary, error: Dictionary) -> void:
|
||||
if not success:
|
||||
_show_status("无法查看名片:%s" % str(error.get("message", "请求失败")))
|
||||
return
|
||||
var data_variant: Variant = response.get("data", {})
|
||||
if data_variant is Dictionary:
|
||||
_current_profile = data_variant as Dictionary
|
||||
_render_profile()
|
||||
)
|
||||
|
||||
func show_own_profile() -> void:
|
||||
_request_get("/social/profile", func(success: bool, response: Dictionary, error: Dictionary) -> void:
|
||||
if not success:
|
||||
_show_status("无法读取个人名片:%s" % str(error.get("message", "请求失败")))
|
||||
return
|
||||
var data_variant: Variant = response.get("data", {})
|
||||
if data_variant is Dictionary:
|
||||
_current_profile = data_variant as Dictionary
|
||||
_render_profile()
|
||||
)
|
||||
|
||||
func request_friend(user_id: String, username: String = "") -> void:
|
||||
var chat := get_node_or_null("/root/ChatManager")
|
||||
if chat != null and chat.has_method("request_friend"):
|
||||
chat.call("request_friend", user_id, username)
|
||||
|
||||
func open_private_chat(user_id: String, username: String) -> void:
|
||||
var event_system := get_node_or_null("/root/EventSystem")
|
||||
if event_system != null:
|
||||
event_system.call("emit_event", EventNames.CHAT_PRIVATE_TARGET_SELECTED, {"userId": user_id, "username": username})
|
||||
_close_profile()
|
||||
|
||||
func request_travel(destination_id: String, completion: Callable = Callable()) -> void:
|
||||
_request_post("/world/travel-destinations/%s/travel" % destination_id.uri_encode(), {}, func(success: bool, response: Dictionary, error: Dictionary) -> void:
|
||||
if not success:
|
||||
_show_status(str(error.get("message", "该地点尚未解锁")))
|
||||
if completion.is_valid(): completion.call(false, {})
|
||||
return
|
||||
var data_variant: Variant = response.get("data", {})
|
||||
var data: Dictionary = data_variant if data_variant is Dictionary else {}
|
||||
if completion.is_valid(): completion.call(true, data)
|
||||
)
|
||||
|
||||
func discover_destination(destination_id: String) -> void:
|
||||
var normalized := destination_id.strip_edges()
|
||||
if normalized.is_empty() or _discoveredDestinations.has(normalized) or _discoveryRequests.has(normalized):
|
||||
return
|
||||
_discoveryRequests[normalized] = true
|
||||
_request_post("/world/travel-destinations/%s/discover" % normalized.uri_encode(), {}, func(success: bool, _response: Dictionary, _error: Dictionary) -> void:
|
||||
_discoveryRequests.erase(normalized)
|
||||
if success:
|
||||
_discoveredDestinations[normalized] = true
|
||||
)
|
||||
|
||||
func discover_nearby_destinations(map_id: String, position: Vector2) -> void:
|
||||
for point in DISCOVERY_POINTS:
|
||||
if str(point.get("mapId", "")) != map_id:
|
||||
continue
|
||||
var target := point.get("position", Vector2.ZERO) as Vector2
|
||||
if position.distance_to(target) <= DISCOVERY_DISTANCE:
|
||||
discover_destination(str(point.get("id", "")))
|
||||
|
||||
func toggle_notifications() -> void:
|
||||
_notification_panel.visible = not _notification_panel.visible
|
||||
if _notification_panel.visible:
|
||||
_refresh_notifications()
|
||||
|
||||
func is_escape_dismissible() -> bool:
|
||||
return (is_instance_valid(_profile_panel) and _profile_panel.visible) or (is_instance_valid(_notification_panel) and _notification_panel.visible)
|
||||
|
||||
func get_escape_priority() -> int:
|
||||
return 800
|
||||
|
||||
func request_escape_close() -> void:
|
||||
if is_instance_valid(_profile_panel) and _profile_panel.visible:
|
||||
_close_profile()
|
||||
return
|
||||
if is_instance_valid(_notification_panel):
|
||||
_notification_panel.visible = false
|
||||
|
||||
func _on_social_realtime(_payload: Dictionary) -> void:
|
||||
if _notification_panel.visible:
|
||||
_refresh_notifications()
|
||||
|
||||
func _refresh_notifications() -> void:
|
||||
_request_get("/social/notifications?limit=30", func(success: bool, response: Dictionary, error: Dictionary) -> void:
|
||||
if not success:
|
||||
_show_notification_rows(["通知暂时无法读取:%s" % str(error.get("message", "请求失败"))])
|
||||
return
|
||||
var data_variant: Variant = response.get("data", {})
|
||||
var data: Dictionary = data_variant if data_variant is Dictionary else {}
|
||||
var rows: Array[String] = []
|
||||
var notifications_variant: Variant = data.get("notifications", [])
|
||||
if notifications_variant is Array:
|
||||
for item in notifications_variant as Array:
|
||||
if item is Dictionary:
|
||||
var notification: Dictionary = item
|
||||
rows.append("%s\n%s" % [str(notification.get("title", "通知")), str(notification.get("content", ""))])
|
||||
if rows.is_empty(): rows.append("暂无通知")
|
||||
_show_notification_rows(rows)
|
||||
)
|
||||
|
||||
func _render_profile() -> void:
|
||||
_profile_panel.visible = true
|
||||
for child in _profile_content.get_children():
|
||||
child.queue_free()
|
||||
var nickname := str(_current_profile.get("nickname", "玩家"))
|
||||
var username := str(_current_profile.get("username", ""))
|
||||
var online := bool(_current_profile.get("online", false))
|
||||
var area := str(_current_profile.get("currentArea", ""))
|
||||
var identity := HBoxContainer.new()
|
||||
identity.add_theme_constant_override("separation", 12)
|
||||
identity.add_child(_avatar_badge(nickname))
|
||||
var identity_text := VBoxContainer.new()
|
||||
identity_text.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
identity_text.add_child(_label(nickname, 26, ACCENT))
|
||||
identity_text.add_child(_label("@%s · %s" % [username, "在线" if online else "离线"], 15, Color(0.72, 0.82, 0.87, 1)))
|
||||
identity.add_child(identity_text)
|
||||
_profile_content.add_child(identity)
|
||||
_profile_content.add_child(_label("当前区域:%s" % _area_label(area), 16, Color(0.88, 0.94, 0.96, 1)))
|
||||
var skin_id := str(_current_profile.get("skinId", "")).strip_edges()
|
||||
_profile_content.add_child(_label("角色:%s" % (skin_id if not skin_id.is_empty() else "默认角色"), 14, Color(0.50, 0.76, 0.80, 1)))
|
||||
var bio := str(_current_profile.get("bio", "")).strip_edges()
|
||||
_profile_content.add_child(_label(bio if not bio.is_empty() else "这个玩家还没有留下简介。", 16, Color(0.84, 0.90, 0.92, 1)))
|
||||
var interests_variant: Variant = _current_profile.get("interests", [])
|
||||
var interests: Array[String] = []
|
||||
if interests_variant is Array:
|
||||
for interest in interests_variant as Array: interests.append(str(interest))
|
||||
_profile_content.add_child(_label("兴趣:%s" % (" · ".join(interests) if not interests.is_empty() else "未设置"), 14, Color(0.50, 0.76, 0.80, 1)))
|
||||
var user_id := str(_current_profile.get("id", ""))
|
||||
var self_profile := user_id == _current_user_id()
|
||||
if self_profile:
|
||||
_profile_content.add_child(_label("编辑社区资料", 17, ACCENT))
|
||||
var nickname_input := LineEdit.new()
|
||||
nickname_input.placeholder_text = "昵称(首次可立即修改,此后每 7 天一次)"
|
||||
nickname_input.text = nickname
|
||||
_profile_content.add_child(nickname_input)
|
||||
var bio_input := TextEdit.new()
|
||||
bio_input.placeholder_text = "简介(最多 160 字)"
|
||||
bio_input.text = bio
|
||||
bio_input.custom_minimum_size = Vector2(0, 82)
|
||||
_profile_content.add_child(bio_input)
|
||||
var current_interests: Array[String] = []
|
||||
for interest in interests: current_interests.append(interest)
|
||||
var interest_row := HBoxContainer.new()
|
||||
interest_row.add_theme_constant_override("separation", 6)
|
||||
var interest_selects: Array[OptionButton] = []
|
||||
for slot in 3:
|
||||
var selector := OptionButton.new()
|
||||
selector.custom_minimum_size = Vector2(145, 34)
|
||||
selector.add_item("兴趣标签")
|
||||
for tag in _interest_catalog():
|
||||
selector.add_item(str(tag.get("label", "")))
|
||||
selector.set_item_metadata(selector.item_count - 1, str(tag.get("id", "")))
|
||||
if slot < current_interests.size():
|
||||
for item_index in range(1, selector.item_count):
|
||||
if str(selector.get_item_metadata(item_index)) == current_interests[slot]:
|
||||
selector.select(item_index)
|
||||
break
|
||||
interest_selects.append(selector)
|
||||
interest_row.add_child(selector)
|
||||
_profile_content.add_child(interest_row)
|
||||
_profile_content.add_child(_button("保存资料", func() -> void: _save_own_profile(nickname_input.text, bio_input.text, interest_selects)))
|
||||
else:
|
||||
var actions := HBoxContainer.new()
|
||||
actions.add_theme_constant_override("separation", 8)
|
||||
actions.add_child(_button("私聊", func() -> void: open_private_chat(user_id, nickname)))
|
||||
actions.add_child(_button("加好友", func() -> void: request_friend(user_id, nickname)))
|
||||
_profile_content.add_child(actions)
|
||||
var safety := HBoxContainer.new()
|
||||
safety.add_theme_constant_override("separation", 8)
|
||||
safety.add_child(_button("拉黑", func() -> void: _block_user(user_id)))
|
||||
var report_form := _report_form(user_id)
|
||||
var report_button := _button("举报", func() -> void: report_form.visible = not report_form.visible)
|
||||
safety.add_child(report_button)
|
||||
_profile_content.add_child(safety)
|
||||
_profile_content.add_child(report_form)
|
||||
_profile_content.add_child(_button("关闭", _close_profile))
|
||||
|
||||
func _block_user(user_id: String) -> void:
|
||||
_request_post("/social/blocks", {"userId": user_id}, func(success: bool, _response: Dictionary, error: Dictionary) -> void:
|
||||
_show_status("已拉黑该玩家" if success else str(error.get("message", "拉黑失败")))
|
||||
if success: _close_profile()
|
||||
)
|
||||
|
||||
func _save_own_profile(nickname: String, bio: String, interest_selects: Array[OptionButton]) -> void:
|
||||
var interests: Array[String] = []
|
||||
for selector in interest_selects:
|
||||
var index := selector.selected
|
||||
if index > 0:
|
||||
var value := str(selector.get_item_metadata(index)).strip_edges()
|
||||
if not value.is_empty() and not interests.has(value): interests.append(value)
|
||||
_request_patch("/social/profile", {"nickname": nickname.strip_edges(), "bio": bio.strip_edges(), "interests": interests}, func(success: bool, response: Dictionary, error: Dictionary) -> void:
|
||||
if not success:
|
||||
_show_status(str(error.get("message", "资料保存失败")))
|
||||
return
|
||||
var data_variant: Variant = response.get("data", {})
|
||||
if data_variant is Dictionary:
|
||||
_current_profile = data_variant as Dictionary
|
||||
_render_profile()
|
||||
var event_system := get_node_or_null("/root/EventSystem")
|
||||
if event_system != null: event_system.call("emit_event", EventNames.SOCIAL_PROFILE_UPDATED, _current_profile)
|
||||
)
|
||||
|
||||
func _report_form(user_id: String) -> VBoxContainer:
|
||||
var form := VBoxContainer.new()
|
||||
form.visible = false
|
||||
form.add_theme_constant_override("separation", 6)
|
||||
form.add_theme_stylebox_override("panel", _subpanel_style())
|
||||
form.add_child(_label("举报原因", 15, ACCENT))
|
||||
var reason := OptionButton.new()
|
||||
for item in [
|
||||
{"id": "harassment", "label": "骚扰或辱骂"},
|
||||
{"id": "spam", "label": "垃圾信息"},
|
||||
{"id": "inappropriate_content", "label": "不当内容"},
|
||||
{"id": "impersonation", "label": "冒充他人"},
|
||||
{"id": "other", "label": "其他"},
|
||||
]:
|
||||
reason.add_item(str(item.get("label", "其他")))
|
||||
reason.set_item_metadata(reason.item_count - 1, str(item.get("id", "other")))
|
||||
form.add_child(reason)
|
||||
var note := TextEdit.new()
|
||||
note.placeholder_text = "补充说明(可选,最多 500 字)"
|
||||
note.custom_minimum_size = Vector2(0, 68)
|
||||
form.add_child(note)
|
||||
var block_also := CheckBox.new()
|
||||
block_also.text = "同时拉黑此玩家"
|
||||
block_also.button_pressed = false
|
||||
form.add_child(block_also)
|
||||
form.add_child(_button("提交举报", func() -> void:
|
||||
_submit_report(user_id, str(reason.get_item_metadata(reason.selected)), note.text, block_also.button_pressed)
|
||||
))
|
||||
return form
|
||||
|
||||
func _submit_report(user_id: String, reason: String, note: String, block_also: bool) -> void:
|
||||
_request_post("/social/reports", {
|
||||
"userId": user_id,
|
||||
"reason": reason,
|
||||
"note": note.strip_edges(),
|
||||
"blockAlso": block_also,
|
||||
}, func(success: bool, _response: Dictionary, error: Dictionary) -> void:
|
||||
_show_status("举报已提交" if success else str(error.get("message", "举报失败")))
|
||||
if success and block_also:
|
||||
_close_profile()
|
||||
)
|
||||
|
||||
func _build_ui() -> void:
|
||||
_canvas = CanvasLayer.new()
|
||||
_canvas.layer = 88
|
||||
add_child(_canvas)
|
||||
_profile_panel = _panel(Vector2(540, 610))
|
||||
_profile_panel.set_anchors_preset(Control.PRESET_CENTER)
|
||||
_profile_panel.position = Vector2(-270, -305)
|
||||
_canvas.add_child(_profile_panel)
|
||||
var profile_margin := _margin(_profile_panel)
|
||||
var profile_scroll := ScrollContainer.new()
|
||||
profile_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
profile_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
profile_margin.add_child(profile_scroll)
|
||||
_profile_content = VBoxContainer.new()
|
||||
_profile_content.add_theme_constant_override("separation", 12)
|
||||
_profile_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
profile_scroll.add_child(_profile_content)
|
||||
_notification_panel = _panel(Vector2(390, 430))
|
||||
_notification_panel.set_anchors_preset(Control.PRESET_TOP_RIGHT)
|
||||
_notification_panel.position = Vector2(-414, 110)
|
||||
_canvas.add_child(_notification_panel)
|
||||
var notification_margin := _margin(_notification_panel)
|
||||
_notification_content = VBoxContainer.new()
|
||||
_notification_content.add_theme_constant_override("separation", 9)
|
||||
notification_margin.add_child(_notification_content)
|
||||
_notification_panel.visible = false
|
||||
_profile_panel.visible = false
|
||||
|
||||
func _show_notification_rows(rows: Array[String]) -> void:
|
||||
for child in _notification_content.get_children(): child.queue_free()
|
||||
_notification_content.add_child(_label("通知中心", 22, ACCENT))
|
||||
for row in rows:
|
||||
_notification_content.add_child(_label(row, 15, Color(0.87, 0.93, 0.95, 1)))
|
||||
_notification_content.add_child(_button("全部标为已读", func() -> void:
|
||||
_request_patch("/social/notifications/read-all", {}, func(_success: bool, _response: Dictionary, _error: Dictionary) -> void: _refresh_notifications())
|
||||
))
|
||||
|
||||
func _panel(panel_size: Vector2) -> PanelContainer:
|
||||
var panel := PanelContainer.new()
|
||||
panel.size = panel_size
|
||||
panel.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = PANEL_COLOR
|
||||
style.border_color = Color(0.20, 0.52, 0.61, 0.9)
|
||||
style.set_border_width_all(1)
|
||||
style.set_corner_radius_all(12)
|
||||
style.shadow_color = Color(0, 0, 0, 0.45)
|
||||
style.shadow_size = 16
|
||||
panel.add_theme_stylebox_override("panel", style)
|
||||
return panel
|
||||
|
||||
func _margin(parent: Control) -> MarginContainer:
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 20)
|
||||
margin.add_theme_constant_override("margin_top", 18)
|
||||
margin.add_theme_constant_override("margin_right", 20)
|
||||
margin.add_theme_constant_override("margin_bottom", 18)
|
||||
parent.add_child(margin)
|
||||
return margin
|
||||
|
||||
func _label(text: String, font_size: int, color: Color) -> Label:
|
||||
var label := Label.new()
|
||||
label.text = text
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
label.add_theme_font_size_override("font_size", font_size)
|
||||
label.add_theme_color_override("font_color", color)
|
||||
return label
|
||||
|
||||
func _button(text: String, callback: Callable) -> Button:
|
||||
var button := Button.new()
|
||||
button.text = text
|
||||
button.custom_minimum_size = Vector2(92, 34)
|
||||
button.pressed.connect(callback)
|
||||
return button
|
||||
|
||||
func _avatar_badge(nickname: String) -> Label:
|
||||
var badge := Label.new()
|
||||
badge.text = nickname.left(1).to_upper() if not nickname.is_empty() else "玩"
|
||||
badge.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
badge.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
badge.custom_minimum_size = Vector2(52, 52)
|
||||
badge.add_theme_font_size_override("font_size", 22)
|
||||
badge.add_theme_color_override("font_color", Color(0.92, 0.99, 1.0, 1.0))
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.12, 0.40, 0.48, 1.0)
|
||||
style.set_corner_radius_all(26)
|
||||
style.border_color = ACCENT
|
||||
style.set_border_width_all(1)
|
||||
badge.add_theme_stylebox_override("normal", style)
|
||||
return badge
|
||||
|
||||
func _subpanel_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.06, 0.13, 0.18, 0.88)
|
||||
style.set_border_width_all(1)
|
||||
style.border_color = Color(0.18, 0.45, 0.52, 0.75)
|
||||
style.set_corner_radius_all(8)
|
||||
style.content_margin_left = 10
|
||||
style.content_margin_right = 10
|
||||
style.content_margin_top = 8
|
||||
style.content_margin_bottom = 8
|
||||
return style
|
||||
|
||||
func _close_profile() -> void:
|
||||
_profile_panel.visible = false
|
||||
|
||||
func _show_status(message: String) -> void:
|
||||
push_warning("Social: %s" % message)
|
||||
|
||||
func _request_get(endpoint: String, callback: Callable) -> void:
|
||||
var api := get_node_or_null("/root/ApiClient")
|
||||
if api != null: api.call("get_json", endpoint, callback, true)
|
||||
|
||||
func _request_post(endpoint: String, payload: Dictionary, callback: Callable) -> void:
|
||||
var api := get_node_or_null("/root/ApiClient")
|
||||
if api != null: api.call("post_json", endpoint, payload, callback, true)
|
||||
|
||||
func _request_patch(endpoint: String, payload: Dictionary, callback: Callable) -> void:
|
||||
var api := get_node_or_null("/root/ApiClient")
|
||||
if api != null: api.call("patch_json", endpoint, payload, callback, true)
|
||||
|
||||
func _current_user_id() -> String:
|
||||
var auth := get_node_or_null("/root/AuthManager")
|
||||
if auth != null and auth.has_method("get_current_user"):
|
||||
var user_variant: Variant = auth.call("get_current_user")
|
||||
if user_variant is Dictionary: return str((user_variant as Dictionary).get("id", ""))
|
||||
return ""
|
||||
|
||||
func _area_label(map_id: String) -> String:
|
||||
return {"whale_port": "中心广场", "work_zone": "打工区", "whale_cafe": "鲸鱼咖啡馆", "personal_space": "个人空间"}.get(map_id, map_id)
|
||||
|
||||
func _interest_catalog() -> Array[Dictionary]:
|
||||
return [
|
||||
{"id": "ai", "label": "AI/大模型"}, {"id": "programming", "label": "编程开发"},
|
||||
{"id": "data_science", "label": "数据科学"}, {"id": "open_source", "label": "开源协作"},
|
||||
{"id": "product", "label": "产品"}, {"id": "design", "label": "设计"},
|
||||
{"id": "game_dev", "label": "游戏开发"}, {"id": "content_creation", "label": "内容创作"},
|
||||
{"id": "community", "label": "社区活动"}, {"id": "learning_partner", "label": "学习搭子"},
|
||||
{"id": "career", "label": "职业成长"}, {"id": "casual_chat", "label": "轻松闲聊"},
|
||||
]
|
||||
1
_Core/managers/SocialManager.gd.uid
Normal file
1
_Core/managers/SocialManager.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cm5em8mgr5i24
|
||||
58
_Core/managers/UiEscapeManager.gd
Normal file
58
_Core/managers/UiEscapeManager.gd
Normal file
@@ -0,0 +1,58 @@
|
||||
extends Node
|
||||
|
||||
# 统一处理 UI 的 Esc 退出。可关闭界面加入 whaletown_escape_dismissible 分组,
|
||||
# 并实现 is_escape_dismissible / get_escape_priority / request_escape_close。
|
||||
const ESCAPE_DISMISSIBLE_GROUP: StringName = &"whaletown_escape_dismissible"
|
||||
|
||||
func _ready() -> void:
|
||||
# 公告、排行榜等界面会暂停场景树,Esc 仍必须有效。
|
||||
process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if not (event is InputEventKey):
|
||||
return
|
||||
var key_event: InputEventKey = event as InputEventKey
|
||||
if not key_event.pressed or key_event.echo or key_event.keycode != KEY_ESCAPE:
|
||||
return
|
||||
if _hide_open_popup_menu() or _close_topmost_dismissible() or _release_control_focus():
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
func _hide_open_popup_menu() -> bool:
|
||||
var popup_nodes: Array[Node] = get_tree().root.find_children("*", "PopupMenu", true, false)
|
||||
for popup_node: Node in popup_nodes:
|
||||
var popup: PopupMenu = popup_node as PopupMenu
|
||||
if popup != null and popup.visible:
|
||||
popup.hide()
|
||||
return true
|
||||
return false
|
||||
|
||||
func _close_topmost_dismissible() -> bool:
|
||||
var target: Node = null
|
||||
var target_priority: int = -2147483648
|
||||
var dismissibles: Array[Node] = get_tree().get_nodes_in_group(ESCAPE_DISMISSIBLE_GROUP)
|
||||
for dismissible: Node in dismissibles:
|
||||
if not is_instance_valid(dismissible):
|
||||
continue
|
||||
if not dismissible.has_method("is_escape_dismissible") or not dismissible.has_method("request_escape_close"):
|
||||
continue
|
||||
var is_dismissible_variant: Variant = dismissible.call("is_escape_dismissible")
|
||||
if not bool(is_dismissible_variant):
|
||||
continue
|
||||
var priority: int = 0
|
||||
if dismissible.has_method("get_escape_priority"):
|
||||
var priority_variant: Variant = dismissible.call("get_escape_priority")
|
||||
priority = int(priority_variant)
|
||||
if target == null or priority > target_priority:
|
||||
target = dismissible
|
||||
target_priority = priority
|
||||
if target == null:
|
||||
return false
|
||||
target.call("request_escape_close")
|
||||
return true
|
||||
|
||||
func _release_control_focus() -> bool:
|
||||
var focus_owner: Control = get_viewport().gui_get_focus_owner()
|
||||
if focus_owner == null:
|
||||
return false
|
||||
focus_owner.release_focus()
|
||||
return true
|
||||
1
_Core/managers/UiEscapeManager.gd.uid
Normal file
1
_Core/managers/UiEscapeManager.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://otx02vxmfni3
|
||||
Reference in New Issue
Block a user