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
357 lines
12 KiB
GDScript
357 lines
12 KiB
GDScript
extends Node3D
|
|
## mtgodot-poc harness — M1 (static render) + M2 (skeletal animation).
|
|
##
|
|
## Loads a Metin2 .gr2 via the Metin2Model GDExtension node, renders it with
|
|
## Godot's Forward+ renderer, and (if an anim .gr2 is given) drives it with
|
|
## Metin2AnimPlayer.
|
|
##
|
|
## 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
|
|
|
|
const ORBIT_SPEED := 0.01
|
|
const ZOOM_STEP := 0.15
|
|
const PITCH_LIMIT := 1.45
|
|
|
|
var _assets_root := ""
|
|
var _cam: Camera3D
|
|
var _model: Node3D # Metin2Model
|
|
var _anim: Node3D # Metin2AnimPlayer
|
|
var _yaw := 0.7
|
|
var _pitch := 0.25
|
|
var _dist := 3.0
|
|
var _target := Vector3(0.0, 1.0, 0.0)
|
|
var _dragging := false
|
|
|
|
|
|
func _ready() -> void:
|
|
_assets_root = AssetRoot.path()
|
|
_build_environment()
|
|
_build_sun()
|
|
_build_camera()
|
|
|
|
var stress := OS.get_environment("MTGODOT_STRESS")
|
|
if stress != "":
|
|
await _run_stress(int(stress))
|
|
return
|
|
|
|
_load_content()
|
|
_frame_model()
|
|
print("[mtgodot] harness ready")
|
|
|
|
var autoshot := OS.get_environment("MTGODOT_AUTOSHOT")
|
|
if autoshot != "":
|
|
# let a few frames render (and the anim settle) before capturing
|
|
for i in 8:
|
|
await get_tree().process_frame
|
|
var img := get_viewport().get_texture().get_image()
|
|
img.save_png(autoshot)
|
|
print("[mtgodot] autoshot -> ", autoshot)
|
|
get_tree().quit()
|
|
|
|
|
|
func _build_environment() -> void:
|
|
var we := WorldEnvironment.new()
|
|
var env := Environment.new()
|
|
var sky := Sky.new()
|
|
sky.sky_material = ProceduralSkyMaterial.new()
|
|
env.background_mode = Environment.BG_SKY
|
|
env.sky = sky
|
|
env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
|
|
env.ambient_light_energy = 0.6
|
|
env.tonemap_mode = Environment.TONE_MAPPER_FILMIC
|
|
# T2.5.4 — distance fog via the engine (not per-shader).
|
|
if OS.get_environment("MTGODOT_FOG") == "1":
|
|
env.fog_enabled = true
|
|
env.fog_mode = Environment.FOG_MODE_DEPTH
|
|
env.fog_light_color = Color(0.55, 0.55, 0.62)
|
|
env.fog_sun_scatter = 0.1
|
|
env.fog_density = 0.0
|
|
env.fog_depth_begin = 3.0
|
|
env.fog_depth_end = 12.0
|
|
we.environment = env
|
|
add_child(we)
|
|
|
|
|
|
func _build_sun() -> void:
|
|
var sun := DirectionalLight3D.new()
|
|
sun.rotation_degrees = Vector3(-50.0, -40.0, 0.0)
|
|
sun.shadow_enabled = true
|
|
sun.light_energy = 1.1
|
|
add_child(sun)
|
|
|
|
|
|
func _build_camera() -> void:
|
|
_cam = Camera3D.new()
|
|
_cam.current = true
|
|
_cam.fov = 45.0
|
|
_cam.near = 0.01
|
|
add_child(_cam)
|
|
_update_camera()
|
|
|
|
|
|
func _env(name: String, fallback: String) -> String:
|
|
var v := OS.get_environment(name)
|
|
return v if v != "" else fallback
|
|
|
|
|
|
func _load_content() -> void:
|
|
if not ClassDB.class_exists("Metin2Model"):
|
|
push_error("[mtgodot] Metin2Model not registered — GDExtension failed to load")
|
|
return
|
|
|
|
var model_path := _env("MTGODOT_MODEL",
|
|
_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"))
|
|
# 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/action/dance_1.msa"))
|
|
|
|
_model = ClassDB.instantiate("Metin2Model")
|
|
_model.name = "Metin2Model"
|
|
_model.set("texture_dir", tex_dir)
|
|
_model.set("gr2_path", model_path)
|
|
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")
|
|
var fixed_t := OS.get_environment("MTGODOT_ANIM_T")
|
|
if fixed_t != "":
|
|
_anim.set("playing", false)
|
|
_anim.call("set_time", float(fixed_t))
|
|
print("[mtgodot] fixed t=", _anim.call("get_time"), " dur=", _anim.call("get_duration"))
|
|
|
|
|
|
func _frame_model() -> void:
|
|
# 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
|
|
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
|
|
_pitch = 0.12
|
|
var yenv := OS.get_environment("MTGODOT_YAW")
|
|
if yenv != "":
|
|
_yaw = float(yenv)
|
|
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),
|
|
sin(_pitch),
|
|
cos(_pitch) * cos(_yaw)) * _dist
|
|
_cam.position = _target + offset
|
|
_cam.look_at(_target, Vector3.UP)
|
|
|
|
|
|
func _unhandled_input(event: InputEvent) -> void:
|
|
if event is InputEventMouseButton:
|
|
var mb := event as InputEventMouseButton
|
|
match mb.button_index:
|
|
MOUSE_BUTTON_LEFT:
|
|
_dragging = mb.pressed
|
|
MOUSE_BUTTON_WHEEL_UP:
|
|
_dist = maxf(0.2, _dist * (1.0 - ZOOM_STEP))
|
|
_update_camera()
|
|
MOUSE_BUTTON_WHEEL_DOWN:
|
|
_dist = minf(200.0, _dist * (1.0 + ZOOM_STEP))
|
|
_update_camera()
|
|
elif event is InputEventMouseMotion and _dragging:
|
|
var mm := event as InputEventMouseMotion
|
|
_yaw -= mm.relative.x * ORBIT_SPEED
|
|
_pitch = clampf(_pitch + mm.relative.y * ORBIT_SPEED, -PITCH_LIMIT, PITCH_LIMIT)
|
|
_update_camera()
|
|
elif event is InputEventKey and event.pressed and not event.echo:
|
|
_on_key(event.keycode)
|
|
|
|
|
|
func _on_key(kc: int) -> void:
|
|
match kc:
|
|
KEY_F2:
|
|
_screenshot()
|
|
KEY_SPACE:
|
|
if _anim:
|
|
_anim.set("playing", not _anim.get("playing"))
|
|
KEY_B:
|
|
if _anim:
|
|
_anim.set("playing", false)
|
|
_anim.call("set_time", 0.0)
|
|
KEY_R:
|
|
if _model:
|
|
_model.call("reload")
|
|
if _anim:
|
|
_anim.call("reload")
|
|
_frame_model()
|
|
KEY_BRACKETLEFT:
|
|
if _anim:
|
|
_anim.call("set_time", _anim.call("get_time") - 0.05)
|
|
KEY_BRACKETRIGHT:
|
|
if _anim:
|
|
_anim.call("set_time", _anim.call("get_time") + 0.05)
|
|
|
|
|
|
## MTGODOT_STRESS=N : spawn N animated warriors in a grid, measure frame time
|
|
## over ~180 frames, write test/godot-macos-stress.json, quit.
|
|
## 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
|
|
var model_path := _assets_root.path_join("PC/ymir work/pc/warrior/warrior_cheongrin.gr2")
|
|
var tex_dir := _assets_root.path_join("PC/ymir work/pc/warrior")
|
|
var anim_path := _assets_root.path_join("PC/ymir work/pc/warrior/action/dance_1.gr2")
|
|
var cols := int(ceil(sqrt(float(n))))
|
|
var t_build_start := Time.get_ticks_usec()
|
|
for i in n:
|
|
var m: Node3D = ClassDB.instantiate("Metin2Model")
|
|
m.name = "M%d" % i
|
|
m.set("texture_dir", tex_dir)
|
|
m.set("gr2_path", model_path)
|
|
m.position = Vector3((i % cols) * 1.2 - cols * 0.6, 0, (i / cols) * 1.2)
|
|
add_child(m)
|
|
var a: Node3D = ClassDB.instantiate("Metin2AnimPlayer")
|
|
a.set("model_path", NodePath("../M%d" % i))
|
|
a.set("anim_path", anim_path)
|
|
a.set("time_scale", 1.0 + 0.01 * i) # desync
|
|
add_child(a)
|
|
a.call("reload")
|
|
a.call("set_time", 0.13 * i)
|
|
var build_ms := (Time.get_ticks_usec() - t_build_start) / 1000.0
|
|
|
|
_target = Vector3(0, 1.0, cols * 0.6)
|
|
_dist = cols * 2.2
|
|
_pitch = 0.35
|
|
_update_camera()
|
|
|
|
# warm-up
|
|
for i in 40:
|
|
await get_tree().process_frame
|
|
var total: Array[float] = []
|
|
var proc: Array[float] = []
|
|
for i in 240:
|
|
await get_tree().process_frame
|
|
total.append(1000.0 / maxf(Engine.get_frames_per_second(), 1.0))
|
|
proc.append(Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0)
|
|
total.sort()
|
|
proc.sort()
|
|
var tsum := 0.0
|
|
for s in total:
|
|
tsum += s
|
|
var psum := 0.0
|
|
for s in proc:
|
|
psum += s
|
|
var report := {
|
|
"characters": n,
|
|
"build_ms_total": build_ms,
|
|
"build_ms_per_char": build_ms / n,
|
|
"frames": total.size(),
|
|
"frame_ms_avg": tsum / total.size(),
|
|
"frame_ms_p95": total[int(total.size() * 0.95)],
|
|
"frame_ms_max": total[-1],
|
|
"fps_avg": 1000.0 / (tsum / total.size()),
|
|
"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 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 == "":
|
|
out = ProjectSettings.globalize_path("res://../test/godot-macos-stress.json")
|
|
var f := FileAccess.open(out, FileAccess.WRITE)
|
|
if f:
|
|
f.store_string(JSON.stringify(report, " "))
|
|
f.close()
|
|
print("[mtgodot] stress ", n, " -> ", report)
|
|
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()
|
|
img.save_png(path)
|
|
print("[mtgodot] screenshot -> ", ProjectSettings.globalize_path(path))
|