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
+240
View File
@@ -0,0 +1,240 @@
# 选人界面 —— 3 个旋转台角色 + 九宫格信息面板 + 选择/创建按钮。
# 数据来自 M2Client.char_list(联机)或 --mock 合成列表(离线开发)。
# 选中 -> M2Client.select_character(index) -> entered_game。
# 用法(离线预览):godot --path project --script charselect.gd --mock --quit-after 120
extends SceneTree
const UiKit = preload("res://ui_kit.gd")
const Audio = preload("res://audio.gd")
const Locale = preload("res://locale.gd")
const CLASS_DIR := ["warrior", "assassin", "sura", "shaman"]
const CLASS_KEY := ["JOB_WARRIOR", "JOB_ASSASSIN", "JOB_SURA", "JOB_SHAMAN"]
var _audio: Node
var _loc: RefCounted
var _assets: String
var _client: Node # M2Client(联机时)
var _slots: Array[Dictionary] = [] # {index,name,job,level,x,y}
var _pedestals: Array[Node3D] = []
var _models: Array[Node3D] = []
var _sel := 0
var _t := 0.0
var _panel: Control
var _status: Label
var _mock := false
func _init() -> void:
_assets = AssetRoot.path()
_mock = "--mock" in OS.get_cmdline_user_args() or "--mock" in OS.get_cmdline_args()
_loc = Locale.new()
_loc.setup(_assets, "en")
_audio = Audio.new()
get_root().add_child(_audio)
_audio.setup(_assets)
_audio.play_bgm("characterselect")
_build_stage()
_build_ui()
if _mock:
_slots = [
{"index": 0, "name": "Baalthazar", "job": 0, "level": 42, "x": 0, "y": 0},
{"index": 1, "name": "Nyx", "job": 1, "level": 7, "x": 0, "y": 0},
{"index": 2, "name": "", "job": 0, "level": 0, "x": 0, "y": 0}, # 空槽
]
_populate()
else:
if ClassDB.class_exists("M2Client"):
_client = ClassDB.instantiate("M2Client")
get_root().add_child(_client)
_client.connect("char_list", _on_char_list)
_client.connect("stage_changed", func(s): _set_status("stage: " + s))
_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")
_set_status("connecting to " + host + " ...")
else:
_set_status("M2Client 未注册 —— 用 --mock")
# --- 3D 舞台 ---------------------------------------------------------------
func _build_stage() -> void:
var root := Node3D.new()
root.name = "Stage"
get_root().add_child(root)
var sun := DirectionalLight3D.new()
sun.rotation_degrees = Vector3(-35, 20, 0)
sun.light_energy = 2.4
root.add_child(sun)
var fill := DirectionalLight3D.new() # 正面补光
fill.rotation_degrees = Vector3(-10, 180, 0)
fill.light_energy = 0.9
fill.light_color = Color(0.8, 0.85, 1.0)
root.add_child(fill)
var we := WorldEnvironment.new()
var e := Environment.new()
e.background_mode = Environment.BG_COLOR
e.background_color = Color(0.10, 0.11, 0.14)
e.ambient_light_color = Color(0.5, 0.52, 0.62)
e.ambient_light_energy = 1.1
e.tonemap_mode = Environment.TONE_MAPPER_FILMIC
we.environment = e
root.add_child(we)
# 三个台座,绕中心排成弧
for i in 3:
var a := deg_to_rad(-28.0 + i * 28.0)
var ped := Node3D.new()
ped.name = "Pedestal%d" % i
ped.position = Vector3(sin(a) * 2.3, 0.0, -cos(a) * 2.3 + 2.3)
root.add_child(ped)
var disc := MeshInstance3D.new()
var cyl := CylinderMesh.new()
cyl.top_radius = 0.62
cyl.bottom_radius = 0.7
cyl.height = 0.14
disc.mesh = cyl
var m := StandardMaterial3D.new()
m.albedo_color = Color(0.18, 0.19, 0.24)
m.metallic = 0.5
m.roughness = 0.4
disc.material_override = m
disc.position.y = 0.07
ped.add_child(disc)
_pedestals.append(ped)
_models.append(null)
var cam := Camera3D.new()
cam.name = "Cam"
root.add_child(cam)
cam.look_at_from_position(Vector3(0.0, 1.5, 5.4), Vector3(0.0, 1.15, 1.8), Vector3.UP)
cam.fov = 44.0
cam.make_current()
func _slot_model(job: int) -> Node3D:
var cls: String = CLASS_DIR[clampi(job, 0, 3)]
var m: Node3D = ClassDB.instantiate("Metin2Model")
m.name = "Model"
m.set("texture_dir", _assets.path_join("PC/ymir work/pc/" + cls))
m.set("gr2_path", _assets.path_join("PC/ymir work/pc/" + cls + "/" + cls + "_novice.gr2"))
if ClassDB.class_exists("Metin2AnimPlayer"):
var anim: Node = ClassDB.instantiate("Metin2AnimPlayer")
anim.set("model_path", NodePath("../Model"))
anim.set("loop", true)
anim.set("anim_path", _assets.path_join("PC/ymir work/pc/" + cls + "/general/wait.msa"))
m.add_child.call_deferred(anim)
return m
func _populate() -> void:
for i in range(min(_slots.size(), _pedestals.size())):
var s := _slots[i]
if _models[i] != null:
_models[i].queue_free()
_models[i] = null
if String(s.get("name", "")).is_empty():
continue
var mdl := _slot_model(int(s.get("job", 0)))
_pedestals[i].add_child(mdl)
_models[i] = mdl
_refresh_panel()
# --- 2D 信息面板 --------------------------------------------------------
func _build_ui() -> void:
var layer := CanvasLayer.new()
get_root().add_child(layer)
_panel = UiKit.board(_assets, "board", 32, 128)
_panel.set_anchors_preset(Control.PRESET_CENTER_BOTTOM)
_panel.position = Vector2(-280, -172)
_panel.size = Vector2(560, 160)
layer.add_child(_panel)
_status = Label.new()
_status.set_anchors_preset(Control.PRESET_TOP_WIDE)
_status.position = Vector2(20, 16)
_status.add_theme_font_size_override("font_size", 16)
layer.add_child(_status)
func _set_status(t: String) -> void:
if _status:
_status.text = t
print("[charselect] ", t)
func _refresh_panel() -> void:
for c in _panel.get_children():
c.queue_free()
var row := HBoxContainer.new()
row.position = Vector2(24, 20)
row.add_theme_constant_override("separation", 10)
_panel.add_child(row)
for i in range(_slots.size()):
var s := _slots[i]
var b := Button.new()
var nm := String(s.get("name", ""))
var job := clampi(int(s.get("job", 0)), 0, 3)
b.text = ("[ %s ]" % _loc.t("SELECT_CREATE") if nm.is_empty()
else "%s\n%s %s %d" % [nm, _loc.t(CLASS_KEY[job]),
_loc.t("SELECT_LEVEL"), int(s.get("level", 0))])
b.custom_minimum_size = Vector2(150, 70)
b.toggle_mode = true
b.button_pressed = (i == _sel)
var idx := i
b.pressed.connect(func(): _select_slot(idx))
row.add_child(b)
var enter := Button.new()
enter.text = _loc.t("SELECT_SELECT") # "Start"
enter.position = Vector2(24, 118)
enter.custom_minimum_size = Vector2(150, 40)
enter.pressed.connect(_enter)
_panel.add_child(enter)
func _select_slot(i: int) -> void:
_sel = i
_set_status("selected slot %d" % i)
if _audio and i < _slots.size():
var job := clampi(int(_slots[i].get("job", 0)), 0, 3)
_audio.play_ui(CLASS_DIR[job] + "_select")
_refresh_panel()
func _enter() -> void:
var s := _slots[_sel] if _sel < _slots.size() else {}
if String(s.get("name", "")).is_empty():
_set_status("空槽不能进入(创建角色未实现)")
return
if _client:
_client.call("select_character", int(s.get("index", _sel)))
_set_status("select_character(%d) sent" % int(s.get("index", _sel)))
else:
_set_status("[mock] 进入游戏:%s" % s.get("name"))
func _on_char_list(list: Array) -> void:
_slots.clear()
for d in list:
_slots.append(d)
while _slots.size() < 3:
_slots.append({"index": _slots.size(), "name": "", "job": 0, "level": 0})
_set_status("char_list: %d" % list.size())
_populate()
# --- 循环:旋转选中角色,截图 -------------------------------------------
func _process(dt: float) -> bool:
_t += dt
for i in range(_pedestals.size()):
var spd := 0.6 if i == _sel else 0.15
_pedestals[i].rotate_y(spd * dt)
if _mock and _t > 3.0:
var img := get_root().get_texture().get_image()
var out := ProjectSettings.globalize_path("res://../test/golden/charselect.png")
DirAccess.make_dir_recursive_absolute(out.get_base_dir())
img.save_png(out)
print("[charselect] saved ", out)
quit(0)
return false