完善客户端功能并同步差距文档

This commit is contained in:
shenlei
2026-09-03 18:40:01 +09:00
parent f93a172296
commit 13ccb8d02d
135 changed files with 21881 additions and 1259 deletions
+133 -26
View File
@@ -45,6 +45,7 @@
#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstdlib>
using namespace godot;
@@ -90,9 +91,14 @@ void Metin2World::_bind_methods() {
ClassDB::bind_method(D_METHOD("sample_height", "gx_m", "gz_m"), &Metin2World::sample_height);
ClassDB::bind_method(D_METHOD("sample_attribute", "gx_m", "gz_m"), &Metin2World::sample_attribute);
ClassDB::bind_method(D_METHOD("is_blocked", "gx_m", "gz_m"), &Metin2World::is_blocked);
ClassDB::bind_method(D_METHOD("get_fishing_rotation", "gx_m", "gz_m", "heading_deg"),
&Metin2World::get_fishing_rotation);
ClassDB::bind_method(D_METHOD("can_fishing_position", "gx_m", "gz_m", "heading_deg"),
&Metin2World::can_fishing_position);
ClassDB::bind_method(D_METHOD("load_dds", "path"), &Metin2World::load_dds);
ClassDB::bind_method(D_METHOD("chunk_dir", "tx", "ty"), &Metin2World::chunk_dir);
ClassDB::bind_method(D_METHOD("get_load_report"), &Metin2World::get_load_report);
ClassDB::bind_method(D_METHOD("get_ambience_sources"), &Metin2World::get_ambience_sources);
ClassDB::bind_method(D_METHOD("bake_asset_index", "out_path"), &Metin2World::bake_asset_index);
ADD_PROPERTY(PropertyInfo(Variant::STRING, "assets_root", PROPERTY_HINT_GLOBAL_DIR),
@@ -248,6 +254,7 @@ bool Metin2World::build_chunk(int tx, int ty) {
MeshInstance3D *pm = memnew(MeshInstance3D);
pm->set_name(Pn > 1 ? vformat("Terrain_%d_%d", pi, pj) : String("Terrain"));
pm->set_layer_mask(1u << 0); // Background light visibility layer.
pm->set_mesh(pmesh);
if (Pn > 1 && terrain_patch_view > 0.0f) {
// 硬剔除(无半透明淡出),避免远景地形变透明
@@ -299,6 +306,7 @@ bool Metin2World::build_chunk(int tx, int ty) {
continue;
MeshInstance3D *w = memnew(MeshInstance3D);
w->set_name("Water");
w->set_layer_mask(1u << 0);
w->set_mesh(p.mesh);
croot->add_child(w);
++water_pieces;
@@ -314,6 +322,8 @@ bool Metin2World::build_chunk(int tx, int ty) {
ck.root = croot;
if (objects_enabled && registry_ok && resolver)
place_chunk_objects(tx, ty, croot, ck.objects, ck.trees);
if (registry_ok)
place_chunk_ambience(tx, ty);
objects_placed += ck.objects;
trees_placed += ck.trees;
chunks.push_back(std::move(ck));
@@ -400,6 +410,7 @@ void Metin2World::place_chunk_objects(int tx, int ty, Node3D *root, int &n_obj,
MeshInstance3D *mi = memnew(MeshInstance3D);
mi->set_name(String(p->name.c_str()) + "_" + String::num_uint64(o.crc));
mi->set_layer_mask(1u << 0); // Background light visibility layer.
mi->set_mesh(mesh);
mi->set_transform(xform);
// ShadowFlag: 客户端把 isShadowFlag 物体丢进动态阴影贴图(= 我们的实时投影);
@@ -462,6 +473,7 @@ void Metin2World::place_chunk_objects(int tx, int ty, Node3D *root, int &n_obj,
mm->set_instance_transform(i, kv.second.xforms[i]);
MultiMeshInstance3D *mmi = memnew(MultiMeshInstance3D);
mmi->set_name(String("Trees_") + String(kv.first.c_str()).get_file().get_basename());
mmi->set_layer_mask(1u << 0);
mmi->set_multimesh(mm);
// §3.3: 树影已烘焙进 shadowmap.dds;proxy 几何的实时投影形状也不对 -> 默认不投。
mmi->set_cast_shadows_setting(tree_shadows ? GeometryInstance3D::SHADOW_CASTING_SETTING_ON
@@ -474,6 +486,46 @@ void Metin2World::place_chunk_objects(int tx, int ty, Node3D *root, int &n_obj,
}
}
void Metin2World::place_chunk_ambience(int tx, int ty) {
// Area ambience is a separate file in ClientVS22. The .pra property owns
// play type / interval / sound names; Audio owns the runtime instances.
const std::string mdir = std::string(map_dir().utf8().get_data());
fmt::AreaAmbienceData ad;
std::string e;
const std::string base = mdir + "/" + fmt::m2coord::tile_dir(tx, ty) + "/";
bool loaded = fmt::parse_area_ambience_file(base + "areaambiencedata.txt", ad, &e);
if (!loaded)
loaded = fmt::parse_area_ambience_file(base + "AreaAmbienceData.txt", ad, &e);
if (!loaded)
return; // 老地图可能没有环境声文件。
for (size_t i = 0; i < ad.objects.size(); ++i) {
const fmt::AreaAmbienceObject &o = ad.objects[i];
const fmt::Property *p = registry.find(o.crc);
if (!p || p->type != fmt::PropertyType::Ambience)
continue;
AmbienceSource source;
source.tile_x = tx;
source.tile_y = ty;
source.object_index = (int)i;
const fmt::m2coord::Vec3 g = fmt::m2coord::position_to_godot(o.x, o.y, o.z);
source.position = Vector3((float)g.x, (float)g.y, (float)g.z);
source.range_cm = std::max(0, (int)std::lround(o.range));
source.max_volume_area_percentage = (float)std::strtof(
p->get("maxvolumeareapercentage", "0").c_str(), nullptr);
source.play_interval = (float)std::strtof(
p->get("playinterval", "0").c_str(), nullptr);
source.play_interval_variation = (float)std::strtof(
p->get("playintervalvariation", "0").c_str(), nullptr);
source.play_type = p->get("playtype", "LOOP").c_str();
const std::string sounds = p->get("ambiencesoundvector");
if (!sounds.empty())
source.sounds.push_back(sounds);
if (!source.sounds.empty() && source.range_cm > 0)
ambience_sources.push_back(std::move(source));
}
}
bool Metin2World::load_map() {
unload_map();
@@ -487,39 +539,35 @@ bool Metin2World::load_map() {
}
setting_ok = true;
// splat 前置:TextureSet + AssetResolver(整盘扫描,一次)
// AssetResolver 是地图对象、环境声属性和 splat 共用的资源索引。
// 即使关闭 splat,也要保留这条链路,避免环境声依赖渲染开关。
splat_ready = false;
if (splat_enabled) {
const std::string root = std::string(assets_root.utf8().get_data());
resolver = std::make_shared<fmt::AssetResolver>();
std::string re;
// asset_index.txt 存在就装载(PCK/移动端必走),否则扫盘(桌面开发)。
if (!resolver->build_or_load(root, fmt::AssetResolver::default_priority(), &re)) {
UtilityFunctions::push_warning(String("[Metin2World] AssetResolver: ") + re.c_str());
resolver.reset();
}
if (splat_enabled && resolver) {
std::string ts_rel = setting.texture_set;
for (char &c : ts_rel) {
if (c == '\\')
c = '/';
c = (char)std::tolower((unsigned char)c);
}
const std::string root = std::string(assets_root.utf8().get_data());
std::string tse;
if (fmt::parse_texture_set_file(root + "/textureset/" + ts_rel, texture_set, &tse)) {
resolver = std::make_shared<fmt::AssetResolver>();
std::string re;
// asset_index.txt 存在就装载(PCK/移动端必走),否则扫盘(桌面开发)。
if (resolver->build_or_load(root, fmt::AssetResolver::default_priority(), &re)) {
splat_ready = true;
} else {
UtilityFunctions::push_warning(String("[Metin2World] AssetResolver: ") + re.c_str());
resolver.reset();
}
} else {
if (fmt::parse_texture_set_file(root + "/textureset/" + ts_rel, texture_set, &tse))
splat_ready = true;
else
UtilityFunctions::push_warning(String("[Metin2World] TextureSet: ") + tse.c_str());
}
}
// Property CRC 注册表(一次;splat 已建 resolver)
// Property CRC 注册表(对象和环境声共用;不依赖 objects_enabled)。
registry_ok = false;
if (objects_enabled) {
const std::string root(assets_root.utf8().get_data());
std::string re;
// resolver 已建(splat 阶段):用它的文件清单,避免再 std::filesystem 扫盘
// (PCK 里扫不了)。没 resolver 时退回目录递归。
{
// resolver 已建:用它的文件清单,避免再 std::filesystem 扫盘(PCK 里扫不了)。
bool ok = resolver
? registry.scan_list(root, resolver->all_rel(), &re)
: registry.scan(root + "/Property", &re);
@@ -554,12 +602,9 @@ bool Metin2World::load_map() {
if (rp.empty())
rp = std::string(assets_root.utf8().get_data()) +
"/ETC/ymir work/environment/" + setting.environment;
// 小写文件名
for (size_t i = rp.rfind('/') + 1; i < rp.size(); ++i)
rp[i] = (char)std::tolower((unsigned char)rp[i]);
std::string ee;
if (fmt::parse_environment_file(rp, env, &ee)) {
apply_environment(env, this);
apply_environment(env, this, resolver.get());
env_ok = true;
} else {
UtilityFunctions::push_warning(String("[Metin2World] .msenv: ") + ee.c_str());
@@ -588,6 +633,9 @@ void Metin2World::unload_chunk(int idx) {
--chunks_built;
if (c.root)
c.root->queue_free();
ambience_sources.erase(std::remove_if(ambience_sources.begin(), ambience_sources.end(),
[&c](const AmbienceSource &s) { return s.tile_x == c.tx && s.tile_y == c.ty; }),
ambience_sources.end());
chunks.erase(chunks.begin() + idx);
}
@@ -637,11 +685,12 @@ void Metin2World::unload_map() {
while (!chunks.empty())
unload_chunk((int)chunks.size() - 1);
stream_queue.clear();
ambience_sources.clear();
if (objects_root) {
objects_root->queue_free();
objects_root = nullptr;
}
for (const char *nm : {"Sun", "WorldEnv"})
for (const char *nm : {"Sun", "CharacterLight", "WorldEnv"})
if (Node *n = get_node_or_null(NodePath(nm)))
n->queue_free();
water_pieces = 0;
@@ -736,6 +785,42 @@ int Metin2World::sample_attribute(double gx_m, double gz_m) const {
return c->am->data[size_t(ay) * fmt::ATTRMAP_XY + ax];
}
double Metin2World::get_fishing_rotation(double gx_m, double gz_m, double heading_deg) const {
// InstanceBaseMotion.cpp uses c_fFishingDistance=600.0f and
// ELPlainCoord_GetRotatedPixelPosition (sin(rot), cos(rot)). The POC
// world frame is the MapCoord frame, where heading -> world yaw is
// heading + 90 degrees; using the same conversion keeps the probe in
// the coordinate frame used by player_controller / MapCoord.
constexpr double DISTANCE_M = 6.0;
constexpr double WATER_ATTRIBUTE = 1 << 1; // PRTerrainLib::ATTRIBUTE_WATER
constexpr double DEG_TO_RAD = 3.14159265358979323846 / 180.0;
auto normalized = [](double d) {
d = std::fmod(d, 360.0);
return d < 0.0 ? d + 360.0 : d;
};
auto water_at = [&](double candidate_heading) {
const double yaw = (candidate_heading + 90.0) * DEG_TO_RAD;
const double x = gx_m + std::sin(yaw) * DISTANCE_M;
const double z = gz_m + std::cos(yaw) * DISTANCE_M;
return (sample_attribute(x, z) & int(WATER_ATTRIBUTE)) != 0;
};
for (int step = 0; step <= 18; ++step) {
const double offset = double(step * 10);
const double right = heading_deg + offset;
if (water_at(right))
return normalized(right);
const double left = heading_deg - offset;
if (water_at(left))
return normalized(left);
}
return -1.0;
}
bool Metin2World::can_fishing_position(double gx_m, double gz_m, double heading_deg) const {
return get_fishing_rotation(gx_m, gz_m, heading_deg) >= 0.0;
}
Dictionary Metin2World::get_load_report() const {
Dictionary d;
d["map_path"] = map_path;
@@ -756,6 +841,7 @@ Dictionary Metin2World::get_load_report() const {
d["objects_placed"] = objects_placed;
d["objects_skipped"] = objects_skipped;
d["objects_missing_model"] = objects_missing_model;
d["ambience_sources"] = (int)ambience_sources.size();
d["trees_placed"] = trees_placed;
d["tree_species"] = tree_species;
d["objects_mdatr_pending"] = objects_mdatr_pending; // 有 .mdatr 但未建碰撞
@@ -768,6 +854,27 @@ Dictionary Metin2World::get_load_report() const {
return d;
}
Array Metin2World::get_ambience_sources() const {
Array out;
for (const AmbienceSource &s : ambience_sources) {
Dictionary d;
d["key"] = String::num_int64(s.tile_x) + ":" + String::num_int64(s.tile_y) + ":" +
String::num_int64(s.object_index);
d["position"] = s.position;
d["range_cm"] = s.range_cm;
d["max_volume_area_percentage"] = s.max_volume_area_percentage;
d["play_interval"] = s.play_interval;
d["play_interval_variation"] = s.play_interval_variation;
d["play_type"] = s.play_type;
Array sounds;
for (const std::string &sound : s.sounds)
sounds.push_back(String(sound.c_str()));
d["sounds"] = sounds;
out.push_back(d);
}
return out;
}
Dictionary Metin2World::get_perf() const {
Dictionary d;
Performance *pf = Performance::get_singleton();