Metin2 game client (P0–P11) + mobile asset pipeline

Networked client on the existing Godot 4.7 + libgr2 renderer:
- net: m2dev wire protocol (libsodium KX + XChaCha20), auth/select/game
  phases, EntityStore world model, ~all GC/CG headers. char create/delete,
  private shop / mall / cube, SHOP_GC_START_EX, guild, party (+ CG_PARTY_SET_STATE),
  quests, dragon soul, refine, safebox, exchange.
- UI: in-game windows migrated 1:1 from the reference uiscript/root .py —
  char status (/stat), inventory+equipment, select-item ([SELECT_ITEM] quest
  token), system-option + game-option + ESC system menu, private-shop 39-grid,
  party info board, shop tabs, atlas, minimap, quickbar, chat, …
- EterGrnLib polish: GR2 material blend/two-sided, LOD crossfade, motion-event
  dispatch, contact shadow, ray-AABB picking, weapon grip pre-transform.

Portable asset IO (A1) — all extension/libgr2/formats/mtproto reads routed
through godot::FileAccess (res:// PCK works on iOS/Android); standalone-lib
*_path() kept for the non-Godot CTests. AssetResolver + PropertyRegistry
switched to a baked index (bake_asset_index.gd) instead of std::filesystem.

Mobile builds: build-{android,ios}.sh, export-android.sh, pack-assets.sh,
gen-debug-keystore.sh. Assets ship as a zip mounted at runtime by
project/asset_pack.gd (adb push now; HTTP download is a drop-in later).

ctest 10/10, 34 GDScript suites, macOS/iOS/Android all build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EJxkHiNKS4kybHS3XKyAJ
This commit is contained in:
shen
2026-08-31 20:02:12 +09:00
co-authored by Claude Sonnet 5
parent f4917a2b3b
commit 47baf6c0c6
414 changed files with 69568 additions and 385 deletions
+356
View File
@@ -0,0 +1,356 @@
# AppFlow (P10) —— 登录 → 选人 → 进游戏 的串场控制器。
#
# 把它做场景根:一个 M2Client 贯穿全程,LoadingScreen / ReconnectUI 常驻。
# var af := preload("res://app_flow.gd").new()
# add_child(af)
# af.start(assets_root) # assets_root 传给 game_scene
#
# 状态机:LOGIN → SELECT → GAME。M2Client 信号驱动切屏。
# 断线:在 GAME 里保留场景交给 ReconnectUI 自动重连;在 LOGIN/SELECT 回登录。
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 ReconnectUI = preload("res://ui/reconnect_ui.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 _reconnect: 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)
_reconnect = ReconnectUI.new()
add_child(_reconnect)
_reconnect.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", "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() and client.has_method("select_character"):
client.select_character(int(_chars[0].get("index", 0)))
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:
# GAME 里:保留 game_sceneReconnectUI 负责重连遮罩
# LOGIN/SELECT 里:回登录
if _state != GAME:
_goto_login()
# --- 切屏 ------------------------------------------------------------------
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("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))
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()