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
+79 -13
View File
@@ -5,8 +5,8 @@ extends Node3D
## Godot's Forward+ renderer, and (if an anim .gr2 is given) drives it with
## Metin2AnimPlayer.
##
## Asset paths default to the sibling m2dev-client-main checkout; override with
## MTGODOT_MODEL / MTGODOT_ANIM / MTGODOT_TEXDIR env vars.
## Asset root resolves via AssetRoot (res://../assets, or the MT_ASSETS env var).
## Per-file overrides: MTGODOT_MODEL / MTGODOT_ANIM / MTGODOT_TEXDIR env vars.
##
## Keys: drag = orbit wheel = zoom Space = play/pause B = bind pose
## [ / ] = scrub R = reload F2 = screenshot
@@ -27,7 +27,7 @@ var _dragging := false
func _ready() -> void:
_assets_root = ProjectSettings.globalize_path("res://../../m2dev-client-main/assets")
_assets_root = AssetRoot.path()
_build_environment()
_build_sun()
_build_camera()
@@ -106,11 +106,12 @@ func _load_content() -> void:
_assets_root.path_join("PC/ymir work/pc/warrior/warrior_cheongrin.gr2"))
var tex_dir := _env("MTGODOT_TEXDIR",
_assets_root.path_join("PC/ymir work/pc/warrior"))
# Default to an idle: dance_1 (and other emotes) hit a libgr2 curve-decode
# bug that collapses the head/neck — reproduced in xrender-poc's bgfx demo
# too, i.e. upstream, not a Godot-route issue. See docs/MIDREVIEW.md.
# dance_1 and other emotes are fine now that CPU skinning is the default
# (GPU/Skeleton3D dropped the shear Granny bakes onto the shoulder/front-robe
# bones → head/neck collapse; see docs/MIDREVIEW.md §4). Force GPU with
# MTGODOT_GPUSKIN=1 for perf testing.
var anim_path := _env("MTGODOT_ANIM",
_assets_root.path_join("PC/ymir work/pc/warrior/general/wait.gr2"))
_assets_root.path_join("PC/ymir work/pc/warrior/action/dance_1.msa"))
_model = ClassDB.instantiate("Metin2Model")
_model.name = "Metin2Model"
@@ -119,15 +120,33 @@ func _load_content() -> void:
add_child(_model)
if _model.has_method("get_info"):
print("[mtgodot] model: ", _model.call("get_info"))
if _model.has_method("get_hair_options"):
var hs: Array = _model.call("get_hair_options")
if not hs.is_empty():
print("[mtgodot] .msm hair options: %d (e.g. %s)" % [hs.size(), hs[0]])
if anim_path != "-" and FileAccess.file_exists(anim_path) and ClassDB.class_exists("Metin2AnimPlayer"):
_anim = ClassDB.instantiate("Metin2AnimPlayer")
_anim.name = "Metin2AnimPlayer"
_anim.set("model_path", _anim.get_path_to(_model) if false else NodePath("../Metin2Model"))
_anim.set("anim_path", anim_path)
var loop_env := OS.get_environment("MTGODOT_LOOP")
if loop_env != "":
_anim.set("loop", loop_env != "0")
var speed_env := OS.get_environment("MTGODOT_TIME_SCALE")
if speed_env != "":
_anim.set("time_scale", float(speed_env))
add_child(_anim)
if _anim.has_signal("motion_event"):
_anim.connect("motion_event", _on_motion_event)
if _anim.has_signal("playback_finished"):
_anim.connect("playback_finished", _on_playback_finished)
if _anim.has_method("get_info"):
print("[mtgodot] anim: ", _anim.call("get_info"))
if _anim.has_method("get_events"):
var evs: Array = _anim.call("get_events")
if not evs.is_empty():
print("[mtgodot] msa events: ", evs, " accum=", _anim.call("get_accumulation"))
if _anim.has_method("selfcheck"):
print("[mtgodot] ", _anim.call("selfcheck", 24))
_anim.call("reload")
@@ -139,10 +158,15 @@ func _load_content() -> void:
func _frame_model() -> void:
# Place the orbit target/dist from the model's converted world-space AABB.
# Use trimmed vertex percentiles: a single malformed/far-away vertex (or a
# distant attachment bone) must not make the character microscopic.
var mi := _model.get_node_or_null("MeshInstance3D") if _model else null
if mi and mi is VisualInstance3D:
var aabb: AABB = _model.transform * (mi as VisualInstance3D).get_aabb()
var aabb := _robust_mesh_frame_aabb(mi as MeshInstance3D) if mi is MeshInstance3D else AABB()
var frame_source := "trimmed-mesh"
if aabb.size.y <= 0.01 and mi and mi is VisualInstance3D:
aabb = _model.transform * (mi as VisualInstance3D).get_aabb()
frame_source = "mesh"
if aabb.size.y > 0.01:
var h := maxf(aabb.size.y, 0.5)
_target = Vector3(aabb.get_center().x, aabb.position.y + h * 0.5, aabb.get_center().z)
_dist = h * 3.2
@@ -150,10 +174,41 @@ func _frame_model() -> void:
var yenv := OS.get_environment("MTGODOT_YAW")
if yenv != "":
_yaw = float(yenv)
print("[mtgodot] frame: aabb=", aabb, " target=", _target, " dist=", _dist)
print("[mtgodot] frame[", frame_source, "]: aabb=", aabb,
" target=", _target, " dist=", _dist)
_update_camera()
func _robust_mesh_frame_aabb(mi: MeshInstance3D) -> AABB:
if not _model or not mi or not mi.mesh:
return AABB()
var xs := PackedFloat32Array()
var ys := PackedFloat32Array()
var zs := PackedFloat32Array()
for surface in range(mi.mesh.get_surface_count()):
var arrays := mi.mesh.surface_get_arrays(surface)
if arrays.size() <= Mesh.ARRAY_VERTEX:
continue
var vertices := arrays[Mesh.ARRAY_VERTEX] as PackedVector3Array
for local_pos in vertices:
var p: Vector3 = _model.transform * local_pos
if p.is_finite():
xs.append(p.x)
ys.append(p.y)
zs.append(p.z)
if xs.size() < 8:
return AABB()
xs.sort()
ys.sort()
zs.sort()
var trim := int(xs.size() * 0.01)
var hi := xs.size() - trim - 1
var lo_p := Vector3(xs[trim], ys[trim], zs[trim])
var hi_p := Vector3(xs[hi], ys[hi], zs[hi])
var bounds := AABB(lo_p, hi_p - lo_p)
return bounds.grow(maxf(bounds.size.y * 0.08, 0.05))
func _update_camera() -> void:
var offset := Vector3(
cos(_pitch) * sin(_yaw),
@@ -211,7 +266,7 @@ func _on_key(kc: int) -> void:
## MTGODOT_STRESS=N : spawn N animated warriors in a grid, measure frame time
## over ~180 frames, write test/godot-macos-stress.json, quit.
## Feeds the Phase-2 animation A/B (per-frame set_bone_global_pose vs baked).
## Baseline for Phase-2 perf: default CPU LBS vs MTGODOT_GPUSKIN=1 vertex shader.
func _run_stress(n: int) -> void:
DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_DISABLED)
Engine.max_fps = 0
@@ -270,7 +325,9 @@ func _run_stress(n: int) -> void:
"cpu_process_ms_avg": psum / proc.size(),
"cpu_process_ms_p95": proc[int(proc.size() * 0.95)],
"renderer": RenderingServer.get_video_adapter_name(),
"method": "per-frame set_bone_global_pose (B)",
"method": ("per-frame GPU LBS via bone-texture vertex shader (full affine)"
if OS.get_environment("MTGODOT_GPUSKIN") == "1"
else "per-frame CPU LBS, mesh rebuilt each frame (B, default)"),
}
var out := OS.get_environment("MTGODOT_STRESS_OUT")
if out == "":
@@ -283,6 +340,15 @@ func _run_stress(n: int) -> void:
get_tree().quit()
func _on_motion_event(type: int, effect: String, sound: String, pos: Vector3) -> void:
print("[mtgodot] motion_event t=%.3f type=%d effect=%s sound=%s pos=%s"
% [_anim.call("get_time") if _anim else 0.0, type, effect, sound, pos])
func _on_playback_finished() -> void:
print("[mtgodot] playback finished at t=", _anim.call("get_time") if _anim else -1.0)
func _screenshot() -> void:
var img := get_viewport().get_texture().get_image()
var path := "user://shot_%d.png" % Time.get_ticks_msec()