Files
whale-town-front-v2/scenes/ui/AdminTestLabPanel.gd
2026-07-22 12:16:31 +08:00

686 lines
28 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)