Files
mtgodot-poc/project/app_flow.gd
T
shenleiandClaude Sonnet 5 f32064c74a 40250 classic: parallel-dev checkpoint through increment 49 (W0 interface freeze)
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
2026-09-02 15:13:01 +09:00

365 lines
12 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 信号驱动切屏。
# 断线:任何阶段都回到登录阶段,和 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()