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)