Files
mtgodot-poc/project/app_flow.gd
T

420 lines
15 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.
# 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)