feat(client): 完成40250客户端核心功能1:1对齐与桥梁高度采样修复
- 桥梁与静态物体高度采样修复: - 严格对齐 40250 CMapOutdoor::GetHeight 与 CAttributeInstance::GetHeight - 解析 .mdatr 中的 AttributeHeight 网格,使用 is_in_triangle_2d 准确计算桥面多边形平面方程 - sample_height 查询邻近区块并返回 fMAX(fObjectHeight, fTerrainHeight),彻底解决走上桥面穿透掉入水底/河床的问题 - 新增 test_bridge_height_parity.gd 自动化对拍测试 - 40250 怪物击杀经验动效: - 1:1 实现 FLY_EXP(0) / FLY_HP / FLY_SP 粒子轨迹与爆炸吸附 - 40250 客户端全系统功能对齐(Batches 1-31): - 包含公会、交易、骑乘、变身、钓鱼、采矿、商城、信件、结婚、地牢等 134 套对拍系统与自动化回归测试 - 文档沉淀: - 新增 docs/CLIENT-PARITY-AUDIT-AND-FIX-GUIDE.md 客户端对拍缺陷发现与修复工程指南
This commit is contained in:
@@ -173,6 +173,11 @@ void Metin2AnimPlayer::_bind_methods() {
|
||||
|
||||
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.
|
||||
@@ -407,6 +412,7 @@ void Metin2AnimPlayer::reload() {
|
||||
events.clear();
|
||||
msa_metadata = fmt::Msa{};
|
||||
last_info = "";
|
||||
playing = true;
|
||||
if (anim_path.is_empty()) {
|
||||
return;
|
||||
}
|
||||
@@ -468,9 +474,11 @@ void Metin2AnimPlayer::reload() {
|
||||
unmatched += String(tr.bone_name.c_str()) + " ";
|
||||
}
|
||||
}
|
||||
UtilityFunctions::print(vformat(
|
||||
"[Metin2AnimPlayer] track->bone match: %d/%d (skel bones=%d) unmatched: %s",
|
||||
matched, (int)an.tracks.size(), (int)sk->bones.size(), unmatched));
|
||||
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",
|
||||
@@ -484,22 +492,30 @@ namespace {
|
||||
|
||||
// mul4x3 lives in gr2_bridge.h now (shared with the weapon-attach grip compose).
|
||||
|
||||
// Blend two bone world transforms: slerp rotation, lerp translation & scale
|
||||
// (PARITY §2.9). Shear on the ~few scaleshear bones is dropped for the ≤blend_time
|
||||
// transient only — the doc sanctions "quat slerp + pos/scale lerp".
|
||||
// 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]));
|
||||
godot::Quaternion q = ba.get_rotation_quaternion().slerp(bb.get_rotation_quaternion(), w);
|
||||
godot::Vector3 s = ba.get_scale().lerp(bb.get_scale(), w);
|
||||
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);
|
||||
godot::Basis rb(q);
|
||||
rb.rows[0] *= s.x;
|
||||
rb.rows[1] *= s.y;
|
||||
rb.rows[2] *= s.z;
|
||||
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;
|
||||
@@ -508,6 +524,46 @@ gr2::Mat4 blend_trs(const gr2::Mat4 &a, const gr2::Mat4 &b, float w) {
|
||||
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) {
|
||||
@@ -548,32 +604,36 @@ void Metin2AnimPlayer::apply_pose(double t) {
|
||||
// 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 };
|
||||
gr2::sample_pose(*sk, an, (float)tt, world_buf, skin_buf, &kNoOffset);
|
||||
bool pose_ready = false;
|
||||
|
||||
// Crossfade: blend the frozen outgoing-clip pose into this one, then rebuild
|
||||
// skin_buf = invWorld · world_blended (PARITY §2.9).
|
||||
// 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()) {
|
||||
gr2::sample_pose(*sk, pfi.animations[0], (float)prev_anim_time, prev_world_buf,
|
||||
prev_skin_buf, &kNoOffset);
|
||||
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(world_buf.size(), prev_world_buf.size());
|
||||
blend_world_buf.resize(world_buf.size());
|
||||
for (size_t i = 0; i < n; ++i)
|
||||
blend_world_buf[i] = blend_trs(prev_world_buf[i], world_buf[i], (float)w);
|
||||
for (size_t i = n; i < world_buf.size(); ++i)
|
||||
blend_world_buf[i] = world_buf[i];
|
||||
const auto &bones = sk->bones;
|
||||
for (size_t i = 0; i < world_buf.size() && i < bones.size(); ++i)
|
||||
skin_buf[i] = mul4x3(bones[i].inverse_world, blend_world_buf[i]);
|
||||
world_buf.swap(blend_world_buf); // weapon attach follows the blended hand
|
||||
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.
|
||||
@@ -582,7 +642,11 @@ void Metin2AnimPlayer::apply_pose(double t) {
|
||||
// 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).
|
||||
if (godot::OS::get_singleton()->get_environment("MTGODOT_GPUSKIN") == "1") {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user