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:
shen
2026-09-19 08:51:25 -07:00
parent 1db9e9a129
commit 66d217b313
650 changed files with 53046 additions and 1586 deletions
+92 -28
View File
@@ -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 {