#include "metin2_anim.h" #include "asset_io.h" #include "gr2_bridge.h" #include "metin2_model.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include using namespace godot; namespace mtgodot { namespace { struct ClipEntry { String path; uint64_t modified; size_t bytes; std::shared_ptr file; }; // Immutable decoded clips are shared, not playback time/pose/event cursors. // Main-thread animation loading only; no Godot objects survive in this cache. std::list clip_cache; size_t clip_bytes = 0; uint64_t clip_hits = 0, clip_misses = 0; constexpr size_t CLIP_LIMIT = 32, CLIP_BYTE_LIMIT = 64 * 1024 * 1024; struct MsaEntry { String path; uint64_t modified; fmt::Msa metadata; }; std::list msa_cache; uint64_t msa_hits = 0, msa_misses = 0; // Keep events immutable in the cache; reload copies them into each actor. bool cached_msa(const String &path, fmt::Msa &metadata, std::string *error) { const uint64_t modified = FileAccess::get_modified_time(path); for (auto it = msa_cache.begin(); it != msa_cache.end(); ++it) { if (it->path != path) continue; if (it->modified == modified) { ++msa_hits; metadata = it->metadata; msa_cache.splice(msa_cache.begin(), msa_cache, it); return true; } msa_cache.erase(it); break; } ++msa_misses; if (!fmt::parse_msa_file(std::string(path.utf8().get_data()), metadata, error)) return false; if (msa_cache.size() >= 128) msa_cache.pop_back(); msa_cache.push_front({path, modified, metadata}); return true; } std::shared_ptr cached_clip(const String &path, gr2::LoadError *err) { uint64_t modified = FileAccess::get_modified_time(path); for (auto it = clip_cache.begin(); it != clip_cache.end(); ++it) { if (it->path != path) continue; if (it->modified == modified) { ++clip_hits; clip_cache.splice(clip_cache.begin(), clip_cache, it); return clip_cache.front().file; } clip_bytes -= it->bytes; clip_cache.erase(it); break; } ++clip_misses; auto loaded = gr2_from_file(path, err); if (!loaded) return {}; size_t bytes = 0; for (size_t i = 0; i < loaded->sections().size(); ++i) bytes += loaded->section_bytes(i).size(); auto file = std::make_shared(std::move(*loaded)); if (bytes <= CLIP_BYTE_LIMIT) { while (!clip_cache.empty() && (clip_cache.size() >= CLIP_LIMIT || clip_bytes + bytes > CLIP_BYTE_LIMIT)) { clip_bytes -= clip_cache.back().bytes; clip_cache.pop_back(); } clip_cache.push_front({path, modified, bytes, file}); clip_bytes += bytes; } return file; } } Dictionary Metin2AnimPlayer::get_clip_cache_stats() { Dictionary d; d["hits"] = clip_hits; d["misses"] = clip_misses; d["entries"] = static_cast(clip_cache.size()); d["section_bytes"] = static_cast(clip_bytes); d["msa_hits"] = msa_hits; d["msa_misses"] = msa_misses; d["msa_entries"] = static_cast(msa_cache.size()); return d; } void Metin2AnimPlayer::clear_clip_cache() { clip_cache.clear(); clip_bytes = 0; clip_hits = clip_misses = 0; msa_cache.clear(); msa_hits = msa_misses = 0; } Metin2AnimPlayer::Metin2AnimPlayer() {} Metin2AnimPlayer::~Metin2AnimPlayer() = default; void Metin2AnimPlayer::_bind_methods() { ClassDB::bind_static_method("Metin2AnimPlayer", D_METHOD("get_clip_cache_stats"), &Metin2AnimPlayer::get_clip_cache_stats); ClassDB::bind_static_method("Metin2AnimPlayer", D_METHOD("clear_clip_cache"), &Metin2AnimPlayer::clear_clip_cache); ClassDB::bind_method(D_METHOD("set_anim_path", "path"), &Metin2AnimPlayer::set_anim_path); ClassDB::bind_method(D_METHOD("get_anim_path"), &Metin2AnimPlayer::get_anim_path); ClassDB::bind_method(D_METHOD("set_model_path", "path"), &Metin2AnimPlayer::set_model_path); ClassDB::bind_method(D_METHOD("get_model_path"), &Metin2AnimPlayer::get_model_path); ClassDB::bind_method(D_METHOD("set_playing", "v"), &Metin2AnimPlayer::set_playing); ClassDB::bind_method(D_METHOD("get_playing"), &Metin2AnimPlayer::get_playing); ClassDB::bind_method(D_METHOD("set_loop", "v"), &Metin2AnimPlayer::set_loop); ClassDB::bind_method(D_METHOD("get_loop"), &Metin2AnimPlayer::get_loop); ClassDB::bind_method(D_METHOD("set_time_scale", "s"), &Metin2AnimPlayer::set_time_scale); ClassDB::bind_method(D_METHOD("get_time_scale"), &Metin2AnimPlayer::get_time_scale); ClassDB::bind_method(D_METHOD("set_blend_time", "s"), &Metin2AnimPlayer::set_blend_time); ClassDB::bind_method(D_METHOD("get_blend_time"), &Metin2AnimPlayer::get_blend_time); ClassDB::bind_method(D_METHOD("set_time", "t"), &Metin2AnimPlayer::set_time); ClassDB::bind_method(D_METHOD("get_time"), &Metin2AnimPlayer::get_time); ClassDB::bind_method(D_METHOD("get_duration"), &Metin2AnimPlayer::get_duration); ClassDB::bind_method(D_METHOD("reload"), &Metin2AnimPlayer::reload); ClassDB::bind_method(D_METHOD("get_info"), &Metin2AnimPlayer::get_info); ClassDB::bind_method(D_METHOD("selfcheck", "samples"), &Metin2AnimPlayer::selfcheck, DEFVAL(24)); ClassDB::bind_method(D_METHOD("get_accumulation"), &Metin2AnimPlayer::get_accumulation); ClassDB::bind_method(D_METHOD("get_events"), &Metin2AnimPlayer::get_events); ClassDB::bind_method(D_METHOD("get_effect_bone_pose", "bone"), &Metin2AnimPlayer::get_effect_bone_pose); ClassDB::bind_method(D_METHOD("get_loop_data"), &Metin2AnimPlayer::get_loop_data); ClassDB::bind_method(D_METHOD("get_motion_data"), &Metin2AnimPlayer::get_motion_data); ClassDB::bind_static_method("Metin2AnimPlayer", D_METHOD("parse_combo_tables", "py_path"), &Metin2AnimPlayer::parse_combo_tables); ClassDB::bind_static_method("Metin2AnimPlayer", D_METHOD("make_combo_key", "motion_mode", "combo_type"), &Metin2AnimPlayer::make_combo_key); // Fired when playback time crosses a .msa MotionEventData entry. ADD_SIGNAL(MethodInfo("motion_event", PropertyInfo(Variant::INT, "type"), PropertyInfo(Variant::STRING, "effect"), PropertyInfo(Variant::STRING, "sound"), PropertyInfo(Variant::VECTOR3, "pos"))); ADD_SIGNAL(MethodInfo("playback_finished")); ADD_SIGNAL(MethodInfo("motion_event_detailed", PropertyInfo(Variant::DICTIONARY, "event"))); ADD_PROPERTY(PropertyInfo(Variant::STRING, "anim_path", PROPERTY_HINT_GLOBAL_FILE, "*.gr2,*.msa"), "set_anim_path", "get_anim_path"); ADD_PROPERTY(PropertyInfo(Variant::NODE_PATH, "model_path", PROPERTY_HINT_NODE_PATH_VALID_TYPES, "Node3D"), "set_model_path", "get_model_path"); ADD_PROPERTY(PropertyInfo(Variant::BOOL, "playing"), "set_playing", "get_playing"); ADD_PROPERTY(PropertyInfo(Variant::BOOL, "loop"), "set_loop", "get_loop"); ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "time_scale", PROPERTY_HINT_RANGE, "0,4,0.01"), "set_time_scale", "get_time_scale"); ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "blend_time", PROPERTY_HINT_RANGE, "0,1,0.01"), "set_blend_time", "get_blend_time"); } void Metin2AnimPlayer::set_anim_path(const String &p) { if (p == anim_path) { if (!playing) { time = 0.0; prev_time = 0.0; playing = true; } return; } // Start a crossfade from the clip that is currently playing. if (is_inside_tree() && anim_file && blend_time > 0.0) { prev_anim_file = std::move(anim_file); // anim_file now empty; reload() refills it prev_anim_duration = duration; prev_anim_loop = loop; double pt = time; if (prev_anim_duration > 0.0) { if (prev_anim_loop) { pt = std::fmod(pt, prev_anim_duration); if (pt < 0.0) { pt += prev_anim_duration; } } else { pt = std::fmax(0.0, std::fmin(pt, prev_anim_duration)); } } prev_anim_time = pt; blend_elapsed = 0.0; } anim_path = p; if (is_inside_tree()) { reload(); } } void Metin2AnimPlayer::set_model_path(const NodePath &p) { model_path = p; } void Metin2AnimPlayer::set_time(double t) { time = (!loop && duration > 0.0) ? std::fmax(0.0, std::fmin(t, duration)) : t; prev_time = time; // scrubbing must not replay every event since the old cursor if (is_inside_tree()) { apply_pose(time); } } Metin2Model *Metin2AnimPlayer::resolve_model() const { if (model_path.is_empty()) { return nullptr; } Node *n = get_node_or_null(model_path); return Object::cast_to(n); } // "d:\Ymir Work\pc\warrior\action\dance.gr2" -> "/PC/ymir work/pc/warrior/action/dance.gr2" // where is found by walking up from the .msa's own dir past "ymir work". // Falls back to / (the anim gr2 is usually right next to it). static String strip_dpath(const String &raw) { String p = raw.replace("\\", "/"); int k = p.to_lower().find("ymir work"); return k >= 0 ? p.substr(k) : p; // "ymir work/pc/.../x.gr2" } String Metin2AnimPlayer::resolve_anim_gr2(const String &spec, const fmt::Msa &m) { String low = spec.to_lower(); if (!low.ends_with(".msa")) { return spec; // already a .gr2 path } const String motion = String(m.motion_gr2.c_str()); const String base = motion.replace("\\", "/").get_file(); const String msa_dir = spec.get_base_dir(); // 1. sibling of the .msa String cand = msa_dir.path_join(base); if (FileAccess::file_exists(cand)) { return cand; } // 2. /PC/, root = ancestor of the .msa above "ymir work" String tail = strip_dpath(motion); // "ymir work/pc/.../x.gr2" int ky = msa_dir.to_lower().find("ymir work"); if (ky > 0) { String root = spec.substr(0, ky); // ".../assets/PC/" cand = root.path_join(tail.substr(String("ymir work/").length())); if (FileAccess::file_exists(cand)) { return cand; } cand = root.get_base_dir().path_join(tail); // ".../assets/" + "ymir work/..." if (FileAccess::file_exists(cand)) { return cand; } } UtilityFunctions::push_warning(String("[Metin2AnimPlayer] .msa motion gr2 not found: ") + motion + " (tried " + msa_dir.path_join(base) + ")"); return cand; } static Dictionary motion_event_dict(const fmt::MotionEvent &ev) { Dictionary d; d["type"] = ev.type; d["start_time"] = ev.start_time; d["effect"] = String(ev.effect_file.c_str()); d["sound"] = String(ev.sound_file.c_str()); d["pos"] = Vector3(ev.position[0], ev.position[1], ev.position[2]); d["bone"] = String(ev.attaching_bone.c_str()); d["attaching"] = ev.attaching; d["following"] = ev.following; d["independent"] = ev.independent; d["fishing_effect"] = ev.fishing_effect; return d; } Dictionary Metin2AnimPlayer::get_effect_bone_pose(const String &bone) { Dictionary result; Metin2Model *model = resolve_model(); const gr2::Skeleton *sk = model ? model->gr2_skeleton() : nullptr; if (!sk || !anim_file) return result; for (size_t i = 0; i < sk->bones.size() && i < world_buf.size(); ++i) { if (String(sk->bones[i].name.c_str()) == bone) { result["transform"] = gr2_to_godot(world_buf[i]); break; } } return result; } void Metin2AnimPlayer::emit_motion_event(const fmt::MotionEvent &ev) { emit_signal("motion_event", ev.type, String(ev.effect_file.c_str()), String(ev.sound_file.c_str()), Vector3(ev.position[0], ev.position[1], ev.position[2])); emit_signal("motion_event_detailed", motion_event_dict(ev)); } Array Metin2AnimPlayer::get_events() const { Array a; for (const fmt::MotionEvent &ev : events) { a.push_back(motion_event_dict(ev)); } return a; } Dictionary Metin2AnimPlayer::get_loop_data() const { Dictionary d; d["present"] = msa_metadata.has_loop_data; d["count"] = msa_metadata.motion_loop_count; d["cancel_enable"] = msa_metadata.loop_cancel_enable; d["start_time"] = msa_metadata.loop_start_time; d["end_time"] = msa_metadata.loop_end_time; return d; } // CLIENT-GAP §3.3 / §3.5 — the currently-loaded .msa's ComboInputData / AttackingData. // `next_combo` is the reference普攻节奏 (CRaceMotionData::GetNextComboTime); when the // motion has no ComboInputData it falls back to MotionDuration * 0.9. Dictionary Metin2AnimPlayer::get_motion_data() const { const fmt::Msa &m = msa_metadata; Dictionary d; d["duration"] = duration > 0.0 ? duration : m.duration; d["has_combo_input"] = m.has_combo_input; d["pre_input_time"] = m.combo_pre_input_time; d["direct_input_time"] = m.combo_direct_input_time; d["input_limit_time"] = m.combo_input_limit_time; d["link_time"] = m.combo_link_time; const double dur = duration > 0.0 ? duration : m.duration; d["next_combo"] = m.has_combo_input && m.combo_direct_input_time > 0.0 ? (double)m.combo_direct_input_time : dur * 0.9; d["has_attacking_data"] = m.has_attacking_data; d["attacking_type"] = m.attacking_type; d["motion_type"] = m.motion_type; d["hitting_type"] = m.hitting_type; d["attack_start_time"] = m.attack_start_time; d["attack_end_time"] = m.attack_end_time; d["stiffen_time"] = m.stiffen_time; d["invisible_time"] = m.invisible_time; d["external_force"] = m.external_force; d["hit_limit_count"] = m.hit_limit_count; // CLIENT-GAP §3.5 修改 1 —— THitDataContainer 多命中窗(多段挥击 / 双持 = 多个窗)。 // [{start_time, end_time, bone, weapon_length, samples:[{time, last_pos:Vector3, pos:Vector3}]}] Array windows; for (const fmt::Msa::HitWindow &w : m.hit_windows) { Dictionary wd; wd["start_time"] = w.start_time; wd["end_time"] = w.end_time; wd["bone"] = String::utf8(w.bone_name.c_str()); wd["weapon_length"] = w.weapon_length; Array samples; for (const fmt::Msa::HitSample &s : w.samples) { Dictionary sd; sd["time"] = s.time; sd["last_pos"] = Vector3(s.last_pos[0], s.last_pos[1], s.last_pos[2]); sd["pos"] = Vector3(s.pos[0], s.pos[1], s.pos[2]); samples.push_back(sd); } wd["samples"] = samples; windows.push_back(wd); } d["hit_windows"] = windows; return d; } int Metin2AnimPlayer::make_combo_key(int motion_mode, int combo_type) { return (int)fmt::make_combo_key((uint16_t)motion_mode, (uint16_t)combo_type); } Dictionary Metin2AnimPlayer::parse_combo_tables(const String &py_path) { Dictionary out; fmt::PlayerComboTables tables; std::string err; if (!fmt::parse_player_combo_tables_file( std::string(py_path.utf8().get_data()), tables, &err)) { UtilityFunctions::push_warning( String("Metin2AnimPlayer.parse_combo_tables: ") + String(err.c_str())); return out; } for (int c = 0; c < fmt::COMBO_CLASS_COUNT; ++c) { Dictionary cls; for (const auto &kv : tables.klass(c).combos) { PackedInt32Array segs; segs.resize((int)kv.second.size()); for (int i = 0; i < (int)kv.second.size(); ++i) { segs.set(i, (int)kv.second[i]); } cls[(int)kv.first] = segs; } out[c] = cls; } return out; } void Metin2AnimPlayer::_ready() { set_process(true); reload(); } void Metin2AnimPlayer::reload() { anim_file.reset(); duration = 0.0; time = 0.0; prev_time = 0.0; next_event = 0; accumulation = Vector3(); events.clear(); msa_metadata = fmt::Msa{}; last_info = ""; playing = true; if (anim_path.is_empty()) { return; } // .msa -> resolve the real motion .gr2 + pull accumulation / events. const bool diagnostics = OS::get_singleton()->get_environment("MTGODOT_ANIM_DIAGNOSTICS") == "1"; String gr2_spec = anim_path; if (anim_path.to_lower().ends_with(".msa")) { fmt::Msa m; std::string e; if (cached_msa(anim_path, m, &e)) { msa_metadata = m; accumulation = Vector3(m.accumulation[0], m.accumulation[1], m.accumulation[2]); events = m.events; // Reuse the metadata just parsed; path resolution must not parse it again. gr2_spec = resolve_anim_gr2(anim_path, m); if (diagnostics) UtilityFunctions::print(vformat("[Metin2AnimPlayer] .msa -> %s accum=(%.2f %.2f %.2f) events=%d loopdata=%s", gr2_spec, accumulation.x, accumulation.y, accumulation.z, (int)events.size(), m.has_loop_data ? vformat("%d x [%.3f,%.3f]", m.motion_loop_count, m.loop_start_time, m.loop_end_time) : String("none"))); } else { UtilityFunctions::push_error(String("[Metin2AnimPlayer] .msa: ") + String(e.c_str())); return; } } gr2::LoadError err; auto f = cached_clip(gr2_spec, &err); if (!f) { UtilityFunctions::push_error(String("[Metin2AnimPlayer] anim load failed [") + String(err.stage.c_str()) + "]: " + String(err.message.c_str())); return; } const gr2::FileInfo &fi = f->file_info(); if (fi.animations.empty()) { UtilityFunctions::push_error("[Metin2AnimPlayer] no animations in " + anim_path); return; } anim_file = std::move(f); const gr2::Animation &an = anim_file->file_info().animations[0]; duration = an.duration; // Diagnostic: how many anim tracks map to a model-skeleton bone by name? if (Metin2Model *model = diagnostics ? resolve_model() : nullptr) { if (const gr2::Skeleton *sk = model->gr2_skeleton()) { int matched = 0; godot::String unmatched; for (const auto &tr : an.tracks) { bool hit = false; for (const auto &b : sk->bones) { if (b.name == tr.bone_name) { hit = true; break; } } if (hit) { ++matched; } else if (unmatched.length() < 300) { unmatched += String(tr.bone_name.c_str()) + " "; } } if (diagnostics) { UtilityFunctions::print(vformat( "[Metin2AnimPlayer] track->bone match: %d/%d (skel bones=%d) unmatched: %s", matched, (int)an.tracks.size(), (int)sk->bones.size(), unmatched)); } } } last_info = vformat("anim '%s' dur=%.3f tracks=%d anims=%d", String(an.name.c_str()), duration, (int)an.tracks.size(), (int)anim_file->file_info().animations.size()); if (diagnostics) UtilityFunctions::print("[Metin2AnimPlayer] ", last_info); apply_pose(0.0); } namespace { // mul4x3 lives in gr2_bridge.h now (shared with the weapon-attach grip compose). // Blend two local bone transforms: slerp rotation, lerp translation and the // complete scale/shear basis (PARITY §2.9). Several warrior/sura armour bones // depend on authored shear, so it must survive the transition as well as steady // animation sampling. gr2::Mat4 blend_trs(const gr2::Mat4 &a, const gr2::Mat4 &b, float w) { godot::Basis ba(godot::Vector3(a[0], a[1], a[2]), godot::Vector3(a[4], a[5], a[6]), godot::Vector3(a[8], a[9], a[10])); godot::Basis bb(godot::Vector3(b[0], b[1], b[2]), godot::Vector3(b[4], b[5], b[6]), godot::Vector3(b[8], b[9], b[10])); const godot::Quaternion qa = ba.get_rotation_quaternion(); const godot::Quaternion qb = bb.get_rotation_quaternion(); godot::Quaternion q = qa.slerp(qb, w); // Preserve Granny's complete scale/shear matrix. Keeping only get_scale() // changes authored joints even at the endpoints and is especially visible in // hands and shoulders. For B = R*S, extract S and interpolate all 3x3 terms. const godot::Basis sa = godot::Basis(qa).inverse() * ba; const godot::Basis sb = godot::Basis(qb).inverse() * bb; godot::Basis ss; for (int row = 0; row < 3; ++row) { ss.rows[row] = sa.rows[row].lerp(sb.rows[row], w); } godot::Vector3 tt = godot::Vector3(a[12], a[13], a[14]).lerp(godot::Vector3(b[12], b[13], b[14]), w); const godot::Basis rb = godot::Basis(q) * ss; gr2::Mat4 o{}; o[0] = rb.rows[0].x; o[1] = rb.rows[0].y; o[2] = rb.rows[0].z; o[4] = rb.rows[1].x; o[5] = rb.rows[1].y; o[6] = rb.rows[1].z; o[8] = rb.rows[2].x; o[9] = rb.rows[2].y; o[10] = rb.rows[2].z; o[12] = tt.x; o[13] = tt.y; o[14] = tt.z; o[15] = 1.0f; return o; } // Sample animation tracks into the model skeleton's LOCAL bone domain. Granny // blends controls before BuildWorldPose; doing the crossfade after world-pose // accumulation independently interpolates every joint endpoint and temporarily // changes parent/child distances (visible as stretched or collapsed limbs when // wait/walk/run switches). void sample_local_pose(const gr2::Skeleton &sk, const gr2::Animation &an, float t, std::vector &local) { std::vector track_local; an.sample_local(t, track_local); local.resize(sk.bones.size()); for (size_t i = 0; i < sk.bones.size(); ++i) { local[i] = sk.bones[i].local_transform; } for (size_t ti = 0; ti < an.tracks.size() && ti < track_local.size(); ++ti) { for (size_t bi = 0; bi < sk.bones.size(); ++bi) { if (sk.bones[bi].name == an.tracks[ti].bone_name) { local[bi] = track_local[ti]; break; } } } } void accumulate_local_pose(const gr2::Skeleton &sk, const std::vector &local, const gr2::Mat4 &root_offset, std::vector &world, std::vector &skin) { static const gr2::Mat4 I{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 }; const size_t n = sk.bones.size(); world.assign(n, I); skin.assign(n, I); for (size_t i = 0; i < n; ++i) { const int parent = sk.bones[i].parent; const gr2::Mat4 &parent_world = (parent < 0 || static_cast(parent) >= i) ? root_offset : world[parent]; world[i] = mul4x3(i < local.size() ? local[i] : sk.bones[i].local_transform, parent_world); skin[i] = mul4x3(sk.bones[i].inverse_world, world[i]); } } } // namespace void Metin2AnimPlayer::apply_pose(double t) { if (!anim_file) { return; } Metin2Model *model = resolve_model(); if (!model) { return; } const gr2::Skeleton *sk = model->gr2_skeleton(); Skeleton3D *skel = model->skeleton_node(); if (!sk || !skel) { return; } const gr2::Animation &an = anim_file->file_info().animations[0]; double tt = t; if (duration > 0.0 && loop) { tt = std::fmod(t, duration); if (tt < 0.0) { tt += duration; } } else if (duration > 0.0) { tt = std::fmax(0.0, std::fmin(t, duration)); } // Offset4x4 = identity, NOT the model's exported InitialPlacement. // // gr2::sample_pose defaults to sk.initial_placement because that is what the // oracle passes (GrannyGetModelInitialPlacement4x4) and what the bind pose / // InverseWorldTransform pair is self-consistent with. Metin2's animation .gr2 // carries the root bone's pelvis height inside its own track, so composing it // onto InitialPlacement counts that placement twice and lifts the whole actor // by one pelvis height (warrior 99.4 cm, assassin 92.6, sura 103.7, shaman // 95.5 — exactly each model's IP.z). That was the "人物悬空" report. // Measured with tools/rendering foot probe: with identity the lowest skinned // vertex sits within a few mm of z=0 for wait/walk on all four classes, and // only leaves the ground during run's airborne frames. static const gr2::Mat4 kNoOffset{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 }; bool pose_ready = false; // Crossfade in LOCAL bone space, then accumulate the hierarchy and rebuild // skin_buf = invWorld · world. Blending world transforms made bone endpoints // take independent straight-line paths, shortening limbs around the midpoint. if (prev_anim_file && blend_time > 0.0 && blend_elapsed < blend_time) { const gr2::FileInfo &pfi = prev_anim_file->file_info(); if (!pfi.animations.empty()) { sample_local_pose(*sk, an, (float)tt, local_buf); sample_local_pose(*sk, pfi.animations[0], (float)prev_anim_time, prev_local_buf); double w = blend_elapsed / blend_time; // ease-in on the incoming clip, matching the client's // GrannySetControlEaseInCurve(t0,t1, 0,0,1,1) Hermite (p0=0,m0=0, // p1=1,m1=1) -> h(w) = 2w^2 - w^3. Flat start, slope-1 finish. w = w * w * (2.0 - w); const size_t n = std::min(local_buf.size(), prev_local_buf.size()); blend_local_buf.resize(local_buf.size()); for (size_t i = 0; i < n; ++i) { blend_local_buf[i] = blend_trs(prev_local_buf[i], local_buf[i], (float)w); } for (size_t i = n; i < local_buf.size(); ++i) { blend_local_buf[i] = local_buf[i]; } accumulate_local_pose(*sk, blend_local_buf, kNoOffset, world_buf, skin_buf); pose_ready = true; } } if (!pose_ready) { gr2::sample_pose(*sk, an, (float)tt, world_buf, skin_buf, &kNoOffset); } // Both skinning paths apply libgr2's per-bone deformer matrices // (skin_buf = Σ w · invWorld · world) with the FULL affine — shear kept. // default : CPU LBS, ArrayMesh rebuilt each frame. // MTGODOT_GPUSKIN=1 : LBS in a custom vertex shader (bone matrices in a // float texture, no Skeleton3D). See m2_material SRC_SKIN. // The old Skeleton3D::set_bone_pose route is gone — Godot's built-in skinning // orthonormalizes the bone matrix and drops the shear (docs/MIDREVIEW.md §4). static const String kZero("0"); static const String kEnvName("MTGODOT_GPUSKIN"); String env = godot::OS::get_singleton()->get_environment(kEnvName); // Default to GPU skinning (true) unless explicitly set to "0" if (env != kZero) { model->enable_gpu_skin(true); model->gpu_skin(skin_buf); } else { model->enable_cpu_skin(true); model->cpu_skin(skin_buf); } // Rigid weapon follows equip_right_hand's animated world transform (PARITY §2.1). model->update_weapon_pose(world_buf); } void Metin2AnimPlayer::_process(double delta) { if (!playing || !anim_file) { return; } if (prev_anim_file) { blend_elapsed += delta; // real seconds, independent of time_scale if (blend_elapsed >= blend_time) { prev_anim_file.reset(); } } double next = time + delta * time_scale; if (!loop && duration > 0.0 && next >= duration) { next = duration; } time = next; apply_pose(time); dispatch_events(prev_time, time); prev_time = time; if (!loop && duration > 0.0 && time >= duration) { playing = false; emit_signal("playback_finished"); } } // Emit `motion_event` for every .msa event whose start_time falls in (from, to], // handling loop wrap-around within one clip. void Metin2AnimPlayer::dispatch_events(double from, double to) { if (events.empty() || duration <= 0.0) { return; } if (!loop) { double a = std::fmax(0.0, std::fmin(from, duration)); double b = std::fmax(0.0, std::fmin(to, duration)); for (const fmt::MotionEvent &ev : events) { if (a < ev.start_time && ev.start_time <= b) { emit_motion_event(ev); } } return; } double a = std::fmod(from, duration); if (a < 0) a += duration; double b = a + (to - from); for (const fmt::MotionEvent &ev : events) { double t = ev.start_time; bool hit = (a < t && t <= b) || (b > duration && t <= b - duration); if (hit) { emit_motion_event(ev); } } } String Metin2AnimPlayer::selfcheck(int samples) { if (!anim_file) { return "no anim loaded"; } Metin2Model *model = resolve_model(); const gr2::Skeleton *sk = model ? model->gr2_skeleton() : nullptr; if (!sk) { return "no model/skeleton"; } const auto &anims = anim_file->file_info().animations; int total = 0; int nan_hits = 0; std::vector w, s; for (const auto &an : anims) { double dur = an.duration > 0.0 ? an.duration : 1.0; for (int k = 0; k <= samples; ++k) { float tt = (float)(dur * k / samples); gr2::sample_pose(*sk, an, tt, w, s); for (const auto &mtx : w) { for (float v : mtx) { ++total; if (std::isnan(v) || std::isinf(v)) { ++nan_hits; } } } } } return vformat("selfcheck: anims=%d samples/anim=%d floats=%d NaN/Inf=%d", (int)anims.size(), samples + 1, total, nan_hits); } } // namespace mtgodot