Fix 40250 classic login and world loading

This commit is contained in:
shen
2026-09-02 07:03:25 +08:00
parent 8f61990a83
commit 44bdcb0781
17 changed files with 446 additions and 45 deletions
+1 -1
View File
@@ -55,7 +55,7 @@ func _init() -> void:
_client.connect("login_failed", func(r): _set_status("login failed: " + r))
_client.connect("entered_game", func(): _set_status("entered game"))
var host := "192.168.21.203"
_client.call("connect_to_server", host, 11000, host, 11011, "admin", "123456789")
_client.call("connect_to_server", host, 11000, host, 13002, "admin", "123456789")
_set_status("connecting to " + host + " ...")
else:
_set_status("M2Client 未注册 —— 用 --mock")
+4
View File
@@ -8,6 +8,10 @@ extends Node
const AppFlow = preload("res://app_flow.gd")
func _ready() -> void:
# 打包 app 从 Finder/open 启动时没有 shell 环境;40250 服务器使用 classic
# DH2/CTR 协议,因此默认固定到 classic,同时保留 MT_PROTOCOL 覆盖能力。
if OS.get_environment("MT_PROTOCOL").is_empty():
OS.set_environment("MT_PROTOCOL", "classic")
# 移动端:APK/IPA 里没有资源 —— 挂载外部的 assets.zipadb push / ios-deploy 上传)。
AssetPack.ensure()
var root := AssetRoot.path()
+1 -1
View File
@@ -21,7 +21,7 @@ script_export_mode=2
[preset.0.options]
export/distribution_type=1
binary_format/architecture="arm64"
binary_format/architecture="universal"
custom_template/debug=""
custom_template/release=""
debug/export_console_wrapper=1
+86
View File
@@ -121,6 +121,11 @@ func setup(m2client: Node, assets_root: String,
map_path: String = "OutdoorA1/metin2_map_a1") -> void:
client = m2client
_assets = assets_root
# GC_MAIN_CHARACTER only carries the server-space coordinates, not the map
# folder. The old fixed A1 fallback puts characters from A2/A3 outside the
# rendered terrain, which looks like an empty map. Resolve the map from the
# character position before constructing Metin2World.
map_path = _resolve_map_path(map_path)
_build_lighting()
# 关键:setup 是协程。每个重活之间让出一帧,好让 M2Client._process 抽 socket
# (否则整段 ~5s 同步阻塞会漏 PONG → 服务器 10s 后 "peer closed")。
@@ -672,6 +677,87 @@ func _map_loaded() -> bool:
var rep: Dictionary = world.call("get_load_report")
return int(rep.get("map_size_x", 0)) > 0
func _resolve_map_path(requested: String) -> String:
if client == null or _assets == "" or not client.has_method("get_main_vid"):
return requested
var vid := int(client.get_main_vid())
if vid == 0 or not client.has_method("get_entity"):
return requested
var entity: Dictionary = client.get_entity(vid)
var pos: Variant = entity.get("pos_cm", null)
if not (pos is Vector3):
return requested
var settings: Array[String] = []
_collect_map_settings(_assets, settings)
var best := requested
var best_score := 1000000
for setting_path: String in settings:
var bounds := _map_bounds(setting_path)
if bounds.is_empty():
continue
var base: Vector2 = bounds["base"]
var size: Vector2i = bounds["size"]
var max_x := base.x + float(size.x) * 25600.0
var max_y := base.y + float(size.y) * 25600.0
if pos.x < base.x or pos.x >= max_x or pos.y < base.y or pos.y >= max_y:
continue
var root := _assets.trim_suffix("/")
var rel_setting := setting_path.substr(root.length() + 1)
var candidate := rel_setting.get_base_dir()
var lower := candidate.to_lower()
# Prefer the normal outdoor map over duplicate patch/season copies when
# several map packages advertise the same world-space rectangle.
var score := candidate.split("/").size()
if lower.begins_with("outdoor"):
score -= 20
if lower.begins_with("season") or lower.begins_with("metin2_patch"):
score += 20
if candidate == requested:
score -= 1000
if score < best_score:
best_score = score
best = candidate
if best != requested:
print("[GameScene] map resolved by server position ", pos, " -> ", best)
return best
func _collect_map_settings(dir_path: String, out: Array[String]) -> void:
var dir := DirAccess.open(dir_path)
if dir == null:
return
for file_name: String in dir.get_files():
if file_name.to_lower() == "setting.txt":
out.append(dir_path.path_join(file_name))
for child: String in dir.get_directories():
if child in [".godot", ".git", "build", "export"]:
continue
_collect_map_settings(dir_path.path_join(child), out)
func _map_bounds(setting_path: String) -> Dictionary:
var f := FileAccess.open(setting_path, FileAccess.READ)
if f == null:
return {}
var base := Vector2.ZERO
var size := Vector2i.ZERO
var have_base := false
var have_size := false
while not f.eof_reached():
var fields := f.get_line().replace("\t", " ").strip_edges().split(" ", false)
if fields.size() < 3:
continue
match String(fields[0]).to_lower():
"baseposition":
base = Vector2(float(fields[1]), float(fields[2]))
have_base = true
"mapsize":
size = Vector2i(int(fields[1]), int(fields[2]))
have_size = size.x > 0 and size.y > 0
if not have_base or not have_size:
return {}
return {"base": base, "size": size}
func _build_lighting() -> void:
var sun := DirectionalLight3D.new()
sun.name = "FallbackSun"
+1 -1
View File
@@ -121,7 +121,7 @@ func _connect() -> void:
_client.call("reconnect"))
_life.bind(_client, _audio)
var h := _host.text
_client.call("connect_to_server", h, 11000, h, 11011, _id.text, _pw.text)
_client.call("connect_to_server", h, 11000, h, 13002, _id.text, _pw.text)
_msg("连接 %s ..." % h)
func _on_chars(list: Array) -> void:
+6 -5
View File
@@ -7,10 +7,11 @@
#
# 文件格式(TSV# 注释):
# name auth_host auth_port game_host game_port channels(csv) [port_step] [mark_port]
# 测试服 192.168.21.203 11000 192.168.21.203 11011 1,2,3 1 0
# 测试服 192.168.21.203 11000 192.168.21.203 13002 1,2,3,4 10 0
#
# 频道 N 的 game 端口 = game_port + (N-1)*port_step。
# 联调服实测端口是 11011/11012/11013(step=1);不同服可在文件第 7 列覆盖
# 这套 40250 配置的 Game2 端口是 13002/13012/13022/13032step=10
# 13000/13010/... 是 first 入口;admin 测试角色在 Game2 地图,直接连 Game2 才能完成进入游戏。
# 第 8 列 mark_port = 公会会徽服端口(0 = 不下载会徽)。
class_name ServerInfo
extends RefCounted
@@ -20,12 +21,12 @@ const DEFAULT_PORT_STEP := 1
var _servers: Array = []
func _init() -> void:
# 内置默认:当前联调服(CServerStateChecker 实测频道口 11011/12/13
# 内置默认:40250 服务器(认证 11000channel1/Game2 端口 13002
_servers = [{
"name": "测试服",
"auth_host": "192.168.21.203", "auth_port": 11000,
"game_host": "192.168.21.203", "game_port": 11011,
"channels": [1, 2, 3], "port_step": 1, "mark_port": 0,
"game_host": "192.168.21.203", "game_port": 13002,
"channels": [1, 2, 3, 4], "port_step": 10, "mark_port": 0,
}]
func load_file(path: String) -> bool:
+2 -2
View File
@@ -41,7 +41,7 @@ func _run() -> void:
var si: RefCounted = ServerInfoRes.new()
_ck(si.count() == 1 and si.server(0)["auth_host"] == "192.168.21.203", "serverinfo: built-in default")
var addr: Dictionary = si.address(0, 3)
_ck(addr["game_port"] == 11013, "serverinfo: 内置 CH3 -> 11013 (step 1)")
_ck(addr["game_port"] == 13022, "serverinfo: 内置 CH3 -> 13022 (step 10)")
var tmp := "user://p10_servers.txt"
var f := FileAccess.open(tmp, FileAccess.WRITE)
f.store_line("# test")
@@ -103,7 +103,7 @@ func _run() -> void:
var reachable: bool = await af._probe_tcp("127.0.0.1", 1, 0.3)
_ck(reachable == false, "flow: _probe_tcp unreachable -> false")
af._do_connect()
_ck(fc2.connects.size() == 1 and fc2.connects[0][3] == 11011, "flow: connect uses serverinfo addr")
_ck(fc2.connects.size() == 1 and fc2.connects[0][3] == 13002, "flow: connect uses serverinfo addr")
if af._loading and af._loading.has_method("show_for"):
af._loading.show_for("载入角色…") # 模拟 phase=select 时遮罩已亮
fc2.char_list.emit([{"index": 0, "name": "Hero", "level": 42}])
+2 -2
View File
@@ -6,8 +6,8 @@ config_version=5
[application]
config/name="mtgodot-poc"
# 完整客户端入口(登录→选人→进游戏)。旧的模型查看器 harness 仍可跑:
# godot --path project res://main.tscn
; 完整客户端入口(登录→选人→进游戏)。旧的模型查看器 harness 仍可跑:
; godot --path project res://main.tscn
run/main_scene="res://client_main.tscn"
config/features=PackedStringArray("4.7", "Forward Plus")
+3 -1
View File
@@ -243,7 +243,9 @@ func _ensure_spec() -> void:
if race < 0 or assets_root == "":
return
var cls: String = CLASS_OF[race & 3]
for rel in ["PC/ymir work/pc/%s/%s.msm" % [cls, cls],
var suffix := "w" if race in [1, 3, 4, 6] else "m"
for rel in ["root/msm/%s_%s.msm" % [cls, suffix],
"PC/ymir work/pc/%s/%s.msm" % [cls, cls],
"pc2/ymir work/pc2/%s/%s.msm" % [cls, cls],
"season1/season1/pc/%s.msm" % cls]:
var p := _resolve_asset(rel)
+3 -2
View File
@@ -57,7 +57,7 @@ func shape(idx: int) -> Dictionary:
if e.is_empty():
return {}
e = e.duplicate()
e["path"] = shape_path
e["path"] = String(e.get("special_path", shape_path))
e["spec_dir"] = _dir
return e
@@ -66,7 +66,7 @@ func hair(idx: int) -> Dictionary:
if e.is_empty():
return {}
e = e.duplicate()
e["path"] = hair_path
e["path"] = String(e.get("special_path", hair_path))
e["spec_dir"] = _dir
return e
@@ -77,6 +77,7 @@ func _entry(g: Dictionary) -> Dictionary:
"model": _s(g.get("Model", g.get("local_model", ""))).strip_edges(),
"source_skin": _s(g.get("SourceSkin", "")).strip_edges(),
"target_skin": _s(g.get("TargetSkin", "")).strip_edges(),
"special_path": _s(g.get("SpecialPath", "")).strip_edges(),
}
func _s(v) -> String: