Metin2 game client (P0–P11) + mobile asset pipeline

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
This commit is contained in:
shen
2026-08-31 20:02:12 +09:00
co-authored by Claude Sonnet 5
parent f4917a2b3b
commit 47baf6c0c6
414 changed files with 69568 additions and 385 deletions
+789
View File
@@ -0,0 +1,789 @@
#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):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_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_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_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);
}
// .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