Files
mtgodot-poc/extension/src/metin2_world.cpp
T
shenandshen 66d217b313 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 客户端对拍缺陷发现与修复工程指南
2026-09-19 08:51:25 -07:00

1170 lines
44 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "metin2_world.h"
#include <godot_cpp/classes/array_mesh.hpp>
#include <godot_cpp/classes/box_shape3d.hpp>
#include <godot_cpp/classes/collision_shape3d.hpp>
#include <godot_cpp/classes/concave_polygon_shape3d.hpp>
#include <godot_cpp/classes/cylinder_shape3d.hpp>
#include <godot_cpp/classes/file_access.hpp>
#include <godot_cpp/classes/geometry_instance3d.hpp>
#include <godot_cpp/classes/height_map_shape3d.hpp>
#include <godot_cpp/classes/image_texture.hpp>
#include <godot_cpp/classes/static_body3d.hpp>
#include <godot_cpp/classes/mesh_instance3d.hpp>
#include <godot_cpp/classes/multi_mesh.hpp>
#include <godot_cpp/classes/multi_mesh_instance3d.hpp>
#include <godot_cpp/classes/performance.hpp>
#include <godot_cpp/classes/standard_material3d.hpp>
#include <godot_cpp/classes/sphere_shape3d.hpp>
#include <godot_cpp/classes/time.hpp>
#include <godot_cpp/core/class_db.hpp>
#include <godot_cpp/variant/basis.hpp>
#include <godot_cpp/variant/packed_int32_array.hpp>
#include <godot_cpp/variant/packed_vector2_array.hpp>
#include <godot_cpp/variant/packed_vector3_array.hpp>
#include <godot_cpp/variant/transform3d.hpp>
#include <godot_cpp/variant/utility_functions.hpp>
#include "asset_io.h"
#include "gr2_bridge.h"
#include <area_data.h>
#include <environment.h>
#include <m2_coord.h>
#include <property.h>
#include <splat.h>
#include <terrain_mesh.h>
#include <attribute_data.h>
#include "dxt.h"
#include "environment_builder.h"
#include "static_object.h"
#include "water_builder.h"
#include "terrain_splat.h"
#include "tree_placeholder.h"
#include <map>
#include <vector>
#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstdlib>
using namespace godot;
namespace mtgodot {
namespace {
// AttributeData stores rotations as D3DX quaternions in Metin2's Z-up
// coordinate system. Convert through the same axis change as m2coord positions.
Basis attribute_quaternion_basis(const std::array<float, 4> &q) {
double x = q[0], y = q[1], z = q[2], w = q[3];
double length = std::sqrt(x * x + y * y + z * z + w * w);
if (length < 1e-8)
return Basis();
x /= length; y /= length; z /= length; w /= length;
const double r[9] = {
1.0 - 2.0 * (y * y + z * z), 2.0 * (x * y - z * w), 2.0 * (x * z + y * w),
2.0 * (x * y + z * w), 1.0 - 2.0 * (x * x + z * z), 2.0 * (y * z - x * w),
2.0 * (x * z - y * w), 2.0 * (y * z + x * w), 1.0 - 2.0 * (x * x + y * y),
};
const fmt::m2coord::Mat3 m2{{r[0], r[1], r[2], r[3], r[4], r[5], r[6], r[7], r[8]}};
const fmt::m2coord::Mat3 c{{1, 0, 0, 0, 0, 1, 0, -1, 0}};
const fmt::m2coord::Mat3 ci{{1, 0, 0, 0, 0, -1, 0, 1, 0}};
auto mul = [](const fmt::m2coord::Mat3 &a, const fmt::m2coord::Mat3 &b) {
fmt::m2coord::Mat3 result{};
for (int i = 0; i < 3; ++i)
for (int j = 0; j < 3; ++j)
for (int k = 0; k < 3; ++k)
result.m[i * 3 + j] += a.m[i * 3 + k] * b.m[k * 3 + j];
return result;
};
const fmt::m2coord::Mat3 godot = mul(mul(c, m2), ci);
return Basis(Vector3(godot.m[0], godot.m[3], godot.m[6]),
Vector3(godot.m[1], godot.m[4], godot.m[7]),
Vector3(godot.m[2], godot.m[5], godot.m[8]));
}
Vector3 attribute_position(const std::array<float, 3> &p) {
const fmt::m2coord::Vec3 v = fmt::m2coord::position_to_godot(p[0], p[1], p[2]);
return Vector3((float)v.x, (float)v.y, (float)v.z);
}
bool add_attribute_collision(const fmt::AttributeCollision &collision,
StaticBody3D *body) {
using Type = fmt::AttributeCollisionType;
const auto type = static_cast<Type>(collision.type);
Ref<Shape3D> shape;
Transform3D local(attribute_quaternion_basis(collision.quaternion),
attribute_position(collision.position));
const float cm = 0.01f;
const float thickness = 0.04f; // finite-plane approximation, 4 cm
switch (type) {
case Type::Plane: {
Ref<BoxShape3D> plane;
plane.instantiate();
plane->set_size(Vector3(std::max(0.02f, std::abs(collision.dimensions[0]) * cm),
thickness, std::max(0.02f, std::abs(collision.dimensions[1]) * cm)));
shape = plane;
break;
}
case Type::Box:
case Type::Aabb:
case Type::Obb: {
Ref<BoxShape3D> box;
box.instantiate();
// CollisionData stores these as half-extents. Swap source y/z for Godot.
box->set_size(Vector3(std::max(0.02f, std::abs(collision.dimensions[0]) * 2.0f * cm),
std::max(0.02f, std::abs(collision.dimensions[2]) * 2.0f * cm),
std::max(0.02f, std::abs(collision.dimensions[1]) * 2.0f * cm)));
shape = box;
if (type == Type::Aabb)
local.basis = Basis();
break;
}
case Type::Sphere: {
Ref<SphereShape3D> sphere;
sphere.instantiate();
sphere->set_radius(std::max(0.01f, std::abs(collision.dimensions[0]) * cm));
shape = sphere;
local.basis = Basis();
break;
}
case Type::Cylinder: {
Ref<CylinderShape3D> cylinder;
cylinder.instantiate();
cylinder->set_radius(std::max(0.01f, std::abs(collision.dimensions[0]) * cm));
cylinder->set_height(std::max(0.02f, std::abs(collision.dimensions[1]) * cm));
shape = cylinder;
break;
}
default:
return false;
}
CollisionShape3D *node = memnew(CollisionShape3D);
node->set_name(String(collision.name.c_str()));
node->set_shape(shape);
node->set_transform(local);
body->add_child(node);
return true;
}
inline float cross_product_2d(float x1, float y1, float x2, float y2) {
return x1 * y2 - y1 * x2;
}
inline bool is_in_triangle_2d(float ax, float ay, float bx, float by, float cx, float cy, float tx, float ty) {
float c1 = cross_product_2d(bx - ax, by - ay, tx - ax, ty - ay);
float c2 = cross_product_2d(cx - bx, cy - by, tx - bx, ty - by);
float c3 = cross_product_2d(ax - cx, ay - cy, tx - cx, ty - cy);
if (c1 * c2 > 0.0f && c1 * c3 > 0.0f)
return true;
if (c1 * c2 * c3 == 0.0f) {
if (tx < ax && tx < bx && tx < cx) return false;
if (tx > ax && tx > bx && tx > cx) return false;
if (ty < ay && ty < by && ty < cy) return false;
if (ty > ay && ty > by && ty > cy) return false;
return true;
}
return false;
}
void extract_height_triangles(const fmt::AttributeData &data, const Transform3D &place,
std::vector<Metin2World::HeightTriangle> &out_heights) {
for (const auto &height : data.heights) {
if (height.vertices.size() < 3)
continue;
for (size_t i = 0; i + 2 < height.vertices.size(); i += 3) {
Vector3 v0 = place.xform(attribute_position(height.vertices[i]));
Vector3 v1 = place.xform(attribute_position(height.vertices[i + 1]));
Vector3 v2 = place.xform(attribute_position(height.vertices[i + 2]));
Vector3 line1 = v1 - v0;
Vector3 line2 = v2 - v0;
Vector3 normal = line1.cross(line2);
float len = normal.length();
if (len < 1e-6f)
continue;
normal /= len;
if (std::abs(normal.y) < 0.001f)
continue; // vertical or near-vertical surface, not a walkable floor/deck
Metin2World::HeightTriangle tri;
tri.v0 = v0;
tri.v1 = v1;
tri.v2 = v2;
tri.min_x = std::min({v0.x, v1.x, v2.x});
tri.max_x = std::max({v0.x, v1.x, v2.x});
tri.min_z = std::min({v0.z, v1.z, v2.z});
tri.max_z = std::max({v0.z, v1.z, v2.z});
tri.normal = normal;
tri.d = normal.dot(v0);
out_heights.push_back(tri);
}
}
}
int add_attribute_data(const std::string &path, const Transform3D &place,
Node3D *root, MeshInstance3D *mesh,
std::vector<Metin2World::HeightTriangle> *out_heights = nullptr) {
fmt::AttributeData data;
std::string error;
if (!fmt::parse_attribute_data_file(path, data, &error)) {
UtilityFunctions::push_warning(String("[mdatr] load failed: ") + path.c_str() +
" (" + error.c_str() + ")");
return 0;
}
if (out_heights) {
extract_height_triangles(data, place, *out_heights);
}
StaticBody3D *body = memnew(StaticBody3D);
body->set_name(String("AttributeBody_") + String(path.c_str()).get_file().get_basename());
body->set_collision_layer(2);
body->set_collision_mask(0);
body->set_transform(place);
int shapes = 0;
for (const auto &collision : data.collisions)
if (add_attribute_collision(collision, body))
++shapes;
for (const auto &height : data.heights) {
if (height.vertices.size() < 3)
continue;
PackedVector3Array faces;
faces.resize((int)height.vertices.size());
for (int i = 0; i < (int)height.vertices.size(); ++i)
faces[i] = attribute_position(height.vertices[(size_t)i]);
Ref<ConcavePolygonShape3D> concave;
concave.instantiate();
concave->set_faces(faces);
CollisionShape3D *node = memnew(CollisionShape3D);
node->set_name(String(height.name.c_str()));
node->set_shape(concave);
body->add_child(node);
++shapes;
}
if (shapes == 0) {
body->queue_free();
return 0;
}
body->set_meta("occ_mesh", mesh);
root->add_child(body);
return shapes;
}
void load_attribute_heights(const std::string &path, const Transform3D &place,
std::vector<Metin2World::HeightTriangle> &out_heights) {
fmt::AttributeData data;
std::string error;
if (fmt::parse_attribute_data_file(path, data, &error)) {
extract_height_triangles(data, place, out_heights);
}
}
} // namespace
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("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),
"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<fmt::HeightMap>();
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<fmt::AttrMap>();
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<Material> mat;
{
Ref<StandardMaterial3D> 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<ShaderMaterial> 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<ArrayMesh> 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_layer_mask(1u << 0); // Background light visibility layer.
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):HeightMapShape3D129×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<HeightMapShape3D> 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_layer_mask(1u << 0);
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, ck.height_triangles);
if (registry_ok)
place_chunk_ambience(tx, ty);
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,
std::vector<HeightTriangle> &out_heights) {
n_obj = 0;
n_tree = 0;
const std::string mdir = std::string(map_dir().utf8().get_data());
// 树按 treefile 分组 -> 每组一个 MultiMeshInstance3D
struct TreeGroup {
std::vector<Transform3D> xforms;
};
std::map<std::string, TreeGroup> 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<godot::ArrayMesh> 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_convcm->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_layer_mask(1u << 0); // Background light visibility layer.
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 先存进 metaR1 不做室内裁剪)
if (!o.portal_ids.empty()) {
Array pids;
for (int pid : o.portal_ids)
pids.push_back(pid);
mi->set_meta("portal_ids", pids);
}
root->add_child(mi);
// §8.2/§8.3: 盒碰撞体(层 2 = 静态遮挡物)。相机用它做防穿 + 遮挡淡出。
// 盒尺寸/中心直接算到「米 / Y-up」,body 只带 place(旋转+平移,无 conv),
// 避免把碰撞形状放在 0.01 缩放节点下(Godot 缩放 shape 不稳)。
bool mdatr_built = false;
String md = String(rp.c_str()).get_basename() + ".mdatr";
if (FileAccess::file_exists(md)) {
const std::string md_str(md.utf8().get_data());
if (collision_enabled) {
const int built = add_attribute_data(md_str, place, root, mi, &out_heights);
mdatr_built = built > 0;
if (mdatr_built)
++objects_mdatr_built;
} else {
load_attribute_heights(md_str, place, out_heights);
}
}
// A malformed/missing attribute resource still gets a conservative
// GR2 AABB fallback. A valid .mdatr owns the exact collision set.
if (collision_enabled && !mdatr_built) {
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<BoxShape3D> 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;
}
}
// Native extracted tree geometry, shared once per treefile across instances.
for (auto &kv : tree_groups) {
if (kv.second.xforms.empty())
continue;
Ref<godot::ArrayMesh> tmesh = get_tree_mesh(kv.first, *resolver, 12.0f);
const bool native = String(tmesh->get_meta("tree_geometry", "proxy")) == "native_spt";
(native ? native_trees_placed : proxy_trees_placed) += (int)kv.second.xforms.size();
Ref<MultiMesh> 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_layer_mask(1u << 0);
mmi->set_multimesh(mm);
mmi->set_meta("tree_geometry", native ? "native_spt" : "proxy");
// §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;
}
}
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();
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;
// AssetResolver 是地图对象、环境声属性和 splat 共用的资源索引。
// 即使关闭 splat,也要保留这条链路,避免环境声依赖渲染开关。
splat_ready = false;
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);
}
std::string tse;
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 注册表(对象和环境声共用;不依赖 objects_enabled)。
registry_ok = false;
{
// resolver 已建:用它的文件清单,避免再 std::filesystem 扫盘(PCK 里扫不了)。
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;
std::string ee;
if (fmt::parse_environment_file(rp, env, &ee)) {
apply_environment(env, this, resolver.get());
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();
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);
}
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) {
update_water_animation();
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();
ambience_sources.clear();
if (objects_root) {
objects_root->queue_free();
objects_root = nullptr;
}
for (const char *nm : {"Sun", "CharacterLight", "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;
native_trees_placed = proxy_trees_placed = 0;
objects_mdatr_built = 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);
double terrain_h = 0.0;
if (c && c->hm) {
const double lx = mx_cm - double(tx) * fmt::m2coord::CHUNK_CM;
const double ly = my_abs_cm - double(ty) * fmt::m2coord::CHUNK_CM;
terrain_h = fmt::terrain_height_at(*c->hm, lx, ly, setting.height_scale) * fmt::m2coord::CM_TO_M;
}
// 40250 CMapOutdoor::GetHeight(fx, fy):
// Check placed objects' height data (bridges, stairs, platforms from .mdatr).
// If object height > terrain height, return fMAX(fObjectHeight, fTerrainHeight).
float obj_h = -25000.0f;
bool obj_found = false;
const float fgx = float(gx_m);
const float fgz = float(gz_m);
for (int dtx = -1; dtx <= 1; ++dtx) {
int ntx = tx + dtx;
if (ntx < 0 || ntx >= setting.map_size_x)
continue;
for (int dty = -1; dty <= 1; ++dty) {
int nty = ty + dty;
if (nty < 0 || nty >= setting.map_size_y)
continue;
const Chunk *nc = chunk_at(ntx, nty);
if (!nc || nc->height_triangles.empty())
continue;
for (const auto &tri : nc->height_triangles) {
if (fgx < tri.min_x || fgx > tri.max_x ||
fgz < tri.min_z || fgz > tri.max_z)
continue;
if (is_in_triangle_2d(tri.v0.x, tri.v0.z, tri.v1.x, tri.v1.z, tri.v2.x, tri.v2.z, fgx, fgz)) {
float h = (tri.d - tri.normal.x * fgx - tri.normal.z * fgz) / tri.normal.y;
if (!obj_found || h > obj_h) {
obj_h = h;
obj_found = true;
}
}
}
}
}
if (obj_found && (c == nullptr || !c->hm || obj_h > terrain_h))
return double(obj_h);
return terrain_h;
}
Ref<godot::Image> Metin2World::load_dds(const String &path) const {
mtgodot::Image d = mtgodot::dds_from_file(path);
if (!d.ok())
return Ref<godot::Image>();
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<FileAccess> 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<const uint8_t *>(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];
}
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;
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["ambience_sources"] = (int)ambience_sources.size();
d["trees_placed"] = trees_placed;
d["tree_species"] = tree_species;
d["native_trees_placed"] = native_trees_placed;
d["proxy_trees_placed"] = proxy_trees_placed;
d["objects_mdatr_built"] = objects_mdatr_built; // 使用 .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;
}
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();
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