Files
mtgodot-poc/extension/src/metin2_world.cpp
T
shenandClaude Sonnet 5 47baf6c0c6 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
2026-08-31 20:02:12 +09:00

790 lines
30 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "metin2_world.h"
#include <godot_cpp/classes/array_mesh.hpp>
#include <godot_cpp/classes/box_shape3d.hpp>
#include <godot_cpp/classes/collision_shape3d.hpp>
#include <godot_cpp/classes/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