Networked client on the existing Godot 4.7 + libgr2 renderer:
- net: m2dev wire protocol (libsodium KX + XChaCha20), auth/select/game
phases, EntityStore world model, ~all GC/CG headers. char create/delete,
private shop / mall / cube, SHOP_GC_START_EX, guild, party (+ CG_PARTY_SET_STATE),
quests, dragon soul, refine, safebox, exchange.
- UI: in-game windows migrated 1:1 from the reference uiscript/root .py —
char status (/stat), inventory+equipment, select-item ([SELECT_ITEM] quest
token), system-option + game-option + ESC system menu, private-shop 39-grid,
party info board, shop tabs, atlas, minimap, quickbar, chat, …
- EterGrnLib polish: GR2 material blend/two-sided, LOD crossfade, motion-event
dispatch, contact shadow, ray-AABB picking, weapon grip pre-transform.
Portable asset IO (A1) — all extension/libgr2/formats/mtproto reads routed
through godot::FileAccess (res:// PCK works on iOS/Android); standalone-lib
*_path() kept for the non-Godot CTests. AssetResolver + PropertyRegistry
switched to a baked index (bake_asset_index.gd) instead of std::filesystem.
Mobile builds: build-{android,ios}.sh, export-android.sh, pack-assets.sh,
gen-debug-keystore.sh. Assets ship as a zip mounted at runtime by
project/asset_pack.gd (adb push now; HTTP download is a drop-in later).
ctest 10/10, 34 GDScript suites, macOS/iOS/Android all build.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EJxkHiNKS4kybHS3XKyAJ
790 lines
30 KiB
C++
790 lines
30 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/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/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 "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>
|
||
|
||
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<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_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_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<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_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<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;
|
||
}
|
||
}
|
||
|
||
// Tree proxy:每 treefile 一个共享 mesh + MultiMeshInstance3D。
|
||
// 当前从 .spt 嗅探真实 bark/composite atlas;几何仍等待离线 SpeedTree exporter。
|
||
for (auto &kv : tree_groups) {
|
||
if (kv.second.xforms.empty())
|
||
continue;
|
||
Ref<godot::ArrayMesh> tmesh = get_tree_proxy_mesh(kv.first, *resolver, 12.0f);
|
||
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_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<fmt::AssetResolver>();
|
||
std::string re;
|
||
// asset_index.txt 存在就装载(PCK/移动端必走),否则扫盘(桌面开发)。
|
||
if (resolver->build_or_load(root, fmt::AssetResolver::default_priority(), &re)) {
|
||
splat_ready = true;
|
||
} else {
|
||
UtilityFunctions::push_warning(String("[Metin2World] AssetResolver: ") + re.c_str());
|
||
resolver.reset();
|
||
}
|
||
} else {
|
||
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<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];
|
||
}
|
||
|
||
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
|