Captures the uncommitted parallel-development work (increments 46-49) on the
40250 classic client port, folded into docs/CLIENT-GAP.md + CLIENT-GAP-FIX.md.
Increment 49 (W0 interface-freeze batch, Phase 1 prerequisite) lands the
cross-workflow shared-file scaffolding so the 4 Phase 1 workflows can run in
isolated worktrees without contention:
- entity_store.{h,cpp}: Entity gains empire/affect_flags/owner_vid/state_flags;
new mut_spawn_full() (single-shot two-packet merge, §2.3), mut_ownership()
(§2.5), mut_map_bgm() + take_bgm_dirty()/bgm_name()/bgm_volume() (§9.1),
drain_dirty() (§2.1/§2.5 bare-field-update queue); mut_char_info() now stores
empire; reset_for_map_change() clears m_dirty.
- m2_client.cpp: new bgm_changed(name, volume) signal; classic + m2dev pump
loops drain drain_dirty() -> entity_info and take_bgm_dirty() -> bgm_changed;
entity_dict() exposes the 4 new keys.
- classic/classic_parser.{h,cpp}: GC_MAIN_CHARACTER3_BGM /
GC_MAIN_CHARACTER4_BGM_VOL route bgm_name/bgm_volume to mut_map_bgm() (was
discarded); new m_pending_actor staging map (W1 fills §2.2 merge logic).
- project/bgm_director.gd (new) + game_scene.gd: BGM consumption split out of
net_world.gd so W2/W4 don't collide; §8.6/§4.8 keybind convergence (digits
1-4 -> quickslots 0-3, F1-F4 -> quickslots 4-7, Ctrl+1..9 -> _emote()).
Tests: cmake --build build clean; ctest 16/16; 15 GDScript regressions green
(netbridge, gamescene, netplay, p9, p10, p2b, p8, system_menu_ui, skill,
combat_fx, skill_fx, player_motion, char_status_ui, chat, inventory), no skips.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yvAPqPivoY7vmBbzgqK4W
365 lines
12 KiB
GDScript
365 lines
12 KiB
GDScript
# 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 信号驱动切屏。
|
||
# 断线:任何阶段都回到登录阶段,和 ClientVS22 的 SetLoginPhase 一致。
|
||
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")
|
||
|
||
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 build_game_scene := true # 测试里置 false,只跑状态机不建重场景
|
||
var _sel_server := 0
|
||
var _sel_channel := 1
|
||
var _id := "admin"
|
||
var _pw := "123456789"
|
||
var _chars: Array = []
|
||
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"
|
||
|
||
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)
|
||
_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
|
||
# 已在选人页(建号 / 删号后 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:
|
||
_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 _state != GAME:
|
||
_goto_login()
|
||
_set_status("登录失败:%s" % reason)
|
||
|
||
func _on_disconnected(reason: String) -> void:
|
||
_goto_login()
|
||
_set_status("连接断开:%s" % reason)
|
||
|
||
# --- 切屏 ------------------------------------------------------------------
|
||
|
||
func _clear_ui() -> void:
|
||
if _ui:
|
||
_ui.queue_free()
|
||
_ui = null
|
||
|
||
func _goto_login() -> void:
|
||
_state = LOGIN
|
||
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 _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):
|
||
if client.has_method("enter_game"):
|
||
client.enter_game(idx)
|
||
elif client.has_method("select_character"):
|
||
client.select_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()
|