- 桥梁与静态物体高度采样修复: - 严格对齐 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 客户端对拍缺陷发现与修复工程指南
1170 lines
44 KiB
C++
1170 lines
44 KiB
C++
#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):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<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_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_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 先存进 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);
|
||
}
|
||
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
|