diff --git a/_Core/EventNames.gd b/_Core/EventNames.gd index 5926a77..a729db7 100644 --- a/_Core/EventNames.gd +++ b/_Core/EventNames.gd @@ -108,6 +108,7 @@ const HUD_SETTINGS_REQUESTED = "hud_settings_requested" const HUD_BACKPACK_TOGGLE = "hud_backpack_toggle" const HUD_MAP_TOGGLE = "hud_map_toggle" const HUD_TASK_BOOK_REQUESTED = "hud_task_book_requested" +const HUD_ADMIN_TEST_LAB_REQUESTED = "hud_admin_test_lab_requested" # ============================================================================ # 商城事件 diff --git a/_Core/managers/ApiClient.gd b/_Core/managers/ApiClient.gd index 9e36164..b395961 100644 --- a/_Core/managers/ApiClient.gd +++ b/_Core/managers/ApiClient.gd @@ -33,6 +33,9 @@ func patch_json(endpoint: String, payload: Dictionary, callback: Callable, authe func put_json(endpoint: String, payload: Dictionary, callback: Callable, authenticated: bool = true) -> void: request_json(endpoint, payload, callback, HTTPClient.METHOD_PUT, authenticated) +func delete_json(endpoint: String, payload: Dictionary, callback: Callable, authenticated: bool = true) -> void: + request_json(endpoint, payload, callback, HTTPClient.METHOD_DELETE, authenticated) + func request_json(endpoint: String, payload: Dictionary, callback: Callable, method: int = HTTPClient.METHOD_GET, authenticated: bool = true) -> void: var request := HTTPRequest.new() request.timeout = REQUEST_TIMEOUT diff --git a/_Core/managers/TestLabManager.gd b/_Core/managers/TestLabManager.gd new file mode 100644 index 0000000..72f1288 --- /dev/null +++ b/_Core/managers/TestLabManager.gd @@ -0,0 +1,135 @@ +extends Node + +# 游戏内管理员测试实验室的唯一 API 入口。 +# 客户端只把 role=9 用于显示入口;实际授权仍由后端 TestLabGuard 完成。 + +signal status_changed(status: Dictionary) +signal status_failed(message: String) +signal operation_completed(message: String) + +var _status: Dictionary = {} +var _loading_status: bool = false +var _account_generation: int = -1 + +func _ready() -> void: + var auth_manager := get_node_or_null("/root/AuthManager") + if auth_manager != null and auth_manager.has_signal("auth_state_changed"): + var callback := Callable(self, "_on_auth_state_changed") + if not auth_manager.is_connected("auth_state_changed", callback): + auth_manager.connect("auth_state_changed", callback) + call_deferred("_refresh_after_ready") + +func get_status() -> Dictionary: + return _status.duplicate(true) + +func is_loaded() -> bool: + return not _status.is_empty() + +func is_admin() -> bool: + var auth_manager := get_node_or_null("/root/AuthManager") + if auth_manager == null or not auth_manager.has_method("is_authenticated") or not bool(auth_manager.call("is_authenticated")): + return false + if not auth_manager.has_method("get_current_user"): + return false + var user_variant: Variant = auth_manager.call("get_current_user") + if not (user_variant is Dictionary): + return false + var user: Dictionary = user_variant as Dictionary + return int(user.get("role", 0)) == 9 + +func refresh_status() -> void: + if not is_admin() or _loading_status: + return + var api := _api_client() + if api == null: + status_failed.emit("测试实验室服务不可用") + return + _loading_status = true + _account_generation = _current_account_generation() + api.call("get_json", "/admin/test-lab/status", Callable(self, "_on_status_response").bind(_account_generation), true) + +func create_actor(nickname: String, map_id: String, x: int, y: int, skin_id: String) -> void: + _mutate("post_json", "/admin/test-lab/actors", { + "nickname": nickname.strip_edges(), "mapId": map_id, "x": x, "y": y, "skinId": skin_id + }, "测试假人已上线") + +func update_actor(user_id: String, online: bool, map_id: String, x: int, y: int, skin_id: String) -> void: + _mutate("patch_json", "/admin/test-lab/actors/%s" % user_id.uri_encode(), { + "online": online, "mapId": map_id, "x": x, "y": y, "skinId": skin_id + }, "假人状态已同步") + +func set_room_policy(user_id: String, policy: String) -> void: + _mutate("patch_json", "/admin/test-lab/actors/%s/room-policy" % user_id.uri_encode(), { + "roomVisitPolicy": policy + }, "房间访问策略已更新") + +func send_message(user_id: String, scope: String, content: String, target_user_id: String = "") -> void: + var payload := {"scope": scope, "content": content.strip_edges()} + if scope == "private": + payload["targetUserId"] = target_user_id + _mutate("post_json", "/admin/test-lab/actors/%s/messages" % user_id.uri_encode(), payload, "测试消息已发送") + +func social_action(user_id: String, action: String, target_user_id: String) -> void: + _mutate("post_json", "/admin/test-lab/actors/%s/social" % user_id.uri_encode(), { + "action": action, "targetUserId": target_user_id + }, "社交操作已执行") + +func delete_actor(user_id: String) -> void: + _mutate("delete_json", "/admin/test-lab/actors/%s" % user_id.uri_encode(), {}, "测试假人已删除") + +func clear_all() -> void: + _mutate("delete_json", "/admin/test-lab/actors", {}, "测试实验室已清空") + +func _mutate(method_name: String, endpoint: String, payload: Dictionary, success_message: String) -> void: + if not is_admin(): + status_failed.emit("仅管理员可使用测试实验室") + return + var api := _api_client() + if api == null: + status_failed.emit("测试实验室服务不可用") + return + var request_generation := _current_account_generation() + api.call(method_name, endpoint, payload, Callable(self, "_on_mutation_response").bind(success_message, request_generation), true) + +func _refresh_after_ready() -> void: + if is_admin(): + refresh_status() + +func _on_auth_state_changed(is_authenticated: bool, _user: Dictionary) -> void: + if not is_authenticated or not is_admin(): + _status.clear() + _loading_status = false + _account_generation = -1 + status_changed.emit({}) + return + refresh_status() + +func _on_status_response(success: bool, response: Dictionary, error_info: Dictionary, request_generation: int) -> void: + if request_generation != _current_account_generation(): + return + _loading_status = false + if not success: + status_failed.emit(str(error_info.get("message", "测试实验室读取失败"))) + return + var data_variant: Variant = response.get("data", {}) + if not (data_variant is Dictionary): + status_failed.emit("测试实验室响应格式错误") + return + _status = (data_variant as Dictionary).duplicate(true) + status_changed.emit(get_status()) + +func _on_mutation_response(success: bool, _response: Dictionary, error_info: Dictionary, success_message: String, request_generation: int) -> void: + if request_generation != _current_account_generation(): + return + if not success: + status_failed.emit(str(error_info.get("message", "测试实验室操作失败"))) + return + operation_completed.emit(success_message) + refresh_status() + +func _api_client() -> Node: + return get_node_or_null("/root/ApiClient") + +func _current_account_generation() -> int: + var auth_manager := get_node_or_null("/root/AuthManager") + return int(auth_manager.call("get_account_generation")) if auth_manager != null and auth_manager.has_method("get_account_generation") else 0 diff --git a/_Core/managers/TestLabManager.gd.uid b/_Core/managers/TestLabManager.gd.uid new file mode 100644 index 0000000..14bc424 --- /dev/null +++ b/_Core/managers/TestLabManager.gd.uid @@ -0,0 +1 @@ +uid://cntd1faiax3br diff --git a/project.godot b/project.godot index b91bd68..341892f 100644 --- a/project.godot +++ b/project.godot @@ -37,6 +37,8 @@ SocialManager="*res://_Core/managers/SocialManager.gd" InteractionManager="*res://_Core/managers/InteractionManager.gd" TaskManager="*res://_Core/managers/TaskManager.gd" TaskBookPanel="*res://scenes/ui/TaskBookPanel.gd" +TestLabManager="*res://_Core/managers/TestLabManager.gd" +AdminTestLabPanel="*res://scenes/ui/AdminTestLabPanel.gd" [display] diff --git a/scenes/ui/AdminTestLabPanel.gd b/scenes/ui/AdminTestLabPanel.gd new file mode 100644 index 0000000..61ea7a4 --- /dev/null +++ b/scenes/ui/AdminTestLabPanel.gd @@ -0,0 +1,685 @@ +extends CanvasLayer + +# 仅 role=9 可打开的游戏内测试实验室。服务端会再次验证管理员身份。 + +const PANEL_SIZE: Vector2 = Vector2(1180, 820) +const MAP_OPTIONS: Array[Dictionary] = [ + {"id": "whale_port", "label": "鲸鱼港广场"}, + {"id": "work_zone", "label": "打工区"}, + {"id": "whale_cafe", "label": "鲸鱼咖啡馆"}, +] +const SKIN_OPTIONS: Array[Dictionary] = [ + {"id": "classic_whale", "label": "经典鲸鱼"}, + {"id": "human_whale_directional_v2_8x4", "label": "海风行者"}, + {"id": "girl_sailor_turnaround_v2_8x4", "label": "海风少女"}, + {"id": "panda_hero_8x4", "label": "熊猫侠"}, + {"id": "ordinary_man_male_8x4", "label": "普通人(男)"}, +] +const POLICY_OPTIONS: Array[Dictionary] = [ + {"id": "public", "label": "公开访问"}, + {"id": "friends", "label": "仅好友"}, + {"id": "closed", "label": "关闭访问"}, +] +const SCOPE_OPTIONS: Array[Dictionary] = [ + {"id": "local", "label": "当前地图公共消息"}, + {"id": "global", "label": "全服测试消息"}, + {"id": "private", "label": "私聊选定玩家"}, +] +const TEXT_COLOR: Color = Color(0.84, 0.92, 0.96) +const MUTED_COLOR: Color = Color(0.53, 0.68, 0.75) +const SURFACE_COLOR: Color = Color(0.035, 0.105, 0.16, 0.98) +const RAISED_COLOR: Color = Color(0.065, 0.16, 0.23, 1.0) +const ACCENT_COLOR: Color = Color(0.20, 0.80, 0.86, 1.0) +const DANGER_COLOR: Color = Color(0.82, 0.29, 0.25, 1.0) + +var _root: Control +var _panel: PanelContainer +var _status_label: Label +var _notice_label: Label +var _actor_list: VBoxContainer +var _selected_label: Label +var _target_select: OptionButton +var _create_nickname: LineEdit +var _create_map: OptionButton +var _create_x: SpinBox +var _create_y: SpinBox +var _create_skin: OptionButton +var _actor_online: CheckButton +var _actor_map: OptionButton +var _actor_x: SpinBox +var _actor_y: SpinBox +var _actor_skin: OptionButton +var _policy_select: OptionButton +var _message_scope: OptionButton +var _message_input: TextEdit +var _selected_actor_id: String = "" +var _is_open: bool = false +var _clear_confirm_deadline_ms: int = 0 + +func _ready() -> void: + layer = 48 + process_mode = Node.PROCESS_MODE_ALWAYS + add_to_group("whaletown_escape_dismissible") + _build_ui() + _connect_services() + +func _exit_tree() -> void: + var event_system := get_node_or_null("/root/EventSystem") + if event_system != null: + event_system.call("disconnect_event", EventNames.HUD_ADMIN_TEST_LAB_REQUESTED, _on_open_requested, self) + var manager := get_node_or_null("/root/TestLabManager") + if manager != null: + _disconnect_signal(manager, "status_changed", Callable(self, "_on_status_changed")) + _disconnect_signal(manager, "status_failed", Callable(self, "_on_status_failed")) + _disconnect_signal(manager, "operation_completed", Callable(self, "_on_operation_completed")) + +func show_panel() -> void: + if not _is_admin(): + hide_panel() + return + _is_open = true + _root.visible = true + _set_notice("正在读取测试实验室…", MUTED_COLOR) + var manager := _manager() + if manager != null: + if manager.has_method("is_loaded") and bool(manager.call("is_loaded")): + var status_variant: Variant = manager.call("get_status") + if status_variant is Dictionary: + _on_status_changed(status_variant as Dictionary) + if manager.has_method("refresh_status"): + manager.call("refresh_status") + +func hide_panel() -> void: + _is_open = false + if is_instance_valid(_root): + _root.visible = false + +func is_escape_dismissible() -> bool: + return _is_open + +func get_escape_priority() -> int: + return 850 + +func request_escape_close() -> void: + hide_panel() + +func _input(event: InputEvent) -> void: + if not _is_open or get_viewport().is_input_handled(): + return + if event.is_action_pressed("ui_cancel"): + hide_panel() + get_viewport().set_input_as_handled() + +func _connect_services() -> void: + var event_system := get_node_or_null("/root/EventSystem") + if event_system != null: + event_system.call("connect_event", EventNames.HUD_ADMIN_TEST_LAB_REQUESTED, _on_open_requested, self) + var manager := _manager() + if manager == null: + return + _connect_signal(manager, "status_changed", Callable(self, "_on_status_changed")) + _connect_signal(manager, "status_failed", Callable(self, "_on_status_failed")) + _connect_signal(manager, "operation_completed", Callable(self, "_on_operation_completed")) + +func _build_ui() -> void: + _root = Control.new() + _root.name = "AdminTestLabRoot" + _root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _root.visible = false + add_child(_root) + + var dim := ColorRect.new() + dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + dim.color = Color(0.005, 0.02, 0.04, 0.78) + dim.mouse_filter = Control.MOUSE_FILTER_STOP + dim.gui_input.connect(func(event: InputEvent) -> void: + if event is InputEventMouseButton and (event as InputEventMouseButton).pressed: + hide_panel() + ) + _root.add_child(dim) + + _panel = PanelContainer.new() + _panel.custom_minimum_size = PANEL_SIZE + _panel.set_anchors_preset(Control.PRESET_CENTER) + _panel.offset_left = -PANEL_SIZE.x * 0.5 + _panel.offset_top = -PANEL_SIZE.y * 0.5 + _panel.offset_right = PANEL_SIZE.x * 0.5 + _panel.offset_bottom = PANEL_SIZE.y * 0.5 + _panel.mouse_filter = Control.MOUSE_FILTER_STOP + _panel.add_theme_stylebox_override("panel", _style(SURFACE_COLOR, 24, Color(0.18, 0.48, 0.58, 0.86), 1)) + _root.add_child(_panel) + + var margin := MarginContainer.new() + margin.add_theme_constant_override("margin_left", 24) + margin.add_theme_constant_override("margin_top", 20) + margin.add_theme_constant_override("margin_right", 24) + margin.add_theme_constant_override("margin_bottom", 20) + _panel.add_child(margin) + var layout := VBoxContainer.new() + layout.add_theme_constant_override("separation", 14) + margin.add_child(layout) + layout.add_child(_build_header()) + _notice_label = Label.new() + _notice_label.visible = false + _notice_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _notice_label.add_theme_font_size_override("font_size", 14) + layout.add_child(_notice_label) + var body := HSplitContainer.new() + body.size_flags_vertical = Control.SIZE_EXPAND_FILL + body.split_offset = 358 + layout.add_child(body) + body.add_child(_build_roster_column()) + body.add_child(_build_control_column()) + var footer := HBoxContainer.new() + footer.add_theme_constant_override("separation", 10) + layout.add_child(footer) + var hint := Label.new() + hint.text = "仅开发/测试环境可用;所有动作由服务端再次校验。" + hint.size_flags_horizontal = Control.SIZE_EXPAND_FILL + hint.add_theme_color_override("font_color", MUTED_COLOR) + hint.add_theme_font_size_override("font_size", 13) + footer.add_child(hint) + footer.add_child(_button("刷新", ACCENT_COLOR, _on_refresh_pressed, Vector2(86, 38))) + footer.add_child(_button("清空实验室", DANGER_COLOR, _on_clear_pressed, Vector2(126, 38))) + var esc := Label.new() + esc.text = "[Esc] 关闭" + esc.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + esc.add_theme_color_override("font_color", MUTED_COLOR) + esc.add_theme_font_size_override("font_size", 13) + footer.add_child(esc) + +func _build_header() -> Control: + var header := PanelContainer.new() + header.custom_minimum_size = Vector2(0, 92) + header.add_theme_stylebox_override("panel", _style(Color(0.045, 0.19, 0.27, 1.0), 16, ACCENT_COLOR.darkened(0.30), 1)) + var margin := MarginContainer.new() + margin.add_theme_constant_override("margin_left", 20) + margin.add_theme_constant_override("margin_top", 12) + margin.add_theme_constant_override("margin_right", 14) + margin.add_theme_constant_override("margin_bottom", 12) + header.add_child(margin) + var row := HBoxContainer.new() + row.alignment = BoxContainer.ALIGNMENT_CENTER + margin.add_child(row) + var title_box := VBoxContainer.new() + title_box.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(title_box) + var eyebrow := Label.new() + eyebrow.text = "管理员 · 多人互动验证" + eyebrow.add_theme_color_override("font_color", Color(0.51, 0.86, 0.89, 1.0)) + eyebrow.add_theme_font_size_override("font_size", 13) + title_box.add_child(eyebrow) + var title := Label.new() + title.text = "测试实验室" + title.add_theme_color_override("font_color", Color.WHITE) + title.add_theme_font_size_override("font_size", 30) + title_box.add_child(title) + _status_label = Label.new() + _status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + _status_label.add_theme_color_override("font_color", TEXT_COLOR) + _status_label.add_theme_font_size_override("font_size", 14) + row.add_child(_status_label) + row.add_child(_button("关闭", Color(0.20, 0.36, 0.44, 1.0), hide_panel, Vector2(78, 38))) + return header + +func _build_roster_column() -> Control: + var column := VBoxContainer.new() + column.custom_minimum_size = Vector2(336, 0) + column.add_theme_constant_override("separation", 12) + var create_title := Label.new() + create_title.text = "投放假人" + create_title.add_theme_color_override("font_color", TEXT_COLOR) + create_title.add_theme_font_size_override("font_size", 20) + column.add_child(create_title) + var create_box := PanelContainer.new() + create_box.add_theme_stylebox_override("panel", _style(RAISED_COLOR, 14, Color(0.16, 0.36, 0.46, 0.72), 1)) + column.add_child(create_box) + var create_margin := MarginContainer.new() + create_margin.add_theme_constant_override("margin_left", 14) + create_margin.add_theme_constant_override("margin_top", 14) + create_margin.add_theme_constant_override("margin_right", 14) + create_margin.add_theme_constant_override("margin_bottom", 14) + create_box.add_child(create_margin) + var create_form := VBoxContainer.new() + create_form.add_theme_constant_override("separation", 8) + create_margin.add_child(create_form) + _create_nickname = _line_edit("显示昵称,例如 社交测试鲸") + create_form.add_child(_field("昵称", _create_nickname)) + _create_map = _option(MAP_OPTIONS) + create_form.add_child(_field("地图", _create_map)) + var create_position := HBoxContainer.new() + create_position.add_theme_constant_override("separation", 8) + _create_x = _spin(1280) + _create_y = _spin(960) + create_position.add_child(_field("X", _create_x)) + create_position.add_child(_field("Y", _create_y)) + create_form.add_child(create_position) + _create_skin = _option(SKIN_OPTIONS) + create_form.add_child(_field("皮肤", _create_skin)) + create_form.add_child(_button("创建并上线", ACCENT_COLOR, _on_create_pressed, Vector2(0, 40))) + var roster_title := Label.new() + roster_title.text = "假人编队" + roster_title.add_theme_color_override("font_color", TEXT_COLOR) + roster_title.add_theme_font_size_override("font_size", 20) + column.add_child(roster_title) + var scroll := ScrollContainer.new() + scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + column.add_child(scroll) + _actor_list = VBoxContainer.new() + _actor_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _actor_list.add_theme_constant_override("separation", 7) + scroll.add_child(_actor_list) + return column + +func _build_control_column() -> Control: + var column := VBoxContainer.new() + column.add_theme_constant_override("separation", 12) + var title_row := HBoxContainer.new() + column.add_child(title_row) + _selected_label = Label.new() + _selected_label.text = "选择一名假人开始控制" + _selected_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _selected_label.add_theme_color_override("font_color", TEXT_COLOR) + _selected_label.add_theme_font_size_override("font_size", 20) + title_row.add_child(_selected_label) + title_row.add_child(_button("删除假人", DANGER_COLOR, _on_delete_selected_pressed, Vector2(96, 34))) + var controls_scroll := ScrollContainer.new() + controls_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + controls_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + column.add_child(controls_scroll) + var controls := VBoxContainer.new() + controls.size_flags_horizontal = Control.SIZE_EXPAND_FILL + controls.add_theme_constant_override("separation", 12) + controls_scroll.add_child(controls) + controls.add_child(_build_presence_section()) + controls.add_child(_build_room_section()) + controls.add_child(_build_social_section()) + return column + +func _build_presence_section() -> Control: + var section := _section("在线与位置") + var content := section.get_node("Margin/Layout/Content") as VBoxContainer + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 10) + _actor_online = CheckButton.new() + _actor_online.text = "在线" + _actor_online.button_pressed = true + _actor_online.add_theme_color_override("font_color", TEXT_COLOR) + row.add_child(_actor_online) + row.add_spacer(false) + row.add_child(_button("同步状态", ACCENT_COLOR, _on_update_pressed, Vector2(104, 36))) + content.add_child(row) + _actor_map = _option(MAP_OPTIONS) + content.add_child(_field("地图", _actor_map)) + var position := HBoxContainer.new() + position.add_theme_constant_override("separation", 10) + _actor_x = _spin(1280) + _actor_y = _spin(960) + position.add_child(_field("X", _actor_x)) + position.add_child(_field("Y", _actor_y)) + content.add_child(position) + _actor_skin = _option(SKIN_OPTIONS) + content.add_child(_field("皮肤", _actor_skin)) + return section + +func _build_room_section() -> Control: + var section := _section("房间访问") + var content := section.get_node("Margin/Layout/Content") as VBoxContainer + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 10) + _policy_select = _option(POLICY_OPTIONS) + _policy_select.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(_policy_select) + row.add_child(_button("保存权限", Color(0.20, 0.42, 0.55, 1.0), _on_policy_pressed, Vector2(102, 36))) + content.add_child(row) + return section + +func _build_social_section() -> Control: + var section := _section("社交注入") + var content := section.get_node("Margin/Layout/Content") as VBoxContainer + var target_hint := Label.new() + target_hint.text = "目标真实在线玩家(私聊、好友和拉黑操作必选)" + target_hint.add_theme_color_override("font_color", MUTED_COLOR) + target_hint.add_theme_font_size_override("font_size", 13) + content.add_child(target_hint) + _target_select = OptionButton.new() + _target_select.custom_minimum_size = Vector2(0, 36) + _target_select.add_item("暂无在线真实玩家") + _target_select.add_theme_stylebox_override("normal", _style(Color(0.03, 0.10, 0.15, 1.0), 10, Color(0.20, 0.42, 0.52, 0.8), 1)) + _target_select.add_theme_color_override("font_color", TEXT_COLOR) + content.add_child(_target_select) + _message_scope = _option(SCOPE_OPTIONS) + content.add_child(_field("消息范围", _message_scope)) + _message_input = TextEdit.new() + _message_input.custom_minimum_size = Vector2(0, 78) + _message_input.placeholder_text = "测试消息不会同步到 Zulip、任务或鲸币。" + _message_input.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY + _message_input.add_theme_color_override("font_color", TEXT_COLOR) + _message_input.add_theme_color_override("font_placeholder_color", MUTED_COLOR) + _message_input.add_theme_stylebox_override("normal", _style(Color(0.025, 0.085, 0.13, 1.0), 10, Color(0.20, 0.42, 0.52, 0.8), 1)) + content.add_child(_message_input) + content.add_child(_button("发送测试消息", ACCENT_COLOR, _on_message_pressed, Vector2(0, 38))) + var social_row := HBoxContainer.new() + social_row.add_theme_constant_override("separation", 8) + social_row.add_child(_button("发起好友申请", Color(0.20, 0.42, 0.55, 1.0), func() -> void: _on_social_pressed("friend_request"), Vector2(116, 36))) + social_row.add_child(_button("接受", Color(0.20, 0.42, 0.55, 1.0), func() -> void: _on_social_pressed("friend_accept"), Vector2(72, 36))) + social_row.add_child(_button("拒绝", Color(0.20, 0.42, 0.55, 1.0), func() -> void: _on_social_pressed("friend_reject"), Vector2(72, 36))) + social_row.add_child(_button("拉黑", DANGER_COLOR, func() -> void: _on_social_pressed("block"), Vector2(72, 36))) + content.add_child(social_row) + return section + +func _section(title_text: String) -> PanelContainer: + var section := PanelContainer.new() + section.add_theme_stylebox_override("panel", _style(RAISED_COLOR, 14, Color(0.16, 0.36, 0.46, 0.72), 1)) + var margin := MarginContainer.new() + margin.name = "Margin" + margin.add_theme_constant_override("margin_left", 14) + margin.add_theme_constant_override("margin_top", 12) + margin.add_theme_constant_override("margin_right", 14) + margin.add_theme_constant_override("margin_bottom", 14) + section.add_child(margin) + var layout := VBoxContainer.new() + layout.name = "Layout" + layout.add_theme_constant_override("separation", 9) + margin.add_child(layout) + var title := Label.new() + title.text = title_text + title.add_theme_color_override("font_color", Color(0.68, 0.91, 0.94, 1.0)) + title.add_theme_font_size_override("font_size", 16) + layout.add_child(title) + var content := VBoxContainer.new() + content.name = "Content" + content.add_theme_constant_override("separation", 7) + layout.add_child(content) + return section + +func _field(label_text: String, control: Control) -> VBoxContainer: + var field := VBoxContainer.new() + field.size_flags_horizontal = Control.SIZE_EXPAND_FILL + field.add_theme_constant_override("separation", 3) + var label := Label.new() + label.text = label_text + label.add_theme_color_override("font_color", MUTED_COLOR) + label.add_theme_font_size_override("font_size", 12) + field.add_child(label) + control.size_flags_horizontal = Control.SIZE_EXPAND_FILL + field.add_child(control) + return field + +func _line_edit(placeholder: String) -> LineEdit: + var input := LineEdit.new() + input.placeholder_text = placeholder + input.custom_minimum_size = Vector2(0, 36) + input.add_theme_color_override("font_color", TEXT_COLOR) + input.add_theme_color_override("font_placeholder_color", MUTED_COLOR) + input.add_theme_stylebox_override("normal", _style(Color(0.025, 0.085, 0.13, 1.0), 10, Color(0.20, 0.42, 0.52, 0.8), 1)) + input.add_theme_stylebox_override("focus", _style(Color(0.025, 0.085, 0.13, 1.0), 10, ACCENT_COLOR, 1)) + return input + +func _spin(initial_value: int) -> SpinBox: + var spin := SpinBox.new() + spin.min_value = -8192 + spin.max_value = 8192 + spin.step = 1 + spin.value = initial_value + spin.custom_minimum_size = Vector2(0, 36) + spin.add_theme_color_override("font_color", TEXT_COLOR) + return spin + +func _option(items: Array[Dictionary]) -> OptionButton: + var select := OptionButton.new() + select.custom_minimum_size = Vector2(0, 36) + select.add_theme_stylebox_override("normal", _style(Color(0.025, 0.085, 0.13, 1.0), 10, Color(0.20, 0.42, 0.52, 0.8), 1)) + select.add_theme_color_override("font_color", TEXT_COLOR) + for item: Dictionary in items: + select.add_item(str(item.get("label", ""))) + select.set_item_metadata(select.item_count - 1, str(item.get("id", ""))) + return select + +func _button(text_value: String, color: Color, callback: Callable, size: Vector2) -> Button: + var button := Button.new() + button.text = text_value + button.custom_minimum_size = size + button.focus_mode = Control.FOCUS_NONE + button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND + button.add_theme_color_override("font_color", Color.WHITE) + button.add_theme_color_override("font_hover_color", Color.WHITE) + button.add_theme_stylebox_override("normal", _style(color, 10)) + button.add_theme_stylebox_override("hover", _style(color.lightened(0.12), 10)) + button.add_theme_stylebox_override("pressed", _style(color.darkened(0.12), 10)) + button.pressed.connect(callback) + return button + +func _style(color: Color, radius: int, border_color: Color = Color.TRANSPARENT, border_width: int = 0) -> 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 + if border_width > 0: + style.border_width_left = border_width + style.border_width_top = border_width + style.border_width_right = border_width + style.border_width_bottom = border_width + style.border_color = border_color + return style + +func _on_open_requested(_data: Variant = null) -> void: + show_panel() + +func _on_refresh_pressed() -> void: + var manager := _manager() + if manager != null: + manager.call("refresh_status") + +func _on_create_pressed() -> void: + if _create_nickname.text.strip_edges().is_empty(): + _set_notice("请输入测试假人的显示昵称", DANGER_COLOR.lightened(0.18)) + return + var manager := _manager() + if manager != null: + manager.call("create_actor", _create_nickname.text, _selected_option(_create_map), int(_create_x.value), int(_create_y.value), _selected_option(_create_skin)) + +func _on_update_pressed() -> void: + if not _ensure_selected_actor(): + return + var manager := _manager() + if manager != null: + manager.call("update_actor", _selected_actor_id, _actor_online.button_pressed, _selected_option(_actor_map), int(_actor_x.value), int(_actor_y.value), _selected_option(_actor_skin)) + +func _on_policy_pressed() -> void: + if not _ensure_selected_actor(): + return + var manager := _manager() + if manager != null: + manager.call("set_room_policy", _selected_actor_id, _selected_option(_policy_select)) + +func _on_message_pressed() -> void: + if not _ensure_selected_actor(): + return + var content := _message_input.text.strip_edges() + if content.is_empty(): + _set_notice("请输入测试消息", DANGER_COLOR.lightened(0.18)) + return + var scope := _selected_option(_message_scope) + var target_id := _selected_target_id() + if scope == "private" and target_id.is_empty(): + _set_notice("私聊需要先选择在线真实玩家", DANGER_COLOR.lightened(0.18)) + return + var manager := _manager() + if manager != null: + manager.call("send_message", _selected_actor_id, scope, content, target_id) + +func _on_social_pressed(action: String) -> void: + if not _ensure_selected_actor(): + return + var target_id := _selected_target_id() + if target_id.is_empty(): + _set_notice("请先选择在线真实玩家", DANGER_COLOR.lightened(0.18)) + return + var manager := _manager() + if manager != null: + manager.call("social_action", _selected_actor_id, action, target_id) + +func _on_clear_pressed() -> void: + var now_ms: int = Time.get_ticks_msec() + if now_ms > _clear_confirm_deadline_ms: + _clear_confirm_deadline_ms = now_ms + 4000 + _set_notice("再次点击“清空实验室”以确认;这会删除全部测试账号和关联测试数据。", DANGER_COLOR.lightened(0.18)) + return + _clear_confirm_deadline_ms = 0 + var manager := _manager() + if manager != null: + manager.call("clear_all") + +func _on_delete_selected_pressed() -> void: + if not _ensure_selected_actor(): + return + var manager := _manager() + if manager != null: + manager.call("delete_actor", _selected_actor_id) + +func _on_status_changed(status: Dictionary) -> void: + if not _is_open: + return + var enabled: bool = bool(status.get("enabled", false)) + var environment: String = str(status.get("environment", "未知")) + var actors_variant: Variant = status.get("actors", []) + var actor_count: int = (actors_variant as Array).size() if actors_variant is Array else 0 + var max_actors: int = int(status.get("maxActors", 20)) + _status_label.text = "%s · 假人 %d/%d" % [environment, actor_count, max_actors] + _set_notice("实验室已启用" if enabled else "测试实验室未启用(仅 development/test 且 TEST_LAB_ENABLED=true)", ACCENT_COLOR if enabled else DANGER_COLOR.lightened(0.18)) + _render_actor_list(actors_variant) + _render_online_players(status.get("onlinePlayers", [])) + +func _on_status_failed(message: String) -> void: + if _is_open: + _set_notice(message, DANGER_COLOR.lightened(0.18)) + +func _on_operation_completed(message: String) -> void: + if _is_open: + _message_input.text = "" + _set_notice(message, ACCENT_COLOR) + +func _render_actor_list(actors_variant: Variant) -> void: + _clear_children(_actor_list) + if not (actors_variant is Array) or (actors_variant as Array).is_empty(): + var empty := Label.new() + empty.text = "尚未投放测试假人" + empty.add_theme_color_override("font_color", MUTED_COLOR) + empty.add_theme_font_size_override("font_size", 14) + _actor_list.add_child(empty) + _selected_actor_id = "" + _selected_label.text = "选择一名假人开始控制" + return + var found_selected: bool = false + for actor_variant: Variant in actors_variant as Array: + if not (actor_variant is Dictionary): + continue + var actor: Dictionary = actor_variant as Dictionary + var actor_id: String = str(actor.get("userId", "")) + if actor_id == _selected_actor_id: + found_selected = true + _actor_list.add_child(_actor_row(actor)) + if not found_selected: + var first_actor_variant: Variant = (actors_variant as Array)[0] + if first_actor_variant is Dictionary: + _select_actor(first_actor_variant as Dictionary) + +func _actor_row(actor: Dictionary) -> Button: + var actor_id: String = str(actor.get("userId", "")) + var nickname: String = str(actor.get("nickname", actor.get("username", "测试假人"))) + var map_id: String = str(actor.get("mapId", "")) + var online: bool = bool(actor.get("online", false)) + var selected: bool = actor_id == _selected_actor_id + var button := Button.new() + button.custom_minimum_size = Vector2(0, 64) + button.text = "%s · %s\n%s · %d, %d" % ["●" if online else "○", nickname, map_id, int(actor.get("x", 0)), int(actor.get("y", 0))] + button.alignment = HORIZONTAL_ALIGNMENT_LEFT + button.focus_mode = Control.FOCUS_NONE + button.add_theme_font_size_override("font_size", 14) + button.add_theme_color_override("font_color", TEXT_COLOR) + button.add_theme_stylebox_override("normal", _style(Color(0.08, 0.23, 0.30, 1.0) if selected else Color(0.05, 0.14, 0.20, 1.0), 10, ACCENT_COLOR if selected else Color(0.16, 0.36, 0.46, 0.52), 1)) + button.add_theme_stylebox_override("hover", _style(Color(0.10, 0.29, 0.37, 1.0), 10, ACCENT_COLOR.darkened(0.10), 1)) + button.pressed.connect(func() -> void: _select_actor(actor)) + return button + +func _select_actor(actor: Dictionary) -> void: + _selected_actor_id = str(actor.get("userId", "")) + var nickname: String = str(actor.get("nickname", actor.get("username", "测试假人"))) + _selected_label.text = "控制 · %s" % nickname + _actor_online.button_pressed = bool(actor.get("online", false)) + _actor_x.value = int(actor.get("x", 1280)) + _actor_y.value = int(actor.get("y", 960)) + _select_option(_actor_map, str(actor.get("mapId", "whale_port"))) + _select_option(_actor_skin, str(actor.get("skinId", "classic_whale"))) + _select_option(_policy_select, str(actor.get("roomVisitPolicy", "public"))) + var manager := _manager() + if manager != null and manager.has_method("get_status"): + var status_variant: Variant = manager.call("get_status") + if status_variant is Dictionary: + _render_actor_list((status_variant as Dictionary).get("actors", [])) + +func _render_online_players(players_variant: Variant) -> void: + _target_select.clear() + if not (players_variant is Array) or (players_variant as Array).is_empty(): + _target_select.add_item("暂无在线真实玩家") + _target_select.set_item_metadata(0, "") + return + for player_variant: Variant in players_variant as Array: + if not (player_variant is Dictionary): + continue + var player: Dictionary = player_variant as Dictionary + var user_id: String = str(player.get("userId", "")) + var username: String = str(player.get("username", user_id)) + var map_id: String = str(player.get("mapId", "")) + _target_select.add_item("%s · %s" % [username, map_id]) + _target_select.set_item_metadata(_target_select.item_count - 1, user_id) + +func _selected_option(select: OptionButton) -> String: + if select.selected < 0: + return "" + return str(select.get_item_metadata(select.selected)) + +func _selected_target_id() -> String: + return _selected_option(_target_select) + +func _select_option(select: OptionButton, value: String) -> void: + for index: int in range(select.item_count): + if str(select.get_item_metadata(index)) == value: + select.select(index) + return + if select.item_count > 0: + select.select(0) + +func _ensure_selected_actor() -> bool: + if not _selected_actor_id.is_empty(): + return true + _set_notice("请先从左侧选择测试假人", DANGER_COLOR.lightened(0.18)) + return false + +func _set_notice(message: String, color: Color) -> void: + _notice_label.visible = not message.strip_edges().is_empty() + _notice_label.text = message + _notice_label.add_theme_color_override("font_color", color) + +func _clear_children(node: Node) -> void: + for child: Node in node.get_children(): + child.queue_free() + +func _is_admin() -> bool: + var manager := _manager() + return manager != null and manager.has_method("is_admin") and bool(manager.call("is_admin")) + +func _manager() -> Node: + return get_node_or_null("/root/TestLabManager") + +func _connect_signal(source: Node, signal_name: StringName, callback: Callable) -> void: + if source.has_signal(signal_name) and not source.is_connected(signal_name, callback): + source.connect(signal_name, callback) + +func _disconnect_signal(source: Node, signal_name: StringName, callback: Callable) -> void: + if source.has_signal(signal_name) and source.is_connected(signal_name, callback): + source.disconnect(signal_name, callback) diff --git a/scenes/ui/AdminTestLabPanel.gd.uid b/scenes/ui/AdminTestLabPanel.gd.uid new file mode 100644 index 0000000..08d949c --- /dev/null +++ b/scenes/ui/AdminTestLabPanel.gd.uid @@ -0,0 +1 @@ +uid://cma7is74nvb3u diff --git a/scenes/ui/HudShortcutIcon.gd b/scenes/ui/HudShortcutIcon.gd index fb589a6..5fdc1c8 100644 --- a/scenes/ui/HudShortcutIcon.gd +++ b/scenes/ui/HudShortcutIcon.gd @@ -42,6 +42,8 @@ func _draw() -> void: _draw_friends() "settings": _draw_settings() + "admin": + _draw_admin() _: _draw_map() @@ -101,6 +103,13 @@ func _draw_settings() -> void: _draw_arc(center, 4.9, 0.0, TAU, 32, LINE_WIDTH) _draw_arc(center, 2.0, 0.0, TAU, 24, DETAIL_WIDTH) +func _draw_admin() -> void: + _draw_round_rect(Rect2(7.0, 5.8, 13.0, 15.6), 3.0) + _draw_line(Vector2(9.5, 11.0), Vector2(17.5, 11.0), DETAIL_WIDTH) + _draw_line(Vector2(9.5, 14.5), Vector2(15.3, 14.5), DETAIL_WIDTH) + _draw_arc(Vector2(13.5, 20.5), 3.5, PI, TAU, 16, DETAIL_WIDTH) + _draw_red_dot(Vector2(20.2, 6.0)) + func _draw_round_rect(rect: Rect2, radius: float, width: float = LINE_WIDTH) -> void: var left := rect.position.x var top := rect.position.y diff --git a/scenes/ui/PlayerHud.gd b/scenes/ui/PlayerHud.gd index b243cda..7739a52 100644 --- a/scenes/ui/PlayerHud.gd +++ b/scenes/ui/PlayerHud.gd @@ -8,7 +8,7 @@ extends Control # ============================================================================ const HUD_MARGIN: Vector2 = Vector2(16, 16) -const SHORTCUT_BAR_SIZE: Vector2 = Vector2(406, 86) +const SHORTCUT_BAR_SIZE: Vector2 = Vector2(476, 86) const PLAYER_PROFILE_BUTTON_SIZE: Vector2 = Vector2(210, 78) const PLAYER_AVATAR_SIZE: Vector2 = Vector2(58, 58) const HUD_SEPARATION: int = 18 @@ -26,6 +26,8 @@ var _avatarPanel: PanelContainer var _walletRefreshTimer: Timer var _taskShortcutButton: Button var _taskRewardBadge: Label +var _shortcutRow: HBoxContainer +var _adminShortcutButton: Button var _currentUsername: String = "玩家" var _walletBalance: int = 0 @@ -39,6 +41,7 @@ func _ready() -> void: _subscribe_to_events() _load_current_user() _refresh_wallet() + call_deferred("_refresh_admin_shortcut") func _exit_tree() -> void: var eventSystem := _get_event_system() @@ -94,6 +97,7 @@ func _build_shortcut_bar() -> PanelContainer: panel.add_child(margin) var row := HBoxContainer.new() + _shortcutRow = row row.alignment = BoxContainer.ALIGNMENT_CENTER row.add_theme_constant_override("separation", 8) margin.add_child(row) @@ -121,6 +125,7 @@ func _build_shortcut_bar() -> PanelContainer: row.add_child(_create_shortcut_button("friends", "好友", _on_friends_pressed)) row.add_child(_create_shortcut_button("activity", "通知", _on_notifications_pressed)) row.add_child(_create_shortcut_button("settings", "设置", _on_settings_pressed)) + _refresh_admin_shortcut() return panel @@ -296,9 +301,11 @@ func _on_auth_state_changed(isAuthenticated: bool, user: Dictionary) -> void: if not isAuthenticated: _set_username("玩家") _reset_wallet() + _refresh_admin_shortcut() return _apply_auth_user_payload({"user": user}) _refresh_wallet() + _refresh_admin_shortcut() func _apply_auth_user_payload(data: Dictionary) -> void: var userVariant: Variant = data.get("user", {}) @@ -306,11 +313,13 @@ func _apply_auth_user_payload(data: Dictionary) -> void: var username := str((userVariant as Dictionary).get("username", "")).strip_edges() if not username.is_empty(): _set_username(username) + _refresh_admin_shortcut() func _on_auth_logout(_data: Variant = null) -> void: _set_username("玩家") _reset_wallet() _on_task_board_changed({}) + _refresh_admin_shortcut() func _on_task_board_changed(board: Dictionary) -> void: if not is_instance_valid(_taskShortcutButton): @@ -482,6 +491,35 @@ func _on_avatar_pressed() -> void: if eventSystem != null: eventSystem.call("emit_event", EventNames.HUD_SETTINGS_REQUESTED, {}) +func _on_admin_test_lab_pressed() -> void: + var eventSystem := _get_event_system() + if eventSystem != null: + eventSystem.call("emit_event", EventNames.HUD_ADMIN_TEST_LAB_REQUESTED, {}) + +func _refresh_admin_shortcut() -> void: + if not is_instance_valid(_shortcutRow): + return + var shouldShow: bool = _is_current_user_admin() + if shouldShow and not is_instance_valid(_adminShortcutButton): + _adminShortcutButton = _create_shortcut_button("admin", "管理", _on_admin_test_lab_pressed) + _adminShortcutButton.tooltip_text = "管理员测试实验室" + _shortcutRow.add_child(_adminShortcutButton) + elif not shouldShow and is_instance_valid(_adminShortcutButton): + _adminShortcutButton.queue_free() + _adminShortcutButton = null + +func _is_current_user_admin() -> bool: + var authManager := get_node_or_null("/root/AuthManager") + if authManager == null or not authManager.has_method("is_authenticated") or not bool(authManager.call("is_authenticated")): + return false + if not authManager.has_method("get_current_user"): + return false + var userVariant: Variant = authManager.call("get_current_user") + if not (userVariant is Dictionary): + return false + var user: Dictionary = userVariant as Dictionary + return int(user.get("role", 0)) == 9 + func _emit_status_message(message: String) -> void: var eventSystem := _get_event_system() if eventSystem != null: