# AppFlow (P10) —— 登录 → 选人 → 进游戏 的串场控制器。 # # 把它做场景根:一个 M2Client 贯穿全程,LoadingScreen 常驻。 # var af := preload("res://app_flow.gd").new() # add_child(af) # af.start(assets_root) # assets_root 传给 game_scene # # 状态机:LOGIN → SELECT → GAME。M2Client 信号驱动切屏。 # 断线:登录/选人阶段回到登录;游戏阶段保留 GameScene,显示重连遮罩, # 重连成功后自动重新进入上次角色,避免短暂闪回选人界面。 extends Node const ServerInfoRes = preload("res://net/serverinfo.gd") const ChannelStatus = preload("res://net/channel_status.gd") const LoadingScreen = preload("res://ui/loading_screen.gd") const CharSelectScreen = preload("res://ui/char_select_screen.gd") const GameScene = preload("res://game_scene.gd") const AppLifecycle = preload("res://app_lifecycle.gd") const ReconnectUI = preload("res://ui/reconnect_ui.gd") const UiProfile = preload("res://ui/ui_profile.gd") enum { LOGIN, SELECT, GAME } var client: Node var serverinfo: RefCounted var _state := LOGIN var _assets := "" var _ui: CanvasLayer # 登录 / 选人的临时界面层 var _loading: CanvasLayer var _game: Node var _lifecycle: Node # 全流程唯一生命周期协调者 var _reconnect: Node var build_game_scene := true # 测试里置 false,只跑状态机不建重场景 var _sel_server := 0 var _sel_channel := 1 var _id := OS.get_environment("MT_ACCOUNT") var _pw := OS.get_environment("MT_PASSWORD") var _chars: Array = [] var _selected_char_slot := -1 var _reconnecting_game := false var _mark_redl_after := 0 # guild-mark re-download cooldown (ticks_msec) # MT_AUTOLOGIN=1:进 LOGIN 自动连接,收到角色列表自动选第一个(冒烟 / CI 用)。 var auto_login := OS.get_environment("MT_AUTOLOGIN") == "1" and not _id.is_empty() and not _pw.is_empty() func start(assets_root: String = "", injected_client: Node = null) -> void: _assets = assets_root serverinfo = ServerInfoRes.new() serverinfo.load_file("res://serverlist.txt") # 有就用,没有用内置 if injected_client != null: client = injected_client elif ClassDB.class_exists("M2Client"): client = ClassDB.instantiate("M2Client") if client == null: push_error("AppFlow: M2Client 未注册且未注入 client") return if client.get_parent() == null: add_child(client) _wire_client() _loading = LoadingScreen.new() add_child(_loading) _loading.setup(client) # §9.3:生命周期属于 AppFlow,而不是会被切屏销毁的 GameScene。 # 这样登录、选人、进游戏始终只有一个通知入口;Audio 在 GameScene # 完成装配后再绑定,客户端从 start() 起就已经绑定。 _lifecycle = AppLifecycle.new() _lifecycle.name = "AppLifecycle" add_child(_lifecycle) _lifecycle.bind(client) _reconnect = ReconnectUI.new() _reconnect.name = "ReconnectUI" add_child(_reconnect) _reconnect.setup(client) _reconnect.set_mobile_mode(UiProfile.resolve() == UiProfile.Mode.MOBILE) _goto_login() if auto_login: call_deferred("_do_connect") func state() -> int: return _state # --- client 信号 ------------------------------------------------------------- func _wire_client() -> void: for sig in ["char_list", "char_name_changed", "entered_game", "disconnected", "login_failed"]: if client.has_signal(sig): client.connect(sig, Callable(self, "_on_" + sig)) # 建 / 删号结果 —— 一次性接,转发给当前选人页 for sig in ["char_created", "char_create_failed", "char_deleted", "char_delete_failed"]: if client.has_signal(sig): client.connect(sig, Callable(self, "_forward_char_evt").bind(sig)) func _on_char_list(list: Array) -> void: _chars = list if _reconnecting_game: # A full reconnect replays the character list before it can enter the # previous character. Do not briefly replace the live game with SELECT; # re-enter the remembered slot and keep the reconnect mask on screen. var slot := _selected_char_slot if slot < 0 and not _chars.is_empty(): slot = int(_chars[0].get("index", 0)) if slot >= 0: _enter_character(slot) return # 已在选人页(建号 / 删号后 M2Client 会重发 char_list)——只刷数据,别整屏重建 if _state == SELECT and _ui: var sc: Node = _ui.get_node_or_null("CharSelect") if sc and sc.has_method("set_chars"): sc.set_chars(list) return _goto_select() if auto_login and not _chars.is_empty(): var idx := int(_chars[0].get("index", 0)) if client.has_method("enter_game"): client.enter_game(idx) elif client.has_method("select_character"): client.select_character(idx) func _on_char_name_changed(pid: int, name: String) -> void: var sc: Node = _ui.get_node_or_null("CharSelect") if _ui and _state == SELECT else null if sc and sc.has_method("on_char_name_changed"): sc.on_char_name_changed(pid, name) func _on_entered_game() -> void: _reconnecting_game = false if _reconnect and _reconnect.has_method("dismiss"): _reconnect.dismiss() _goto_game() _start_guild_mark_download() # 会徽下载:serverlist 第 8 列配了 mark_port 才连(0 = 跳过)。 # `guild_mark_updated` 时按服务器 1s 冷却重连一次。 func _start_guild_mark_download() -> void: if client == null or not client.has_method("download_guild_marks"): return var addr: Dictionary = serverinfo.address(_sel_server, _sel_channel) var mp: int = int(addr.get("mark_port", 0)) if mp <= 0: return client.download_guild_marks(String(addr.get("mark_host", addr["game_host"])), mp) if client.has_signal("guild_mark_updated") \ and not client.guild_mark_updated.is_connected(_on_guild_mark_updated): client.guild_mark_updated.connect(_on_guild_mark_updated) func _on_guild_mark_updated(_guild_id: int, _img_idx: int) -> void: var now := Time.get_ticks_msec() if now < _mark_redl_after: return _mark_redl_after = now + 1000 _start_guild_mark_download() func _on_login_failed(reason: String) -> void: if _reconnecting_game: if _reconnect and _reconnect.has_method("set_error"): _reconnect.set_error(reason) return if _state != GAME: _goto_login() _set_status("登录失败:%s" % reason) func _on_disconnected(reason: String) -> void: if _state == GAME and _game != null and is_instance_valid(_game): _reconnecting_game = true if _reconnect and _reconnect.has_method("set_error"): _reconnect.set_error(reason) return _reconnecting_game = false _goto_login() if _reconnect and _reconnect.has_method("dismiss"): _reconnect.dismiss() _set_status("连接断开:%s" % reason) # --- 切屏 ------------------------------------------------------------------ func _clear_ui() -> void: if _ui: _ui.queue_free() _ui = null func _goto_login() -> void: _state = LOGIN _reconnecting_game = false if _lifecycle: # GameScene 即将释放,不能让生命周期协调者保留悬空 Audio 引用。 _lifecycle.bind(client) if _game: _game.queue_free() _game = null if _loading: _loading.hide_screen() _clear_ui() _ui = CanvasLayer.new() _ui.layer = 5 add_child(_ui) _build_login_form() func _goto_select() -> void: _state = SELECT if _loading: _loading.hide_screen() # 选人界面要露出来,别被读取遮罩盖住 _clear_ui() _ui = CanvasLayer.new() _ui.layer = 5 add_child(_ui) _build_char_list() _start_guild_mark_download() # 选人页也想显示帮会徽 func _goto_game() -> void: _state = GAME _clear_ui() if _game == null and build_game_scene: if _loading: _loading.show_for("载入地图…") # 场景分帧组装期间挡一下 _game = GameScene.new() add_child(_game) if _game.has_method("setup"): await _game.setup(client, _assets) # 协程:分帧,不阻塞 socket if _lifecycle and is_instance_valid(_game) and _game.has_method("lifecycle_audio"): _lifecycle.bind(client, _game.lifecycle_audio()) if _loading: _loading.hide_screen() # --- 登录表单 ------------------------------------------------------------ func _build_login_form() -> void: var panel := Panel.new() panel.set_anchors_preset(Control.PRESET_CENTER) panel.position = Vector2(-180, -140) panel.custom_minimum_size = Vector2(360, 280) panel.size = Vector2(360, 280) _ui.add_child(panel) var box := VBoxContainer.new() box.position = Vector2(20, 18) box.custom_minimum_size = Vector2(320, 0) box.add_theme_constant_override("separation", 8) panel.add_child(box) var title := Label.new() title.text = "登录" title.add_theme_font_size_override("font_size", 18) box.add_child(title) var srv := OptionButton.new() for s in serverinfo.servers(): srv.add_item(String(s.get("name", "?"))) srv.selected = 0 srv.item_selected.connect(func(i): _sel_server = i; _refresh_channels()) box.add_child(srv) var ch := OptionButton.new() ch.name = "ChannelOpt" box.add_child(ch) ch.item_selected.connect(func(i): var chans: Array = serverinfo.server(_sel_server).get("channels", [1]) _sel_channel = int(chans[i]) if i < chans.size() else 1) var id_edit := LineEdit.new() id_edit.text = _id id_edit.placeholder_text = "账号" id_edit.text_changed.connect(func(t): _id = t) box.add_child(id_edit) var pw_edit := LineEdit.new() pw_edit.text = _pw pw_edit.secret = true pw_edit.placeholder_text = "密码" pw_edit.text_changed.connect(func(t): _pw = t) box.add_child(pw_edit) var btnrow := HBoxContainer.new() box.add_child(btnrow) var connect_btn := Button.new() connect_btn.text = "连接" connect_btn.pressed.connect(_do_connect) btnrow.add_child(connect_btn) var probe_btn := Button.new() probe_btn.text = "检测频道" probe_btn.pressed.connect(_probe_channels) btnrow.add_child(probe_btn) var status := Label.new() status.name = "Status" status.add_theme_font_size_override("font_size", 11) box.add_child(status) _refresh_channels() func _refresh_channels() -> void: if _ui == null: return var ch: OptionButton = _ui.find_child("ChannelOpt", true, false) if ch == null: return ch.clear() var chans: Array = serverinfo.server(_sel_server).get("channels", [1]) for c in chans: ch.add_item("CH %d" % int(c)) ch.selected = 0 _sel_channel = int(chans[0]) if not chans.is_empty() else 1 # 频道负载 —— 先问 CServerStateChecker(一个连接拿全部频道状态:正常/拥挤/爆满/关闭), # 没答上的频道回退纯 TCP 连通性(● 通 / ○ 不通)。 func _probe_channels() -> void: var s: Dictionary = serverinfo.server(_sel_server) if s.is_empty(): return var chans: Array = s.get("channels", [1]) var ch_opt: OptionButton = _ui.find_child("ChannelOpt", true, false) _set_status("检测中…") # 1) 状态查询(连第一个频道端口,服务器回全部频道的 status_by_port) var addr0: Dictionary = serverinfo.address(_sel_server, int(chans[0])) var cs: Node = ChannelStatus.new() add_child(cs) var status_map: Dictionary = {} cs.done.connect(func(m): status_map = m) cs.query(addr0["game_host"], int(addr0["game_port"]), Callable(), 3.0) while cs.is_processing(): await get_tree().process_frame cs.queue_free() # 2) 逐频道标注 for i in chans.size(): var addr: Dictionary = serverinfo.address(_sel_server, int(chans[i])) var port := int(addr["game_port"]) var label := "" if status_map.has(port): var st := int(status_map[port]) label = "%s CH %d" % [["○", "●", "◐", "✕"][clampi(st, 0, 3)], int(chans[i])] else: var up: bool = await _probe_tcp(addr["game_host"], port, 2.0) label = "%s CH %d" % ["●" if up else "○", int(chans[i])] if ch_opt and i < ch_opt.item_count: ch_opt.set_item_text(i, label) _set_status("检测完成") func _probe_tcp(host: String, port: int, timeout_s: float) -> bool: var peer := StreamPeerTCP.new() if peer.connect_to_host(host, port) != OK: return false var t := 0.0 while t < timeout_s: peer.poll() match peer.get_status(): StreamPeerTCP.STATUS_CONNECTED: peer.disconnect_from_host() return true StreamPeerTCP.STATUS_ERROR: return false if is_inside_tree(): await get_tree().process_frame t += 0.016 return false func _set_status(text: String) -> void: if _ui: var s: Label = _ui.find_child("Status", true, false) if s: s.text = text func _do_connect() -> void: var addr: Dictionary = serverinfo.address(_sel_server, _sel_channel) if addr.is_empty(): _set_status("没有服务器") return _set_status("连接 %s:%d …" % [addr["game_host"], addr["game_port"]]) if client.has_method("connect_to_server"): client.connect_to_server(addr["auth_host"], addr["auth_port"], addr["game_host"], addr["game_port"], _id, _pw) # --- 选人列表 ------------------------------------------------------------ func _build_char_list() -> void: var screen := CharSelectScreen.new() screen.name = "CharSelect" _ui.add_child(screen) screen.setup(client, _assets, _chars) screen.select_requested.connect(func(idx: int): _enter_character(idx)) screen.back_requested.connect(func(): if client.has_method("disconnect_from_server"): client.disconnect_from_server() _goto_login()) screen.create_requested.connect(func(spec: Dictionary): if client.has_method("create_character"): client.create_character(int(spec.get("slot", 0)), String(spec.get("name", "")), int(spec.get("job", 0)), int(spec.get("shape", 0)), int(spec.get("con", 0)), int(spec.get("int", 0)), int(spec.get("str", 0)), int(spec.get("dex", 0)))) screen.delete_requested.connect(func(idx: int, code: String): if client.has_method("delete_character"): client.delete_character(idx, code)) screen.change_name_requested.connect(func(idx: int, name: String): if not client.has_method("change_name") or not bool(client.call("change_name", idx, name)): if screen.has_method("on_char_name_send_failed"): screen.on_char_name_send_failed()) if client.has_signal("guild_marks_ready"): client.guild_marks_ready.connect(_on_marks_ready_for_select, CONNECT_ONE_SHOT) func _forward_char_evt(a = null, sig := "") -> void: # 兼容 0/1 参数的信号:char_delete_failed 无参,其它带一个 int if typeof(a) == TYPE_STRING and sig == "": sig = a a = null var sc: Node = _ui.get_node_or_null("CharSelect") if _ui and _state == SELECT else null if sc == null: return match sig: "char_created": sc.on_char_created(int(a)) "char_create_failed": sc.on_char_create_failed(int(a)) "char_deleted": sc.on_char_deleted(int(a)) "char_delete_failed": sc.on_char_delete_failed() func _on_marks_ready_for_select() -> void: var sc: Node = _ui.get_node_or_null("CharSelect") if _ui else null if sc and sc.has_method("refresh_crest"): sc.refresh_crest() func _enter_character(idx: int) -> void: _selected_char_slot = idx if client.has_method("enter_game"): client.enter_game(idx) elif client.has_method("select_character"): client.select_character(idx)