feat: add nearby social interactions

This commit is contained in:
ANG-Server
2026-07-21 22:19:41 +08:00
parent 0bab768b14
commit 9dee47492c
43 changed files with 1499 additions and 77 deletions

View 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": "轻松闲聊"},
]