Files
mtgodot-poc/extension/src/metin2_anim.cpp
T
shenandshen c93894313a fix: 装备属性面板避让逻辑 + 多项功能更新
- item_tooltip_view.gd: 新增 avoid_rect 属性,tooltip 与装备窗口重叠时自动推到左侧
- inventory_ui.gd: 悬停装备时传入窗口矩形作为避让区域
- 包含其他累积的功能开发和测试文件
2026-09-21 16:38:59 -07:00

857 lines
32 KiB
C++

#include "metin2_anim.h"
#include "asset_io.h"
#include "gr2_bridge.h"
#include "metin2_model.h"
#include <godot_cpp/classes/skeleton3d.hpp>
#include <godot_cpp/core/class_db.hpp>
#include <godot_cpp/variant/utility_functions.hpp>
#include <cmath>
#include <string>
#include <list>
#include <godot_cpp/classes/mesh_instance3d.hpp>
#include <godot_cpp/classes/os.hpp>
#include <godot_cpp/classes/skin.hpp>
#include <godot_cpp/classes/file_access.hpp>
#include <godot_cpp/variant/array.hpp>
#include <godot_cpp/variant/dictionary.hpp>
#include <godot_cpp/variant/packed_int32_array.hpp>
#include <combo_table.h>
using namespace godot;
namespace mtgodot {
namespace {
struct ClipEntry {
String path;
uint64_t modified;
size_t bytes;
std::shared_ptr<const gr2::File> 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<ClipEntry> 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<MsaEntry> 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<const gr2::File> 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<const gr2::File>(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<int64_t>(clip_cache.size());
d["section_bytes"] = static_cast<int64_t>(clip_bytes);
d["msa_hits"] = msa_hits;
d["msa_misses"] = msa_misses;
d["msa_entries"] = static_cast<int64_t>(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_motion_loop_count", "count"), &Metin2AnimPlayer::set_motion_loop_count);
ClassDB::bind_method(D_METHOD("get_motion_loop_count"), &Metin2AnimPlayer::get_motion_loop_count);
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::INT, "motion_loop_count"),
"set_motion_loop_count", "get_motion_loop_count");
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;
}
// SetMotionLoopCount belongs to the newly bound motion. Do not leak a
// previous skill's override into a normal walk/attack/action clip.
motion_loop_override = false;
motion_loop_count = 0;
// 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_motion_loop_count(int count) {
if (count > 0) {
motion_loop_override = true;
motion_loop_count = count;
} else {
motion_loop_override = false;
motion_loop_count = msa_metadata.has_loop_data ? msa_metadata.motion_loop_count : 0;
}
}
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<Metin2Model>(n);
}
// "d:\Ymir Work\pc\warrior\action\dance.gr2" -> "<root>/PC/ymir work/pc/warrior/action/dance.gr2"
// where <root> is found by walking up from the .msa's own dir past "ymir work".
// Falls back to <msa_dir>/<basename> (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. <root>/PC/<ymir-work tail>, 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["duration_time"] = ev.duration_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;
d["fly_file"] = String(ev.fly_file.c_str());
d["fly_bone"] = String(ev.fly_attaching_bone.c_str());
d["fly_attaching"] = ev.fly_attaching;
d["fly_pos"] = Vector3(ev.fly_position[0], ev.fly_position[1], ev.fly_position[2]);
d["power"] = ev.power;
d["affecting_range"] = ev.affecting_range;
d["enable_hit_process"] = ev.enable_hit_process;
d["attack_type"] = ev.attack_type;
d["hitting_type"] = ev.hitting_type;
d["stiffen_time"] = ev.stiffen_time;
d["invisible_time"] = ev.invisible_time;
d["external_force"] = ev.external_force;
d["hit_limit_count"] = ev.hit_limit_count;
d["collision_type"] = ev.collision_type;
Array spheres;
for (const fmt::MotionEvent::CollisionSphere &sphere : ev.collision_spheres) {
Dictionary sd;
sd["radius"] = sphere.radius;
sd["pos"] = Vector3(sphere.position[0], sphere.position[1], sphere.position[2]);
spheres.push_back(sd);
}
d["collision_spheres"] = spheres;
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;
}
}
if (!motion_loop_override)
motion_loop_count = msa_metadata.has_loop_data ? msa_metadata.motion_loop_count : 0;
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];
// 40250 CRaceMotionData::GetMotionDuration() is sourced from the MSA
// MotionDuration field. GR2 contains sampled tracks, but its clip length
// is not the motion-data authority used by actor timing, LoopData and
// MotionEventData. Keep GR2 duration only for raw .gr2 paths or malformed
// MSA files without a positive MotionDuration.
duration = msa_metadata.duration > 0.0 ? msa_metadata.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<gr2::Mat4> &local) {
std::vector<gr2::Mat4> 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<gr2::Mat4> &local,
const gr2::Mat4 &root_offset, std::vector<gr2::Mat4> &world,
std::vector<gr2::Mat4> &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<size_t>(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;
// 40250 does not loop the whole clip. For a motion with LoopData it keeps
// the lead-in and tail, and only rewinds LoopStartTime..LoopEndTime while
// the actor's remaining loop count is > 1 (or -1 for infinite looping).
// The legacy actor resets to LoopStartTime at the first frame after the
// loop end, so the frame remainder is intentionally discarded here too.
const bool has_segment_loop = !loop && msa_metadata.has_loop_data &&
motion_loop_count != 0 &&
(motion_loop_count == -1 || motion_loop_count > 1) &&
msa_metadata.loop_end_time > msa_metadata.loop_start_time &&
msa_metadata.loop_end_time <= duration;
if (has_segment_loop && next > msa_metadata.loop_end_time) {
dispatch_events(time, msa_metadata.loop_end_time);
time = msa_metadata.loop_start_time;
if (motion_loop_count > 0)
--motion_loop_count;
apply_pose(time);
// ActorInstance resets dwcurFrame to LoopStartTime and processes
// that frame immediately. Without an inclusive point dispatch,
// events authored exactly at the loop boundary never fire.
dispatch_events(time, time, true);
prev_time = time;
return;
}
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, bool include_from) {
if (events.empty() || duration <= 0.0) {
return;
}
const double epsilon = 1e-9;
if (to < from - epsilon) {
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) {
const bool at_inclusive_start = include_from &&
std::fabs(double(ev.start_time) - a) <= epsilon;
// The first 40250 motion frame is frame 0, not frame 1. The
// normal interval remains half-open on the left so a regular
// frame cannot replay an event on every tick.
const bool initial_zero = !include_from && from <= epsilon &&
to > from + epsilon && std::fabs(double(ev.start_time)) <= epsilon;
if (at_inclusive_start || initial_zero ||
(a < ev.start_time && ev.start_time <= b)) {
emit_motion_event(ev);
}
}
return;
}
// Walk each clip segment independently. The old single-wrap expression
// could emit at most one copy of an event when time_scale/delta crossed
// two or more full durations; ActorInstance processes every frame and
// therefore reaches every crossed MotionEventData entry.
double cursor = from;
double remaining = to - from;
if (remaining <= epsilon) {
if (!include_from) {
return;
}
double point = std::fmod(cursor, duration);
if (point < 0) point += duration;
for (const fmt::MotionEvent &ev : events) {
if (std::fabs(double(ev.start_time) - point) <= epsilon) {
emit_motion_event(ev);
}
}
return;
}
while (remaining > epsilon) {
double a = std::fmod(cursor, duration);
if (a < 0) a += duration;
double segment = std::fmin(remaining, duration - a);
if (segment <= epsilon) {
cursor += duration;
continue;
}
double b = a + segment;
const bool include_segment_start = a <= epsilon;
for (const fmt::MotionEvent &ev : events) {
const bool at_segment_start = include_segment_start &&
std::fabs(double(ev.start_time) - a) <= epsilon;
const bool crossed = a < ev.start_time && ev.start_time <= b;
if (at_segment_start || crossed) {
emit_motion_event(ev);
}
}
cursor += segment;
remaining -= segment;
}
}
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<gr2::Mat4> 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