#include "metin2_world.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "asset_io.h" #include "gr2_bridge.h" #include #include #include #include #include #include #include "dxt.h" #include "environment_builder.h" #include "static_object.h" #include "water_builder.h" #include "terrain_splat.h" #include "tree_placeholder.h" #include #include #include #include #include using namespace godot; namespace mtgodot { Metin2World::Metin2World() {} Metin2World::~Metin2World() {} void Metin2World::_bind_methods() { ClassDB::bind_method(D_METHOD("set_assets_root", "p"), &Metin2World::set_assets_root); ClassDB::bind_method(D_METHOD("get_assets_root"), &Metin2World::get_assets_root); ClassDB::bind_method(D_METHOD("set_map_path", "p"), &Metin2World::set_map_path); ClassDB::bind_method(D_METHOD("get_map_path"), &Metin2World::get_map_path); ClassDB::bind_method(D_METHOD("set_load_radius_tiles", "r"), &Metin2World::set_load_radius_tiles); ClassDB::bind_method(D_METHOD("get_load_radius_tiles"), &Metin2World::get_load_radius_tiles); ClassDB::bind_method(D_METHOD("set_focus_tile", "t"), &Metin2World::set_focus_tile); ClassDB::bind_method(D_METHOD("get_focus_tile"), &Metin2World::get_focus_tile); ClassDB::bind_method(D_METHOD("set_auto_load", "v"), &Metin2World::set_auto_load); ClassDB::bind_method(D_METHOD("get_auto_load"), &Metin2World::get_auto_load); ClassDB::bind_method(D_METHOD("set_splat_enabled", "v"), &Metin2World::set_splat_enabled); ClassDB::bind_method(D_METHOD("get_splat_enabled"), &Metin2World::get_splat_enabled); ClassDB::bind_method(D_METHOD("set_terrain_patches", "n"), &Metin2World::set_terrain_patches); ClassDB::bind_method(D_METHOD("get_terrain_patches"), &Metin2World::get_terrain_patches); ClassDB::bind_method(D_METHOD("set_objects_enabled", "v"), &Metin2World::set_objects_enabled); ClassDB::bind_method(D_METHOD("get_objects_enabled"), &Metin2World::get_objects_enabled); ClassDB::bind_method(D_METHOD("set_env_enabled", "v"), &Metin2World::set_env_enabled); ClassDB::bind_method(D_METHOD("get_env_enabled"), &Metin2World::get_env_enabled); ClassDB::bind_method(D_METHOD("set_water_enabled", "v"), &Metin2World::set_water_enabled); ClassDB::bind_method(D_METHOD("get_water_enabled"), &Metin2World::get_water_enabled); ClassDB::bind_method(D_METHOD("set_tree_shadows", "v"), &Metin2World::set_tree_shadows); ClassDB::bind_method(D_METHOD("get_tree_shadows"), &Metin2World::get_tree_shadows); ClassDB::bind_method(D_METHOD("set_static_shadows", "v"), &Metin2World::set_static_shadows); ClassDB::bind_method(D_METHOD("get_static_shadows"), &Metin2World::get_static_shadows); ClassDB::bind_method(D_METHOD("set_stream_budget", "v"), &Metin2World::set_stream_budget); ClassDB::bind_method(D_METHOD("get_stream_budget"), &Metin2World::get_stream_budget); ClassDB::bind_method(D_METHOD("load_map"), &Metin2World::load_map); ClassDB::bind_method(D_METHOD("unload_map"), &Metin2World::unload_map); ClassDB::bind_method(D_METHOD("set_focus_position", "gx_m", "gz_m"), &Metin2World::set_focus_position); ClassDB::bind_method(D_METHOD("get_perf"), &Metin2World::get_perf); ClassDB::bind_method(D_METHOD("get_map_base_cm"), &Metin2World::get_map_base_cm); ClassDB::bind_method(D_METHOD("get_map_size_tiles"), &Metin2World::get_map_size_tiles); 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("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("bake_asset_index", "out_path"), &Metin2World::bake_asset_index); ADD_PROPERTY(PropertyInfo(Variant::STRING, "assets_root", PROPERTY_HINT_GLOBAL_DIR), "set_assets_root", "get_assets_root"); ADD_PROPERTY(PropertyInfo(Variant::STRING, "map_path"), "set_map_path", "get_map_path"); ADD_PROPERTY(PropertyInfo(Variant::INT, "load_radius_tiles"), "set_load_radius_tiles", "get_load_radius_tiles"); ADD_PROPERTY(PropertyInfo(Variant::VECTOR2I, "focus_tile"), "set_focus_tile", "get_focus_tile"); ADD_PROPERTY(PropertyInfo(Variant::BOOL, "auto_load"), "set_auto_load", "get_auto_load"); ADD_PROPERTY(PropertyInfo(Variant::BOOL, "splat_enabled"), "set_splat_enabled", "get_splat_enabled"); ADD_PROPERTY(PropertyInfo(Variant::INT, "terrain_patches"), "set_terrain_patches", "get_terrain_patches"); ADD_PROPERTY(PropertyInfo(Variant::BOOL, "objects_enabled"), "set_objects_enabled", "get_objects_enabled"); ADD_PROPERTY(PropertyInfo(Variant::BOOL, "env_enabled"), "set_env_enabled", "get_env_enabled"); ADD_PROPERTY(PropertyInfo(Variant::BOOL, "water_enabled"), "set_water_enabled", "get_water_enabled"); ADD_PROPERTY(PropertyInfo(Variant::BOOL, "tree_shadows"), "set_tree_shadows", "get_tree_shadows"); ADD_PROPERTY(PropertyInfo(Variant::BOOL, "static_shadows"), "set_static_shadows", "get_static_shadows"); ADD_PROPERTY(PropertyInfo(Variant::INT, "stream_budget"), "set_stream_budget", "get_stream_budget"); } void Metin2World::set_assets_root(const String &p) { assets_root = p; } void Metin2World::set_map_path(const String &p) { map_path = p; } void Metin2World::set_focus_tile(Vector2i t) { focus_tx = t.x; focus_ty = t.y; } void Metin2World::_ready() { set_process(true); // streaming 队列逐帧建 if (auto_load && !assets_root.is_empty()) load_map(); } String Metin2World::map_dir() const { String r = assets_root; if (!r.ends_with("/")) r += "/"; return r + map_path; } const Metin2World::Chunk *Metin2World::chunk_at(int tx, int ty) const { for (auto &c : chunks) if (c.tx == tx && c.ty == ty) return &c; return nullptr; } bool Metin2World::build_chunk(int tx, int ty) { const std::string dir = std::string(map_dir().utf8().get_data()) + "/" + fmt::m2coord::tile_dir(tx, ty); auto hm = std::make_shared(); std::string err; if (!fmt::load_height_map(dir + "/height.raw", *hm, &err)) { last_error = String("tile ") + fmt::m2coord::tile_dir(tx, ty).c_str() + ": " + err.c_str(); ++chunks_failed; return false; } auto am = std::make_shared(); if (!fmt::load_attr_map(dir + "/attr.atr", *am, &err)) am.reset(); // 非致命:attr 缺失 -> 该区块无阻挡 fmt::TerrainMesh tmesh; fmt::build_terrain_mesh(*hm, tx, ty, setting.height_scale, tmesh); Ref mat; { Ref grey; grey.instantiate(); grey->set_albedo(Color(0.5f, 0.5f, 0.5f)); mat = grey; } bool splatted = false; if (splat_ready && resolver) { fmt::TileMap tile; std::string e2; if (fmt::load_tile_map(dir + "/tile.raw", tile, &e2)) { fmt::SplatSet ss; fmt::build_splat(tile, texture_set.runtime_count(), ss); if (!ss.layers.empty()) { String smpath; String sm = String(dir.c_str()) + "/shadowmap.dds"; if (FileAccess::file_exists(sm)) smpath = sm; Ref tm = build_chunk_terrain_material( ss, texture_set, *resolver, smpath); if (tm.is_valid()) { mat = tm; splatted = true; } } } } if (splatted) ++chunks_splatted; // 该区块的场景根 —— terrain / water / 对象 / 树都挂它下面,卸载 = free 它 Node3D *croot = memnew(Node3D); croot->set_name(String("Chunk_") + fmt::m2coord::tile_dir(tx, ty).c_str()); add_child(croot); // §3.5: 拆成 N×N patch,逐 patch MeshInstance —— Godot 自动逐 patch 视锥剔除, // 远处 patch 用 visibility_range 整片剔除。terrain_patches=1 = 旧行为(整区块一 mesh)。 const int QN = fmt::TerrainMesh::QUADS_XY; // 128 const int Pn = (terrain_patches >= 1 && QN % terrain_patches == 0) ? terrain_patches : 1; const int P = QN / Pn; // 每 patch 边上的 quad 数 const int VN = fmt::TerrainMesh::VERTS_XY; // 129 const int pw = P + 1; // 每 patch 边上的顶点数 for (int pj = 0; pj < Pn; ++pj) { for (int pi = 0; pi < Pn; ++pi) { const int i0 = pi * P, j0 = pj * P; PackedVector3Array pv, pn; PackedVector2Array pu; pv.resize(pw * pw); pn.resize(pw * pw); pu.resize(pw * pw); for (int lj = 0; lj < pw; ++lj) { for (int li = 0; li < pw; ++li) { const int src = (j0 + lj) * VN + (i0 + li); const int dst = lj * pw + li; pv[dst] = Vector3(tmesh.positions[src * 3 + 0], tmesh.positions[src * 3 + 1], tmesh.positions[src * 3 + 2]); pn[dst] = Vector3(tmesh.normals[src * 3 + 0], tmesh.normals[src * 3 + 1], tmesh.normals[src * 3 + 2]); pu[dst] = Vector2(tmesh.uvs[src * 2 + 0], tmesh.uvs[src * 2 + 1]); } } PackedInt32Array pidx; pidx.resize(P * P * 6); int k = 0; for (int lj = 0; lj < P; ++lj) { for (int li = 0; li < P; ++li) { const int TL = lj * pw + li, TR = TL + 1, BL = TL + pw, BR = BL + 1; pidx[k++] = TL; pidx[k++] = BR; pidx[k++] = BL; pidx[k++] = TL; pidx[k++] = TR; pidx[k++] = BR; } } Array pa; pa.resize(Mesh::ARRAY_MAX); pa[Mesh::ARRAY_VERTEX] = pv; pa[Mesh::ARRAY_NORMAL] = pn; pa[Mesh::ARRAY_TEX_UV] = pu; pa[Mesh::ARRAY_INDEX] = pidx; Ref pmesh; pmesh.instantiate(); pmesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, pa); pmesh->surface_set_material(0, mat); MeshInstance3D *pm = memnew(MeshInstance3D); pm->set_name(Pn > 1 ? vformat("Terrain_%d_%d", pi, pj) : String("Terrain")); pm->set_mesh(pmesh); if (Pn > 1 && terrain_patch_view > 0.0f) { // 硬剔除(无半透明淡出),避免远景地形变透明 pm->set_visibility_range_end(terrain_patch_view); pm->set_visibility_range_fade_mode(GeometryInstance3D::VISIBILITY_RANGE_FADE_DISABLED); } croot->add_child(pm); } } ++chunks_built; // 地形碰撞(W1 item 6):HeightMapShape3D,129×129,格距 = CELL_M(缩放承载) if (collision_enabled) { const int N = fmt::TerrainMesh::VERTS_XY; // 129 PackedFloat32Array hd; hd.resize(N * N); for (int j = 0; j < N; ++j) for (int i = 0; i < N; ++i) hd[j * N + i] = tmesh.positions[(j * N + i) * 3 + 1]; // Godot Y Ref hs; hs.instantiate(); hs->set_map_width(N); hs->set_map_depth(N); hs->set_map_data(hd); StaticBody3D *sb = memnew(StaticBody3D); sb->set_name("TerrainBody"); CollisionShape3D *cs = memnew(CollisionShape3D); cs->set_shape(hs); sb->add_child(cs); croot->add_child(sb); const double CM = fmt::m2coord::CELLSCALE * fmt::m2coord::CM_TO_M; // 2m const double X0 = double(tx) * fmt::m2coord::CHUNK_CM * fmt::m2coord::CM_TO_M; const double Z0 = double(ty) * fmt::m2coord::CHUNK_CM * fmt::m2coord::CM_TO_M; // HeightMapShape 以中心为原点、格距 1 -> 缩放到 CM,平移到区块中心 Transform3D t; t.basis.scale(Vector3(CM, 1, CM)); t.origin = Vector3(X0 + (N - 1) * 0.5 * CM, 0, Z0 + (N - 1) * 0.5 * CM); sb->set_transform(t); } // 水面(water.wtr) if (water_enabled) { fmt::WaterMap wm; std::string we; if (fmt::load_water_map(dir + "/water.wtr", wm, &we) && wm.layer_count > 0 && resolver) { auto pieces = build_chunk_water(wm, *hm, tx, ty, setting.height_scale, *resolver); for (auto &p : pieces) { if (!p.mesh.is_valid()) continue; MeshInstance3D *w = memnew(MeshInstance3D); w->set_name("Water"); w->set_mesh(p.mesh); croot->add_child(w); ++water_pieces; } } } Chunk ck; ck.tx = tx; ck.ty = ty; ck.hm = hm; ck.am = am; ck.root = croot; if (objects_enabled && registry_ok && resolver) place_chunk_objects(tx, ty, croot, ck.objects, ck.trees); objects_placed += ck.objects; trees_placed += ck.trees; chunks.push_back(std::move(ck)); return true; } void Metin2World::place_chunk_objects(int tx, int ty, Node3D *root, int &n_obj, int &n_tree) { n_obj = 0; n_tree = 0; const std::string mdir = std::string(map_dir().utf8().get_data()); // 树按 treefile 分组 -> 每组一个 MultiMeshInstance3D struct TreeGroup { std::vector xforms; }; std::map tree_groups; { fmt::AreaData ad; std::string e; if (!fmt::parse_area_data_file( mdir + "/" + fmt::m2coord::tile_dir(tx, ty) + "/areadata.txt", ad, &e)) return; for (const fmt::AreaObject &o : ad.objects) { const fmt::Property *p = registry.find(o.crc); if (!p) { ++objects_skipped; continue; } // areadata position = 地图全局 cm(W0 结论修正:不是区块本地!x 正/东,y 负/南)。 const double mx = o.x; const double my = o.y; const double mz = o.z + o.height_bias; fmt::m2coord::Vec3 g = fmt::m2coord::position_to_godot(mx, my, mz); if (p->type == fmt::PropertyType::Tree) { // 原客户端 Area -> Forest::CreateInstance 在地图放置路径只设置位置; // 不给每实例添加随机 yaw/scale。树种差异由 treefile 对应的共享 mesh 承载。 std::string tf = p->get("treefile"); if (tf.empty()) { ++objects_skipped; continue; } Basis b; tree_groups[tf].xforms.push_back(Transform3D(b, Vector3(g.x, g.y, g.z))); continue; } std::string model; if (p->type == fmt::PropertyType::Building) model = p->get("buildingfile"); else if (p->type == fmt::PropertyType::DungeonBlock) model = p->get("dungeonblockfile"); else { ++objects_skipped; // Effect / Ambience 不影响 R1 画面 continue; } if (model.empty()) { ++objects_skipped; continue; } std::string rp = resolver->resolve(model, nullptr); if (rp.empty()) { ++objects_missing_model; continue; } Ref mesh = get_static_mesh(rp, *resolver, static_cache); if (!mesh.is_valid()) { ++objects_missing_model; continue; } // areadata yaw#pitch#roll 已共轭到 Godot 空间(roll = 朝向 -> 绕 Godot +Y)。 fmt::m2coord::Mat3 r = fmt::m2coord::object_basis_godot(o.yaw, o.pitch, o.roll); Basis place_basis( Vector3(r.m[0], r.m[3], r.m[6]), Vector3(r.m[1], r.m[4], r.m[7]), Vector3(r.m[2], r.m[5], r.m[8])); Transform3D place(place_basis, Vector3(g.x, g.y, g.z)); // mesh 顶点是 gr2 原始 cm / Z-up -> 本地再套 make_conv(cm->m + Z-up->Y-up) Transform3D xform = place * make_conv(0.01f, false); MeshInstance3D *mi = memnew(MeshInstance3D); mi->set_name(String(p->name.c_str()) + "_" + String::num_uint64(o.crc)); mi->set_mesh(mesh); mi->set_transform(xform); // ShadowFlag: 客户端把 isShadowFlag 物体丢进动态阴影贴图(= 我们的实时投影); // 其余物体的阴影只存在于烘焙 shadowmap.dds。__static_shadows 可强制全部实时投。 if (p->get("shadowflag") != "1" && !static_shadows) mi->set_cast_shadows_setting(GeometryInstance3D::SHADOW_CASTING_SETTING_OFF); // portal ids 先存进 meta(R1 不做室内裁剪) if (!o.portal_ids.empty()) { Array pids; for (int pid : o.portal_ids) pids.push_back(pid); mi->set_meta("portal_ids", pids); } // .mdatr 静态碰撞:未实现。有同名 .mdatr 的计数上报(SHINSOO §9-W3 第 7 项) { String md = String(rp.c_str()).get_basename() + ".mdatr"; if (FileAccess::file_exists(md)) ++objects_mdatr_pending; } root->add_child(mi); // §8.2/§8.3: 盒碰撞体(层 2 = 静态遮挡物)。相机用它做防穿 + 遮挡淡出。 // 盒尺寸/中心直接算到「米 / Y-up」,body 只带 place(旋转+平移,无 conv), // 避免把碰撞形状放在 0.01 缩放节点下(Godot 缩放 shape 不稳)。 if (collision_enabled) { AABB ab = mesh->get_aabb(); // gr2 原始 cm / Z-up if (ab.size.length() > 0.001f) { Vector3 c_cm = ab.position + ab.size * 0.5f; // 中心 cm Z-up StaticBody3D *body = memnew(StaticBody3D); body->set_collision_layer(2); body->set_collision_mask(0); body->set_transform(place); // 世界旋转 + 平移 CollisionShape3D *cs = memnew(CollisionShape3D); Ref box; box.instantiate(); // Z-up cm -> Y-up m:(sx, sz, sy) * 0.01 box->set_size(Vector3(ab.size.x, ab.size.z, ab.size.y) * 0.01f); cs->set_shape(box); cs->set_position(Vector3(c_cm.x, c_cm.z, -c_cm.y) * 0.01f); body->add_child(cs); body->set_meta("occ_mesh", mi); // §8.2 相机淡出用 root->add_child(body); } } ++n_obj; } } // Tree proxy:每 treefile 一个共享 mesh + MultiMeshInstance3D。 // 当前从 .spt 嗅探真实 bark/composite atlas;几何仍等待离线 SpeedTree exporter。 for (auto &kv : tree_groups) { if (kv.second.xforms.empty()) continue; Ref tmesh = get_tree_proxy_mesh(kv.first, *resolver, 12.0f); Ref mm; mm.instantiate(); mm->set_transform_format(MultiMesh::TRANSFORM_3D); mm->set_mesh(tmesh); mm->set_instance_count((int)kv.second.xforms.size()); for (int i = 0; i < (int)kv.second.xforms.size(); ++i) 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_multimesh(mm); // §3.3: 树影已烘焙进 shadowmap.dds;proxy 几何的实时投影形状也不对 -> 默认不投。 mmi->set_cast_shadows_setting(tree_shadows ? GeometryInstance3D::SHADOW_CASTING_SETTING_ON : GeometryInstance3D::SHADOW_CASTING_SETTING_OFF); mmi->set_visibility_range_end(420.0f); // 远处树剔除(W8 打磨) mmi->set_visibility_range_end_margin(60.0f); // 淡出过渡 root->add_child(mmi); n_tree += (int)kv.second.xforms.size(); ++tree_species; } } bool Metin2World::load_map() { unload_map(); std::string err; if (!fmt::parse_map_setting_file( std::string(map_dir().utf8().get_data()) + "/setting.txt", setting, &err)) { last_error = String("setting.txt: ") + err.c_str(); setting_ok = false; UtilityFunctions::push_error(String("[Metin2World] ") + last_error); return false; } setting_ok = true; // splat 前置:TextureSet + AssetResolver(整盘扫描,一次) splat_ready = false; if (splat_enabled) { 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(); 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 { UtilityFunctions::push_warning(String("[Metin2World] TextureSet: ") + tse.c_str()); } } // Property CRC 注册表(一次;splat 已建 resolver) registry_ok = false; if (objects_enabled) { const std::string root(assets_root.utf8().get_data()); std::string re; // resolver 已建(splat 阶段):用它的文件清单,避免再 std::filesystem 扫盘 // (PCK 里扫不了)。没 resolver 时退回目录递归。 bool ok = resolver ? registry.scan_list(root, resolver->all_rel(), &re) : registry.scan(root + "/Property", &re); if (ok) registry_ok = true; else UtilityFunctions::push_warning(String("[Metin2World] Property scan: ") + re.c_str()); } const double t0 = Time::get_singleton()->get_ticks_usec(); if (load_radius < 0) { // 非流式:一次全建 for (int tx = 0; tx < setting.map_size_x; ++tx) for (int ty = 0; ty < setting.map_size_y; ++ty) build_chunk(tx, ty); } else { // 流式:把 focus 半径内的区块入队,_process 逐帧建 stream_update(); // 首帧同步建完队列,避免第一帧空场景 while (!stream_queue.empty()) { auto [tx, ty] = stream_queue.front(); stream_queue.erase(stream_queue.begin()); build_chunk(tx, ty); } } // .msenv -> 光照 / 天空 / 雾 / 色调 env_ok = false; if (env_enabled && !setting.environment.empty()) { std::string vp = std::string("d:/ymir work/environment/") + setting.environment; std::string rp = resolver ? resolver->resolve(vp, nullptr) : std::string(); 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); env_ok = true; } else { UtilityFunctions::push_warning(String("[Metin2World] .msenv: ") + ee.c_str()); } } build_ms = (Time::get_singleton()->get_ticks_usec() - t0) / 1000.0; UtilityFunctions::print(String("[Metin2World] ") + map_path + " loaded: " + String::num_int64(chunks_built) + "/" + String::num_int64(setting.map_size_x * setting.map_size_y) + " chunks, " + String::num_int64(chunks_splatted) + " splatted, " + String::num_int64(objects_placed) + " objects (" + String::num_int64(objects_missing_model) + " missing model), " + String::num_int64(trees_placed) + " trees/" + String::num_int64(tree_species) + " spp, " + String::num(build_ms, 1) + " ms"); return chunks_failed == 0; } void Metin2World::unload_chunk(int idx) { if (idx < 0 || idx >= (int)chunks.size()) return; Chunk &c = chunks[idx]; objects_placed -= c.objects; trees_placed -= c.trees; if (chunks_built > 0) --chunks_built; if (c.root) c.root->queue_free(); chunks.erase(chunks.begin() + idx); } void Metin2World::stream_update() { if (!setting_ok || load_radius < 0) return; // 卸载半径外 for (int i = (int)chunks.size() - 1; i >= 0; --i) { if (std::abs(chunks[i].tx - focus_tx) > load_radius || std::abs(chunks[i].ty - focus_ty) > load_radius) unload_chunk(i); } // 入队半径内且未加载 / 未在队列的 stream_queue.clear(); for (int dx = -load_radius; dx <= load_radius; ++dx) for (int dy = -load_radius; dy <= load_radius; ++dy) { int tx = focus_tx + dx, ty = focus_ty + dy; if (tx < 0 || ty < 0 || tx >= setting.map_size_x || ty >= setting.map_size_y) continue; if (!chunk_at(tx, ty)) stream_queue.push_back({tx, ty}); } } void Metin2World::set_focus_position(double gx_m, double gz_m) { const double mx = gx_m / fmt::m2coord::CM_TO_M; const double my_abs = gz_m / fmt::m2coord::CM_TO_M; int tx = std::max(0, std::min(setting.map_size_x - 1, int(mx / fmt::m2coord::CHUNK_CM))); int ty = std::max(0, std::min(setting.map_size_y - 1, int(my_abs / fmt::m2coord::CHUNK_CM))); if (tx == focus_tx && ty == focus_ty) return; focus_tx = tx; focus_ty = ty; if (load_radius >= 0) stream_update(); } void Metin2World::_process(double) { for (int n = 0; n < stream_budget && !stream_queue.empty(); ++n) { auto [tx, ty] = stream_queue.front(); stream_queue.erase(stream_queue.begin()); build_chunk(tx, ty); } } void Metin2World::unload_map() { while (!chunks.empty()) unload_chunk((int)chunks.size() - 1); stream_queue.clear(); if (objects_root) { objects_root->queue_free(); objects_root = nullptr; } for (const char *nm : {"Sun", "WorldEnv"}) if (Node *n = get_node_or_null(NodePath(nm))) n->queue_free(); water_pieces = 0; env_ok = false; env = fmt::Environment{}; static_cache = StaticMeshCache{}; registry = fmt::PropertyRegistry{}; chunks_built = chunks_failed = chunks_splatted = 0; objects_placed = objects_skipped = objects_missing_model = 0; trees_placed = tree_species = 0; objects_mdatr_pending = 0; build_ms = 0; splat_ready = registry_ok = false; last_error = ""; } Vector2 Metin2World::get_map_base_cm() const { return Vector2((float)setting.base_position_x, (float)setting.base_position_y); } Vector2i Metin2World::get_map_size_tiles() const { return Vector2i(setting.map_size_x, setting.map_size_y); } double Metin2World::sample_height(double gx_m, double gz_m) const { if (!setting_ok) return 0.0; // Godot 米 -> Metin2 全局厘米。position_to_godot: gx = mx*0.01, gz = -my*0.01 const double mx_cm = gx_m / fmt::m2coord::CM_TO_M; const double my_abs_cm = gz_m / fmt::m2coord::CM_TO_M; // = -my; 已是正的 "南向距离" const int tx = int(mx_cm / fmt::m2coord::CHUNK_CM); const int ty = int(my_abs_cm / fmt::m2coord::CHUNK_CM); const Chunk *c = chunk_at(tx, ty); if (!c || !c->hm) return 0.0; const double lx = mx_cm - double(tx) * fmt::m2coord::CHUNK_CM; const double ly = my_abs_cm - double(ty) * fmt::m2coord::CHUNK_CM; return fmt::terrain_height_at(*c->hm, lx, ly, setting.height_scale) * fmt::m2coord::CM_TO_M; } Ref Metin2World::load_dds(const String &path) const { mtgodot::Image d = mtgodot::dds_from_file(path); if (!d.ok()) return Ref(); PackedByteArray b; b.resize((int64_t)d.rgba.size()); for (size_t i = 0; i < d.rgba.size(); ++i) b[(int64_t)i] = d.rgba[i]; return godot::Image::create_from_data(d.w, d.h, false, godot::Image::FORMAT_RGBA8, b); } String Metin2World::chunk_dir(int tile_x, int tile_y) const { return map_dir() + "/" + fmt::m2coord::tile_dir(tile_x, tile_y).c_str(); } bool Metin2World::bake_asset_index(const String &out_path) { const std::string root(assets_root.utf8().get_data()); fmt::AssetResolver r; std::string err; if (!r.build(root, fmt::AssetResolver::default_priority(), &err)) { UtilityFunctions::push_error(String("[Metin2World] bake_asset_index build: ") + err.c_str()); return false; } const std::string idx = r.save_index(); Ref f = FileAccess::open(out_path, FileAccess::WRITE); if (f.is_null()) { UtilityFunctions::push_error(String("[Metin2World] bake_asset_index: cannot write ") + out_path); return false; } f->store_buffer(reinterpret_cast(idx.data()), (int64_t)idx.size()); f->close(); UtilityFunctions::print(String("[Metin2World] asset_index: ") + String::num_int64((int64_t)r.files_indexed) + " files -> " + out_path); return true; } int Metin2World::sample_attribute(double gx_m, double gz_m) const { if (!setting_ok) return 0; const double mx_cm = gx_m / fmt::m2coord::CM_TO_M; const double my_abs_cm = gz_m / fmt::m2coord::CM_TO_M; const int tx = int(mx_cm / fmt::m2coord::CHUNK_CM); const int ty = int(my_abs_cm / fmt::m2coord::CHUNK_CM); const Chunk *c = chunk_at(tx, ty); if (!c || !c->am) return 0; // ATTRMAP 256x256 / 区块 25600cm -> 100cm/texel const double lx = mx_cm - double(tx) * fmt::m2coord::CHUNK_CM; const double ly = my_abs_cm - double(ty) * fmt::m2coord::CHUNK_CM; int ax = int(lx / 100.0), ay = int(ly / 100.0); if (ax < 0 || ay < 0 || ax >= fmt::ATTRMAP_XY || ay >= fmt::ATTRMAP_XY) return 0; return c->am->data[size_t(ay) * fmt::ATTRMAP_XY + ax]; } Dictionary Metin2World::get_load_report() const { Dictionary d; d["map_path"] = map_path; d["setting_ok"] = setting_ok; d["map_size_x"] = setting.map_size_x; d["map_size_y"] = setting.map_size_y; d["cell_scale"] = setting.cell_scale; d["height_scale"] = setting.height_scale; d["chunks_built"] = chunks_built; d["chunks_failed"] = chunks_failed; d["chunks_splatted"] = chunks_splatted; d["water_pieces"] = water_pieces; d["splat_ready"] = splat_ready; d["registry_ok"] = registry_ok; d["registry_crcs"] = (int)registry.by_crc.size(); d["env_ok"] = env_ok; d["fog_level"] = env.fog.fog_level; d["objects_placed"] = objects_placed; d["objects_skipped"] = objects_skipped; d["objects_missing_model"] = objects_missing_model; d["trees_placed"] = trees_placed; d["tree_species"] = tree_species; d["objects_mdatr_pending"] = objects_mdatr_pending; // 有 .mdatr 但未建碰撞 d["static_meshes"] = static_cache.loaded; d["build_ms"] = build_ms; d["resident_chunks"] = (int)chunks.size(); d["stream_queue"] = (int)stream_queue.size(); d["load_radius_tiles"] = load_radius; d["last_error"] = last_error; return d; } Dictionary Metin2World::get_perf() const { Dictionary d; Performance *pf = Performance::get_singleton(); d["fps"] = pf->get_monitor(Performance::TIME_FPS); d["process_ms"] = pf->get_monitor(Performance::TIME_PROCESS) * 1000.0; d["frame_ms"] = pf->get_monitor(Performance::TIME_PROCESS) * 1000.0 + pf->get_monitor(Performance::TIME_PHYSICS_PROCESS) * 1000.0; d["draw_calls"] = pf->get_monitor(Performance::RENDER_TOTAL_DRAW_CALLS_IN_FRAME); d["primitives"] = pf->get_monitor(Performance::RENDER_TOTAL_PRIMITIVES_IN_FRAME); d["video_mem_mb"] = pf->get_monitor(Performance::RENDER_VIDEO_MEM_USED) / (1024.0 * 1024.0); d["tex_mem_mb"] = pf->get_monitor(Performance::RENDER_TEXTURE_MEM_USED) / (1024.0 * 1024.0); d["objects_3d"] = pf->get_monitor(Performance::RENDER_TOTAL_OBJECTS_IN_FRAME); d["nodes"] = pf->get_monitor(Performance::OBJECT_NODE_COUNT); d["resident_chunks"] = (int)chunks.size(); d["stream_queue"] = (int)stream_queue.size(); return d; } } // namespace mtgodot