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:
@@ -40,6 +40,22 @@ static const gr2::MaterialInfo *match_material(const std::vector<gr2::MaterialIn
|
||||
static mtgodot::BlendMode decide_blend(const gr2::MaterialInfo *m, const godot::String &sname);
|
||||
static bool decide_two_sided(const gr2::MaterialInfo *m, const godot::String &sname);
|
||||
|
||||
static HashMap<String, Ref<ImageTexture>> s_model_tex_cache;
|
||||
static HashMap<String, PackedStringArray> s_model_dir_dds_cache;
|
||||
|
||||
static bool model_diagnostics() {
|
||||
static int diag = -1;
|
||||
if (diag == -1) {
|
||||
diag = (OS::get_singleton()->get_environment("MTGODOT_MODEL_DIAGNOSTICS") == "1") ? 1 : 0;
|
||||
}
|
||||
return diag == 1;
|
||||
}
|
||||
|
||||
void Metin2Model::clear_texture_cache() {
|
||||
s_model_tex_cache.clear();
|
||||
s_model_dir_dds_cache.clear();
|
||||
}
|
||||
|
||||
Metin2Model::Metin2Model() {
|
||||
file = std::make_shared<std::optional<gr2::File>>();
|
||||
lod_dist.push_back(18.0f);
|
||||
@@ -66,6 +82,8 @@ void Metin2Model::_bind_methods() {
|
||||
ClassDB::bind_method(D_METHOD("set_specular_power", "p"), &Metin2Model::set_specular_power);
|
||||
ClassDB::bind_method(D_METHOD("get_specular_power"), &Metin2Model::get_specular_power);
|
||||
ClassDB::bind_method(D_METHOD("set_surface_texture", "surface", "path"), &Metin2Model::set_surface_texture);
|
||||
ClassDB::bind_method(D_METHOD("set_skin_texture", "source", "target"), &Metin2Model::set_skin_texture);
|
||||
ClassDB::bind_method(D_METHOD("clear_skin_textures"), &Metin2Model::clear_skin_textures);
|
||||
ClassDB::bind_method(D_METHOD("reload"), &Metin2Model::reload);
|
||||
ClassDB::bind_method(D_METHOD("get_info"), &Metin2Model::get_info);
|
||||
ClassDB::bind_method(D_METHOD("get_visual_aabb"), &Metin2Model::get_visual_aabb);
|
||||
@@ -85,6 +103,7 @@ void Metin2Model::_bind_methods() {
|
||||
ClassDB::bind_method(D_METHOD("set_shield_bone", "b"), &Metin2Model::set_shield_bone);
|
||||
ClassDB::bind_method(D_METHOD("get_shield_bone"), &Metin2Model::get_shield_bone);
|
||||
ClassDB::bind_method(D_METHOD("probe_gr2", "path"), &Metin2Model::probe_gr2);
|
||||
ClassDB::bind_static_method("Metin2Model", D_METHOD("clear_texture_cache"), &Metin2Model::clear_texture_cache);
|
||||
// A renderer consumer may have framed the model before an animation switches
|
||||
// it from the CPU mesh to GPU skinning. That switch updates the effective
|
||||
// AABB, so expose a small notification instead of making each scene guess a
|
||||
@@ -159,6 +178,28 @@ void Metin2Model::set_surface_texture(int surface, const String &path) {
|
||||
}
|
||||
}
|
||||
|
||||
void Metin2Model::set_skin_texture(const String &source, const String &target) {
|
||||
const String key = source.replace("\\", "/").get_file().get_basename().to_lower();
|
||||
if (key.is_empty()) {
|
||||
return;
|
||||
}
|
||||
if (target.is_empty()) {
|
||||
skin_tex_override.erase(key);
|
||||
} else {
|
||||
skin_tex_override[key] = target;
|
||||
}
|
||||
if (is_inside_tree() && mi) {
|
||||
_apply_materials();
|
||||
}
|
||||
}
|
||||
|
||||
void Metin2Model::clear_skin_textures() {
|
||||
skin_tex_override.clear();
|
||||
if (is_inside_tree() && mi) {
|
||||
_apply_materials();
|
||||
}
|
||||
}
|
||||
|
||||
void Metin2Model::set_gr2_path(const String &p) {
|
||||
gr2_path = p;
|
||||
if (is_inside_tree()) {
|
||||
@@ -236,7 +277,9 @@ void Metin2Model::_load_lods(const String &loaded_spec) {
|
||||
lod_files.push_back(std::move(slot));
|
||||
}
|
||||
if (!lod_files.empty()) {
|
||||
UtilityFunctions::print(vformat("[Metin2Model] LOD: %d level(s) loaded", (int)lod_files.size()));
|
||||
if (model_diagnostics()) {
|
||||
UtilityFunctions::print(vformat("[Metin2Model] LOD: %d level(s) loaded", (int)lod_files.size()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,7 +561,7 @@ void Metin2Model::reload() {
|
||||
for (const auto &m : fi.meshes) {
|
||||
vtot += (int)m.vertices.size();
|
||||
}
|
||||
{
|
||||
if (model_diagnostics()) {
|
||||
int with_tex = 0;
|
||||
for (const auto &mm : materials) {
|
||||
if (!mm.diffuse_texture.empty()) {
|
||||
@@ -535,7 +578,9 @@ void Metin2Model::reload() {
|
||||
mesh->get_surface_count(),
|
||||
vtot,
|
||||
String(bounds));
|
||||
UtilityFunctions::print("[Metin2Model] ", last_info);
|
||||
if (model_diagnostics()) {
|
||||
UtilityFunctions::print("[Metin2Model] ", last_info);
|
||||
}
|
||||
|
||||
_load_lods(gr2_spec);
|
||||
|
||||
@@ -738,19 +783,18 @@ void Metin2Model::_load_attach(const String &gr2_rel, const String &bone_name,
|
||||
*slot_file = std::move(w);
|
||||
const gr2::FileInfo &wfi = (**slot_file).file_info();
|
||||
|
||||
// Grip pre-transform, mirroring the client's weapon composite:
|
||||
// weaponComposite[0] = weaponInverseWorld[0] · weaponLocal[0]
|
||||
// weaponMeshWorld = weaponComposite[0] · pAttachBoneMatrix(handWorld) · charWorld
|
||||
// (EterGrnLib/ModelInstanceUpdate.cpp:171 + GetBoneMatrixPointer).
|
||||
// Most Metin2 weapons are authored directly in hand space so this pre is ~I;
|
||||
// a few non-standard files carry a huge bone offset that would fling the mesh
|
||||
// metres away — clamp those out and fall back to the raw hand pose.
|
||||
// Grip pre-transform, mirroring Granny's rigid-mesh composite in the client:
|
||||
// weaponWorld[0] = weaponLocal[0] · handWorld
|
||||
// weaponComposite[0] = weaponInverseWorld[0] · weaponWorld[0]
|
||||
// The explicit parent matrix passed to GrannyBuildWorldPose replaces the
|
||||
// weapon model's InitialPlacement; multiplying it here would cancel 03150's
|
||||
// inverse-bind correction. See ModelInstanceUpdate.cpp:148.
|
||||
// Do not clamp a large composite translation: files such as 03150 also store
|
||||
// their raw vertices metres away from the skeleton origin, and this authored
|
||||
// inverse-bind transform is precisely what moves the grip back onto the hand.
|
||||
if (!wfi.skeletons.empty() && !wfi.skeletons[0].bones.empty()) {
|
||||
const gr2::Bone &wb0 = wfi.skeletons[0].bones[0];
|
||||
gr2::Mat4 pre = mtgodot::mul4x3(wb0.inverse_world, wb0.local_transform);
|
||||
const float tx = pre[12], ty = pre[13], tz = pre[14];
|
||||
if (tx * tx + ty * ty + tz * tz < 30.0f * 30.0f) // < 30 cm
|
||||
slot_pre = pre;
|
||||
slot_pre = mtgodot::mul4x3(wb0.inverse_world, wb0.local_transform);
|
||||
}
|
||||
|
||||
std::vector<mtgodot::RenderPart> wparts = mtgodot::build_parts(wfi);
|
||||
@@ -987,8 +1031,8 @@ String Metin2Model::resolve_rel_gr2(const String &base, const String &spec) {
|
||||
|
||||
Ref<godot::ImageTexture> Metin2Model::_load_dds(const String &path) {
|
||||
Ref<ImageTexture> t;
|
||||
if (tex_cache.has(path)) {
|
||||
return tex_cache[path];
|
||||
if (s_model_tex_cache.has(path)) {
|
||||
return s_model_tex_cache[path];
|
||||
}
|
||||
if (FileAccess::file_exists(path)) {
|
||||
mtgodot::Image dds = mtgodot::dds_from_file(path);
|
||||
@@ -999,7 +1043,7 @@ Ref<godot::ImageTexture> Metin2Model::_load_dds(const String &path) {
|
||||
dds.rgba.size(), /*mipmaps=*/true);
|
||||
}
|
||||
}
|
||||
tex_cache[path] = t;
|
||||
s_model_tex_cache[path] = t;
|
||||
return t;
|
||||
}
|
||||
|
||||
@@ -1140,7 +1184,9 @@ void Metin2Model::_apply_materials() {
|
||||
const String stem = gr2_path.get_file().get_basename();
|
||||
|
||||
PackedStringArray dds_files;
|
||||
{
|
||||
if (s_model_dir_dds_cache.has(dir)) {
|
||||
dds_files = s_model_dir_dds_cache[dir];
|
||||
} else {
|
||||
Ref<DirAccess> da = DirAccess::open(dir);
|
||||
if (da.is_valid()) {
|
||||
da->list_dir_begin();
|
||||
@@ -1151,6 +1197,7 @@ void Metin2Model::_apply_materials() {
|
||||
}
|
||||
da->list_dir_end();
|
||||
}
|
||||
s_model_dir_dds_cache[dir] = dds_files;
|
||||
}
|
||||
|
||||
// Surface s <-> parts[s]. tri_group.material_index is a MESH-LOCAL index into
|
||||
@@ -1230,6 +1277,13 @@ void Metin2Model::_apply_materials() {
|
||||
if (s < surface_tex_override.size() && !String(surface_tex_override[s]).is_empty()) {
|
||||
tex = _load_dds(surface_tex_override[s]);
|
||||
}
|
||||
// `.msm` SourceSkin -> TargetSkin remaps are material-name based in the
|
||||
// 40250 client. An armour can have face/body/effect surfaces in a
|
||||
// different order, so applying TargetSkin to hard-coded surface 0 gives
|
||||
// the right geometry with the wrong (usually base black) appearance.
|
||||
if (tex.is_null() && skin_tex_override.has(mat_want)) {
|
||||
tex = _load_dds(String(skin_tex_override[mat_want]));
|
||||
}
|
||||
// gr2 material binding -> texture (default; set use_gr2_materials=false to skip)
|
||||
if (tex.is_null() && use_gr2_materials) {
|
||||
tex = find_dds(mat_want);
|
||||
@@ -1270,8 +1324,10 @@ void Metin2Model::_apply_materials() {
|
||||
surf_mats[s] = mat;
|
||||
mi->set_surface_override_material(s, mat);
|
||||
}
|
||||
UtilityFunctions::print(vformat("[Metin2Model] materials: %d/%d surfaces textured (dir=%s, %d dds)",
|
||||
textured, mesh->get_surface_count(), dir, (int)dds_files.size()));
|
||||
if (model_diagnostics()) {
|
||||
UtilityFunctions::print(vformat("[Metin2Model] materials: %d/%d surfaces textured (dir=%s, %d dds)",
|
||||
textured, mesh->get_surface_count(), dir, (int)dds_files.size()));
|
||||
}
|
||||
}
|
||||
|
||||
void Metin2Model::_build_gpu_mesh() {
|
||||
@@ -1353,7 +1409,9 @@ void Metin2Model::enable_cpu_skin(bool on) {
|
||||
if (skel) {
|
||||
skel->set_show_rest_only(true);
|
||||
}
|
||||
UtilityFunctions::print("[Metin2Model] CPU-skin mode ON");
|
||||
if (model_diagnostics()) {
|
||||
UtilityFunctions::print("[Metin2Model] CPU-skin mode ON");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1652,7 +1710,9 @@ void Metin2Model::enable_gpu_skin(bool on) {
|
||||
// sampler has produced its skin matrices.
|
||||
gpu_skin_active = true;
|
||||
_apply_materials(); // rebuild surface materials with the skinned shader + bones_tex
|
||||
UtilityFunctions::print(vformat("[Metin2Model] GPU-skin mode ON (%d bones)", nbones));
|
||||
if (model_diagnostics()) {
|
||||
UtilityFunctions::print(vformat("[Metin2Model] GPU-skin mode ON (%d bones)", nbones));
|
||||
}
|
||||
}
|
||||
|
||||
void Metin2Model::gpu_skin(const std::vector<gr2::Mat4> &skin) {
|
||||
|
||||
Reference in New Issue
Block a user