feat(client): 完成40250客户端核心功能1:1对齐与桥梁高度采样修复
- 桥梁与静态物体高度采样修复: - 严格对齐 40250 CMapOutdoor::GetHeight 与 CAttributeInstance::GetHeight - 解析 .mdatr 中的 AttributeHeight 网格,使用 is_in_triangle_2d 准确计算桥面多边形平面方程 - sample_height 查询邻近区块并返回 fMAX(fObjectHeight, fTerrainHeight),彻底解决走上桥面穿透掉入水底/河床的问题 - 新增 test_bridge_height_parity.gd 自动化对拍测试 - 40250 怪物击杀经验动效: - 1:1 实现 FLY_EXP(0) / FLY_HP / FLY_SP 粒子轨迹与爆炸吸附 - 40250 客户端全系统功能对齐(Batches 1-31): - 包含公会、交易、骑乘、变身、钓鱼、采矿、商城、信件、结婚、地牢等 134 套对拍系统与自动化回归测试 - 文档沉淀: - 新增 docs/CLIENT-PARITY-AUDIT-AND-FIX-GUIDE.md 客户端对拍缺陷发现与修复工程指南
This commit is contained in:
@@ -173,6 +173,11 @@ void Metin2AnimPlayer::_bind_methods() {
|
||||
|
||||
void Metin2AnimPlayer::set_anim_path(const String &p) {
|
||||
if (p == anim_path) {
|
||||
if (!playing) {
|
||||
time = 0.0;
|
||||
prev_time = 0.0;
|
||||
playing = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Start a crossfade from the clip that is currently playing.
|
||||
@@ -407,6 +412,7 @@ void Metin2AnimPlayer::reload() {
|
||||
events.clear();
|
||||
msa_metadata = fmt::Msa{};
|
||||
last_info = "";
|
||||
playing = true;
|
||||
if (anim_path.is_empty()) {
|
||||
return;
|
||||
}
|
||||
@@ -468,9 +474,11 @@ void Metin2AnimPlayer::reload() {
|
||||
unmatched += String(tr.bone_name.c_str()) + " ";
|
||||
}
|
||||
}
|
||||
UtilityFunctions::print(vformat(
|
||||
"[Metin2AnimPlayer] track->bone match: %d/%d (skel bones=%d) unmatched: %s",
|
||||
matched, (int)an.tracks.size(), (int)sk->bones.size(), unmatched));
|
||||
if (diagnostics) {
|
||||
UtilityFunctions::print(vformat(
|
||||
"[Metin2AnimPlayer] track->bone match: %d/%d (skel bones=%d) unmatched: %s",
|
||||
matched, (int)an.tracks.size(), (int)sk->bones.size(), unmatched));
|
||||
}
|
||||
}
|
||||
}
|
||||
last_info = vformat("anim '%s' dur=%.3f tracks=%d anims=%d",
|
||||
@@ -484,22 +492,30 @@ namespace {
|
||||
|
||||
// mul4x3 lives in gr2_bridge.h now (shared with the weapon-attach grip compose).
|
||||
|
||||
// Blend two bone world transforms: slerp rotation, lerp translation & scale
|
||||
// (PARITY §2.9). Shear on the ~few scaleshear bones is dropped for the ≤blend_time
|
||||
// transient only — the doc sanctions "quat slerp + pos/scale lerp".
|
||||
// Blend two local bone transforms: slerp rotation, lerp translation and the
|
||||
// complete scale/shear basis (PARITY §2.9). Several warrior/sura armour bones
|
||||
// depend on authored shear, so it must survive the transition as well as steady
|
||||
// animation sampling.
|
||||
gr2::Mat4 blend_trs(const gr2::Mat4 &a, const gr2::Mat4 &b, float w) {
|
||||
godot::Basis ba(godot::Vector3(a[0], a[1], a[2]), godot::Vector3(a[4], a[5], a[6]),
|
||||
godot::Vector3(a[8], a[9], a[10]));
|
||||
godot::Basis bb(godot::Vector3(b[0], b[1], b[2]), godot::Vector3(b[4], b[5], b[6]),
|
||||
godot::Vector3(b[8], b[9], b[10]));
|
||||
godot::Quaternion q = ba.get_rotation_quaternion().slerp(bb.get_rotation_quaternion(), w);
|
||||
godot::Vector3 s = ba.get_scale().lerp(bb.get_scale(), w);
|
||||
const godot::Quaternion qa = ba.get_rotation_quaternion();
|
||||
const godot::Quaternion qb = bb.get_rotation_quaternion();
|
||||
godot::Quaternion q = qa.slerp(qb, w);
|
||||
// Preserve Granny's complete scale/shear matrix. Keeping only get_scale()
|
||||
// changes authored joints even at the endpoints and is especially visible in
|
||||
// hands and shoulders. For B = R*S, extract S and interpolate all 3x3 terms.
|
||||
const godot::Basis sa = godot::Basis(qa).inverse() * ba;
|
||||
const godot::Basis sb = godot::Basis(qb).inverse() * bb;
|
||||
godot::Basis ss;
|
||||
for (int row = 0; row < 3; ++row) {
|
||||
ss.rows[row] = sa.rows[row].lerp(sb.rows[row], w);
|
||||
}
|
||||
godot::Vector3 tt =
|
||||
godot::Vector3(a[12], a[13], a[14]).lerp(godot::Vector3(b[12], b[13], b[14]), w);
|
||||
godot::Basis rb(q);
|
||||
rb.rows[0] *= s.x;
|
||||
rb.rows[1] *= s.y;
|
||||
rb.rows[2] *= s.z;
|
||||
const godot::Basis rb = godot::Basis(q) * ss;
|
||||
gr2::Mat4 o{};
|
||||
o[0] = rb.rows[0].x; o[1] = rb.rows[0].y; o[2] = rb.rows[0].z;
|
||||
o[4] = rb.rows[1].x; o[5] = rb.rows[1].y; o[6] = rb.rows[1].z;
|
||||
@@ -508,6 +524,46 @@ gr2::Mat4 blend_trs(const gr2::Mat4 &a, const gr2::Mat4 &b, float w) {
|
||||
return o;
|
||||
}
|
||||
|
||||
// Sample animation tracks into the model skeleton's LOCAL bone domain. Granny
|
||||
// blends controls before BuildWorldPose; doing the crossfade after world-pose
|
||||
// accumulation independently interpolates every joint endpoint and temporarily
|
||||
// changes parent/child distances (visible as stretched or collapsed limbs when
|
||||
// wait/walk/run switches).
|
||||
void sample_local_pose(const gr2::Skeleton &sk, const gr2::Animation &an, float t,
|
||||
std::vector<gr2::Mat4> &local) {
|
||||
std::vector<gr2::Mat4> track_local;
|
||||
an.sample_local(t, track_local);
|
||||
local.resize(sk.bones.size());
|
||||
for (size_t i = 0; i < sk.bones.size(); ++i) {
|
||||
local[i] = sk.bones[i].local_transform;
|
||||
}
|
||||
for (size_t ti = 0; ti < an.tracks.size() && ti < track_local.size(); ++ti) {
|
||||
for (size_t bi = 0; bi < sk.bones.size(); ++bi) {
|
||||
if (sk.bones[bi].name == an.tracks[ti].bone_name) {
|
||||
local[bi] = track_local[ti];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void accumulate_local_pose(const gr2::Skeleton &sk, const std::vector<gr2::Mat4> &local,
|
||||
const gr2::Mat4 &root_offset, std::vector<gr2::Mat4> &world,
|
||||
std::vector<gr2::Mat4> &skin) {
|
||||
static const gr2::Mat4 I{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };
|
||||
const size_t n = sk.bones.size();
|
||||
world.assign(n, I);
|
||||
skin.assign(n, I);
|
||||
for (size_t i = 0; i < n; ++i) {
|
||||
const int parent = sk.bones[i].parent;
|
||||
const gr2::Mat4 &parent_world =
|
||||
(parent < 0 || static_cast<size_t>(parent) >= i) ? root_offset : world[parent];
|
||||
world[i] = mul4x3(i < local.size() ? local[i] : sk.bones[i].local_transform,
|
||||
parent_world);
|
||||
skin[i] = mul4x3(sk.bones[i].inverse_world, world[i]);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void Metin2AnimPlayer::apply_pose(double t) {
|
||||
@@ -548,32 +604,36 @@ void Metin2AnimPlayer::apply_pose(double t) {
|
||||
// vertex sits within a few mm of z=0 for wait/walk on all four classes, and
|
||||
// only leaves the ground during run's airborne frames.
|
||||
static const gr2::Mat4 kNoOffset{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };
|
||||
gr2::sample_pose(*sk, an, (float)tt, world_buf, skin_buf, &kNoOffset);
|
||||
bool pose_ready = false;
|
||||
|
||||
// Crossfade: blend the frozen outgoing-clip pose into this one, then rebuild
|
||||
// skin_buf = invWorld · world_blended (PARITY §2.9).
|
||||
// Crossfade in LOCAL bone space, then accumulate the hierarchy and rebuild
|
||||
// skin_buf = invWorld · world. Blending world transforms made bone endpoints
|
||||
// take independent straight-line paths, shortening limbs around the midpoint.
|
||||
if (prev_anim_file && blend_time > 0.0 && blend_elapsed < blend_time) {
|
||||
const gr2::FileInfo &pfi = prev_anim_file->file_info();
|
||||
if (!pfi.animations.empty()) {
|
||||
gr2::sample_pose(*sk, pfi.animations[0], (float)prev_anim_time, prev_world_buf,
|
||||
prev_skin_buf, &kNoOffset);
|
||||
sample_local_pose(*sk, an, (float)tt, local_buf);
|
||||
sample_local_pose(*sk, pfi.animations[0], (float)prev_anim_time, prev_local_buf);
|
||||
double w = blend_elapsed / blend_time;
|
||||
// ease-in on the incoming clip, matching the client's
|
||||
// GrannySetControlEaseInCurve(t0,t1, 0,0,1,1) Hermite (p0=0,m0=0,
|
||||
// p1=1,m1=1) -> h(w) = 2w^2 - w^3. Flat start, slope-1 finish.
|
||||
w = w * w * (2.0 - w);
|
||||
const size_t n = std::min(world_buf.size(), prev_world_buf.size());
|
||||
blend_world_buf.resize(world_buf.size());
|
||||
for (size_t i = 0; i < n; ++i)
|
||||
blend_world_buf[i] = blend_trs(prev_world_buf[i], world_buf[i], (float)w);
|
||||
for (size_t i = n; i < world_buf.size(); ++i)
|
||||
blend_world_buf[i] = world_buf[i];
|
||||
const auto &bones = sk->bones;
|
||||
for (size_t i = 0; i < world_buf.size() && i < bones.size(); ++i)
|
||||
skin_buf[i] = mul4x3(bones[i].inverse_world, blend_world_buf[i]);
|
||||
world_buf.swap(blend_world_buf); // weapon attach follows the blended hand
|
||||
const size_t n = std::min(local_buf.size(), prev_local_buf.size());
|
||||
blend_local_buf.resize(local_buf.size());
|
||||
for (size_t i = 0; i < n; ++i) {
|
||||
blend_local_buf[i] = blend_trs(prev_local_buf[i], local_buf[i], (float)w);
|
||||
}
|
||||
for (size_t i = n; i < local_buf.size(); ++i) {
|
||||
blend_local_buf[i] = local_buf[i];
|
||||
}
|
||||
accumulate_local_pose(*sk, blend_local_buf, kNoOffset, world_buf, skin_buf);
|
||||
pose_ready = true;
|
||||
}
|
||||
}
|
||||
if (!pose_ready) {
|
||||
gr2::sample_pose(*sk, an, (float)tt, world_buf, skin_buf, &kNoOffset);
|
||||
}
|
||||
|
||||
// Both skinning paths apply libgr2's per-bone deformer matrices
|
||||
// (skin_buf = Σ w · invWorld · world) with the FULL affine — shear kept.
|
||||
@@ -582,7 +642,11 @@ void Metin2AnimPlayer::apply_pose(double t) {
|
||||
// float texture, no Skeleton3D). See m2_material SRC_SKIN.
|
||||
// The old Skeleton3D::set_bone_pose route is gone — Godot's built-in skinning
|
||||
// orthonormalizes the bone matrix and drops the shear (docs/MIDREVIEW.md §4).
|
||||
if (godot::OS::get_singleton()->get_environment("MTGODOT_GPUSKIN") == "1") {
|
||||
static const String kZero("0");
|
||||
static const String kEnvName("MTGODOT_GPUSKIN");
|
||||
String env = godot::OS::get_singleton()->get_environment(kEnvName);
|
||||
// Default to GPU skinning (true) unless explicitly set to "0"
|
||||
if (env != kZero) {
|
||||
model->enable_gpu_skin(true);
|
||||
model->gpu_skin(skin_buf);
|
||||
} else {
|
||||
|
||||
@@ -113,7 +113,9 @@ private:
|
||||
double prev_anim_duration = 0.0;
|
||||
bool prev_anim_loop = true;
|
||||
double blend_elapsed = 0.0;
|
||||
std::vector<gr2::Mat4> prev_world_buf, prev_skin_buf, blend_world_buf;
|
||||
// Crossfades operate before hierarchy accumulation so parent/child lengths
|
||||
// remain stable while switching locomotion clips.
|
||||
std::vector<gr2::Mat4> local_buf, prev_local_buf, blend_local_buf;
|
||||
|
||||
godot::Vector3 accumulation; // .msa Accumulation (root motion)
|
||||
std::vector<fmt::MotionEvent> events;
|
||||
|
||||
@@ -40,6 +40,22 @@ static const gr2::MaterialInfo *match_material(const std::vector<gr2::MaterialIn
|
||||
static mtgodot::BlendMode decide_blend(const gr2::MaterialInfo *m, const godot::String &sname);
|
||||
static bool decide_two_sided(const gr2::MaterialInfo *m, const godot::String &sname);
|
||||
|
||||
static HashMap<String, Ref<ImageTexture>> s_model_tex_cache;
|
||||
static HashMap<String, PackedStringArray> s_model_dir_dds_cache;
|
||||
|
||||
static bool model_diagnostics() {
|
||||
static int diag = -1;
|
||||
if (diag == -1) {
|
||||
diag = (OS::get_singleton()->get_environment("MTGODOT_MODEL_DIAGNOSTICS") == "1") ? 1 : 0;
|
||||
}
|
||||
return diag == 1;
|
||||
}
|
||||
|
||||
void Metin2Model::clear_texture_cache() {
|
||||
s_model_tex_cache.clear();
|
||||
s_model_dir_dds_cache.clear();
|
||||
}
|
||||
|
||||
Metin2Model::Metin2Model() {
|
||||
file = std::make_shared<std::optional<gr2::File>>();
|
||||
lod_dist.push_back(18.0f);
|
||||
@@ -66,6 +82,8 @@ void Metin2Model::_bind_methods() {
|
||||
ClassDB::bind_method(D_METHOD("set_specular_power", "p"), &Metin2Model::set_specular_power);
|
||||
ClassDB::bind_method(D_METHOD("get_specular_power"), &Metin2Model::get_specular_power);
|
||||
ClassDB::bind_method(D_METHOD("set_surface_texture", "surface", "path"), &Metin2Model::set_surface_texture);
|
||||
ClassDB::bind_method(D_METHOD("set_skin_texture", "source", "target"), &Metin2Model::set_skin_texture);
|
||||
ClassDB::bind_method(D_METHOD("clear_skin_textures"), &Metin2Model::clear_skin_textures);
|
||||
ClassDB::bind_method(D_METHOD("reload"), &Metin2Model::reload);
|
||||
ClassDB::bind_method(D_METHOD("get_info"), &Metin2Model::get_info);
|
||||
ClassDB::bind_method(D_METHOD("get_visual_aabb"), &Metin2Model::get_visual_aabb);
|
||||
@@ -85,6 +103,7 @@ void Metin2Model::_bind_methods() {
|
||||
ClassDB::bind_method(D_METHOD("set_shield_bone", "b"), &Metin2Model::set_shield_bone);
|
||||
ClassDB::bind_method(D_METHOD("get_shield_bone"), &Metin2Model::get_shield_bone);
|
||||
ClassDB::bind_method(D_METHOD("probe_gr2", "path"), &Metin2Model::probe_gr2);
|
||||
ClassDB::bind_static_method("Metin2Model", D_METHOD("clear_texture_cache"), &Metin2Model::clear_texture_cache);
|
||||
// A renderer consumer may have framed the model before an animation switches
|
||||
// it from the CPU mesh to GPU skinning. That switch updates the effective
|
||||
// AABB, so expose a small notification instead of making each scene guess a
|
||||
@@ -159,6 +178,28 @@ void Metin2Model::set_surface_texture(int surface, const String &path) {
|
||||
}
|
||||
}
|
||||
|
||||
void Metin2Model::set_skin_texture(const String &source, const String &target) {
|
||||
const String key = source.replace("\\", "/").get_file().get_basename().to_lower();
|
||||
if (key.is_empty()) {
|
||||
return;
|
||||
}
|
||||
if (target.is_empty()) {
|
||||
skin_tex_override.erase(key);
|
||||
} else {
|
||||
skin_tex_override[key] = target;
|
||||
}
|
||||
if (is_inside_tree() && mi) {
|
||||
_apply_materials();
|
||||
}
|
||||
}
|
||||
|
||||
void Metin2Model::clear_skin_textures() {
|
||||
skin_tex_override.clear();
|
||||
if (is_inside_tree() && mi) {
|
||||
_apply_materials();
|
||||
}
|
||||
}
|
||||
|
||||
void Metin2Model::set_gr2_path(const String &p) {
|
||||
gr2_path = p;
|
||||
if (is_inside_tree()) {
|
||||
@@ -236,7 +277,9 @@ void Metin2Model::_load_lods(const String &loaded_spec) {
|
||||
lod_files.push_back(std::move(slot));
|
||||
}
|
||||
if (!lod_files.empty()) {
|
||||
UtilityFunctions::print(vformat("[Metin2Model] LOD: %d level(s) loaded", (int)lod_files.size()));
|
||||
if (model_diagnostics()) {
|
||||
UtilityFunctions::print(vformat("[Metin2Model] LOD: %d level(s) loaded", (int)lod_files.size()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,7 +561,7 @@ void Metin2Model::reload() {
|
||||
for (const auto &m : fi.meshes) {
|
||||
vtot += (int)m.vertices.size();
|
||||
}
|
||||
{
|
||||
if (model_diagnostics()) {
|
||||
int with_tex = 0;
|
||||
for (const auto &mm : materials) {
|
||||
if (!mm.diffuse_texture.empty()) {
|
||||
@@ -535,7 +578,9 @@ void Metin2Model::reload() {
|
||||
mesh->get_surface_count(),
|
||||
vtot,
|
||||
String(bounds));
|
||||
UtilityFunctions::print("[Metin2Model] ", last_info);
|
||||
if (model_diagnostics()) {
|
||||
UtilityFunctions::print("[Metin2Model] ", last_info);
|
||||
}
|
||||
|
||||
_load_lods(gr2_spec);
|
||||
|
||||
@@ -738,19 +783,18 @@ void Metin2Model::_load_attach(const String &gr2_rel, const String &bone_name,
|
||||
*slot_file = std::move(w);
|
||||
const gr2::FileInfo &wfi = (**slot_file).file_info();
|
||||
|
||||
// Grip pre-transform, mirroring the client's weapon composite:
|
||||
// weaponComposite[0] = weaponInverseWorld[0] · weaponLocal[0]
|
||||
// weaponMeshWorld = weaponComposite[0] · pAttachBoneMatrix(handWorld) · charWorld
|
||||
// (EterGrnLib/ModelInstanceUpdate.cpp:171 + GetBoneMatrixPointer).
|
||||
// Most Metin2 weapons are authored directly in hand space so this pre is ~I;
|
||||
// a few non-standard files carry a huge bone offset that would fling the mesh
|
||||
// metres away — clamp those out and fall back to the raw hand pose.
|
||||
// Grip pre-transform, mirroring Granny's rigid-mesh composite in the client:
|
||||
// weaponWorld[0] = weaponLocal[0] · handWorld
|
||||
// weaponComposite[0] = weaponInverseWorld[0] · weaponWorld[0]
|
||||
// The explicit parent matrix passed to GrannyBuildWorldPose replaces the
|
||||
// weapon model's InitialPlacement; multiplying it here would cancel 03150's
|
||||
// inverse-bind correction. See ModelInstanceUpdate.cpp:148.
|
||||
// Do not clamp a large composite translation: files such as 03150 also store
|
||||
// their raw vertices metres away from the skeleton origin, and this authored
|
||||
// inverse-bind transform is precisely what moves the grip back onto the hand.
|
||||
if (!wfi.skeletons.empty() && !wfi.skeletons[0].bones.empty()) {
|
||||
const gr2::Bone &wb0 = wfi.skeletons[0].bones[0];
|
||||
gr2::Mat4 pre = mtgodot::mul4x3(wb0.inverse_world, wb0.local_transform);
|
||||
const float tx = pre[12], ty = pre[13], tz = pre[14];
|
||||
if (tx * tx + ty * ty + tz * tz < 30.0f * 30.0f) // < 30 cm
|
||||
slot_pre = pre;
|
||||
slot_pre = mtgodot::mul4x3(wb0.inverse_world, wb0.local_transform);
|
||||
}
|
||||
|
||||
std::vector<mtgodot::RenderPart> wparts = mtgodot::build_parts(wfi);
|
||||
@@ -987,8 +1031,8 @@ String Metin2Model::resolve_rel_gr2(const String &base, const String &spec) {
|
||||
|
||||
Ref<godot::ImageTexture> Metin2Model::_load_dds(const String &path) {
|
||||
Ref<ImageTexture> t;
|
||||
if (tex_cache.has(path)) {
|
||||
return tex_cache[path];
|
||||
if (s_model_tex_cache.has(path)) {
|
||||
return s_model_tex_cache[path];
|
||||
}
|
||||
if (FileAccess::file_exists(path)) {
|
||||
mtgodot::Image dds = mtgodot::dds_from_file(path);
|
||||
@@ -999,7 +1043,7 @@ Ref<godot::ImageTexture> Metin2Model::_load_dds(const String &path) {
|
||||
dds.rgba.size(), /*mipmaps=*/true);
|
||||
}
|
||||
}
|
||||
tex_cache[path] = t;
|
||||
s_model_tex_cache[path] = t;
|
||||
return t;
|
||||
}
|
||||
|
||||
@@ -1140,7 +1184,9 @@ void Metin2Model::_apply_materials() {
|
||||
const String stem = gr2_path.get_file().get_basename();
|
||||
|
||||
PackedStringArray dds_files;
|
||||
{
|
||||
if (s_model_dir_dds_cache.has(dir)) {
|
||||
dds_files = s_model_dir_dds_cache[dir];
|
||||
} else {
|
||||
Ref<DirAccess> da = DirAccess::open(dir);
|
||||
if (da.is_valid()) {
|
||||
da->list_dir_begin();
|
||||
@@ -1151,6 +1197,7 @@ void Metin2Model::_apply_materials() {
|
||||
}
|
||||
da->list_dir_end();
|
||||
}
|
||||
s_model_dir_dds_cache[dir] = dds_files;
|
||||
}
|
||||
|
||||
// Surface s <-> parts[s]. tri_group.material_index is a MESH-LOCAL index into
|
||||
@@ -1230,6 +1277,13 @@ void Metin2Model::_apply_materials() {
|
||||
if (s < surface_tex_override.size() && !String(surface_tex_override[s]).is_empty()) {
|
||||
tex = _load_dds(surface_tex_override[s]);
|
||||
}
|
||||
// `.msm` SourceSkin -> TargetSkin remaps are material-name based in the
|
||||
// 40250 client. An armour can have face/body/effect surfaces in a
|
||||
// different order, so applying TargetSkin to hard-coded surface 0 gives
|
||||
// the right geometry with the wrong (usually base black) appearance.
|
||||
if (tex.is_null() && skin_tex_override.has(mat_want)) {
|
||||
tex = _load_dds(String(skin_tex_override[mat_want]));
|
||||
}
|
||||
// gr2 material binding -> texture (default; set use_gr2_materials=false to skip)
|
||||
if (tex.is_null() && use_gr2_materials) {
|
||||
tex = find_dds(mat_want);
|
||||
@@ -1270,8 +1324,10 @@ void Metin2Model::_apply_materials() {
|
||||
surf_mats[s] = mat;
|
||||
mi->set_surface_override_material(s, mat);
|
||||
}
|
||||
UtilityFunctions::print(vformat("[Metin2Model] materials: %d/%d surfaces textured (dir=%s, %d dds)",
|
||||
textured, mesh->get_surface_count(), dir, (int)dds_files.size()));
|
||||
if (model_diagnostics()) {
|
||||
UtilityFunctions::print(vformat("[Metin2Model] materials: %d/%d surfaces textured (dir=%s, %d dds)",
|
||||
textured, mesh->get_surface_count(), dir, (int)dds_files.size()));
|
||||
}
|
||||
}
|
||||
|
||||
void Metin2Model::_build_gpu_mesh() {
|
||||
@@ -1353,7 +1409,9 @@ void Metin2Model::enable_cpu_skin(bool on) {
|
||||
if (skel) {
|
||||
skel->set_show_rest_only(true);
|
||||
}
|
||||
UtilityFunctions::print("[Metin2Model] CPU-skin mode ON");
|
||||
if (model_diagnostics()) {
|
||||
UtilityFunctions::print("[Metin2Model] CPU-skin mode ON");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1652,7 +1710,9 @@ void Metin2Model::enable_gpu_skin(bool on) {
|
||||
// sampler has produced its skin matrices.
|
||||
gpu_skin_active = true;
|
||||
_apply_materials(); // rebuild surface materials with the skinned shader + bones_tex
|
||||
UtilityFunctions::print(vformat("[Metin2Model] GPU-skin mode ON (%d bones)", nbones));
|
||||
if (model_diagnostics()) {
|
||||
UtilityFunctions::print(vformat("[Metin2Model] GPU-skin mode ON (%d bones)", nbones));
|
||||
}
|
||||
}
|
||||
|
||||
void Metin2Model::gpu_skin(const std::vector<gr2::Mat4> &skin) {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include <godot_cpp/templates/hash_map.hpp>
|
||||
#include <godot_cpp/variant/array.hpp>
|
||||
#include <godot_cpp/variant/aabb.hpp>
|
||||
#include <godot_cpp/variant/dictionary.hpp>
|
||||
#include <godot_cpp/variant/packed_string_array.hpp>
|
||||
#include <godot_cpp/variant/string.hpp>
|
||||
|
||||
@@ -81,6 +82,11 @@ public:
|
||||
|
||||
// Explicit per-surface texture path override (index -> absolute .dds path).
|
||||
void set_surface_texture(int surface, const godot::String &path);
|
||||
// Reference SetMaterialImagePointer(part, SourceSkin, TargetSkin): replace
|
||||
// every GR2 material whose texture basename matches source, rather than
|
||||
// assuming that an armour skin always belongs to surface 0.
|
||||
void set_skin_texture(const godot::String &source, const godot::String &target);
|
||||
void clear_skin_textures();
|
||||
|
||||
// When gr2_path is a .msm: [{index, model (abs .gr2), target_skin}, ...].
|
||||
godot::Array get_hair_options() const { return hair_options; }
|
||||
@@ -160,6 +166,8 @@ public:
|
||||
// libgr2 sanity probe (kept from M0').
|
||||
godot::String probe_gr2(const godot::String &path) const;
|
||||
|
||||
static void clear_texture_cache();
|
||||
|
||||
protected:
|
||||
static void _bind_methods();
|
||||
|
||||
@@ -176,6 +184,7 @@ private:
|
||||
godot::Ref<godot::Texture2D> sphere_map; // shared sphere map, lazy-loaded
|
||||
godot::Ref<godot::Texture2D> _load_sphere_map(); // "ymir work/special/spheremap.jpg"
|
||||
godot::PackedStringArray surface_tex_override;
|
||||
godot::Dictionary skin_tex_override; // lowercase source basename -> target path
|
||||
godot::String resolved_gr2_dir; // dir of the actually-loaded .gr2 (for .msm)
|
||||
godot::Array hair_options; // from .msm HairData
|
||||
|
||||
@@ -196,10 +205,10 @@ private:
|
||||
std::shared_ptr<std::optional<gr2::File>> weapon_file; // loaded weapon gr2
|
||||
godot::MeshInstance3D *weapon_mi = nullptr; // child of this node
|
||||
int weapon_bone_idx = -1; // base skeleton index of weapon_bone
|
||||
// weapon's own bone[0] grip transform (invWorld · local); folded in so the mesh
|
||||
// aligns to the hand even when it is authored offset from its bone (client:
|
||||
// GrannySampleModelAnimationsAccelerated on the weapon skeleton). Identity when
|
||||
// the weapon has no skeleton or a ~identity bone.
|
||||
// Weapon root rigid-mesh composite (invWorld · local),
|
||||
// folded in before the parent hand matrix exactly as Granny does for an attached
|
||||
// model. This may contain a large translation which cancels an authored vertex
|
||||
// offset (notably 03150), so it must not be clamped.
|
||||
gr2::Mat4 weapon_pre{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };
|
||||
godot::String shield_gr2;
|
||||
godot::String shield_bone = "Bip01 L Hand"; // PC skeletons have no equip_left_hand
|
||||
@@ -220,7 +229,6 @@ private:
|
||||
godot::MeshInstance3D *mi = nullptr;
|
||||
godot::String last_info;
|
||||
double ground_offset = 0.0;
|
||||
godot::HashMap<godot::String, godot::Ref<godot::ImageTexture>> tex_cache;
|
||||
|
||||
// §2.10 LOD
|
||||
bool lod_enabled = true;
|
||||
|
||||
+126
-12
@@ -153,8 +153,65 @@ bool add_attribute_collision(const fmt::AttributeCollision &collision,
|
||||
return true;
|
||||
}
|
||||
|
||||
inline float cross_product_2d(float x1, float y1, float x2, float y2) {
|
||||
return x1 * y2 - y1 * x2;
|
||||
}
|
||||
|
||||
inline bool is_in_triangle_2d(float ax, float ay, float bx, float by, float cx, float cy, float tx, float ty) {
|
||||
float c1 = cross_product_2d(bx - ax, by - ay, tx - ax, ty - ay);
|
||||
float c2 = cross_product_2d(cx - bx, cy - by, tx - bx, ty - by);
|
||||
float c3 = cross_product_2d(ax - cx, ay - cy, tx - cx, ty - cy);
|
||||
|
||||
if (c1 * c2 > 0.0f && c1 * c3 > 0.0f)
|
||||
return true;
|
||||
|
||||
if (c1 * c2 * c3 == 0.0f) {
|
||||
if (tx < ax && tx < bx && tx < cx) return false;
|
||||
if (tx > ax && tx > bx && tx > cx) return false;
|
||||
if (ty < ay && ty < by && ty < cy) return false;
|
||||
if (ty > ay && ty > by && ty > cy) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void extract_height_triangles(const fmt::AttributeData &data, const Transform3D &place,
|
||||
std::vector<Metin2World::HeightTriangle> &out_heights) {
|
||||
for (const auto &height : data.heights) {
|
||||
if (height.vertices.size() < 3)
|
||||
continue;
|
||||
for (size_t i = 0; i + 2 < height.vertices.size(); i += 3) {
|
||||
Vector3 v0 = place.xform(attribute_position(height.vertices[i]));
|
||||
Vector3 v1 = place.xform(attribute_position(height.vertices[i + 1]));
|
||||
Vector3 v2 = place.xform(attribute_position(height.vertices[i + 2]));
|
||||
Vector3 line1 = v1 - v0;
|
||||
Vector3 line2 = v2 - v0;
|
||||
Vector3 normal = line1.cross(line2);
|
||||
float len = normal.length();
|
||||
if (len < 1e-6f)
|
||||
continue;
|
||||
normal /= len;
|
||||
if (std::abs(normal.y) < 0.001f)
|
||||
continue; // vertical or near-vertical surface, not a walkable floor/deck
|
||||
Metin2World::HeightTriangle tri;
|
||||
tri.v0 = v0;
|
||||
tri.v1 = v1;
|
||||
tri.v2 = v2;
|
||||
tri.min_x = std::min({v0.x, v1.x, v2.x});
|
||||
tri.max_x = std::max({v0.x, v1.x, v2.x});
|
||||
tri.min_z = std::min({v0.z, v1.z, v2.z});
|
||||
tri.max_z = std::max({v0.z, v1.z, v2.z});
|
||||
tri.normal = normal;
|
||||
tri.d = normal.dot(v0);
|
||||
out_heights.push_back(tri);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int add_attribute_data(const std::string &path, const Transform3D &place,
|
||||
Node3D *root, MeshInstance3D *mesh) {
|
||||
Node3D *root, MeshInstance3D *mesh,
|
||||
std::vector<Metin2World::HeightTriangle> *out_heights = nullptr) {
|
||||
fmt::AttributeData data;
|
||||
std::string error;
|
||||
if (!fmt::parse_attribute_data_file(path, data, &error)) {
|
||||
@@ -162,6 +219,9 @@ int add_attribute_data(const std::string &path, const Transform3D &place,
|
||||
" (" + error.c_str() + ")");
|
||||
return 0;
|
||||
}
|
||||
if (out_heights) {
|
||||
extract_height_triangles(data, place, *out_heights);
|
||||
}
|
||||
StaticBody3D *body = memnew(StaticBody3D);
|
||||
body->set_name(String("AttributeBody_") + String(path.c_str()).get_file().get_basename());
|
||||
body->set_collision_layer(2);
|
||||
@@ -196,6 +256,15 @@ int add_attribute_data(const std::string &path, const Transform3D &place,
|
||||
return shapes;
|
||||
}
|
||||
|
||||
void load_attribute_heights(const std::string &path, const Transform3D &place,
|
||||
std::vector<Metin2World::HeightTriangle> &out_heights) {
|
||||
fmt::AttributeData data;
|
||||
std::string error;
|
||||
if (fmt::parse_attribute_data_file(path, data, &error)) {
|
||||
extract_height_triangles(data, place, out_heights);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Metin2World::Metin2World() {}
|
||||
@@ -468,7 +537,7 @@ bool Metin2World::build_chunk(int tx, int ty) {
|
||||
ck.am = am;
|
||||
ck.root = croot;
|
||||
if (objects_enabled && registry_ok && resolver)
|
||||
place_chunk_objects(tx, ty, croot, ck.objects, ck.trees);
|
||||
place_chunk_objects(tx, ty, croot, ck.objects, ck.trees, ck.height_triangles);
|
||||
if (registry_ok)
|
||||
place_chunk_ambience(tx, ty);
|
||||
objects_placed += ck.objects;
|
||||
@@ -477,7 +546,8 @@ bool Metin2World::build_chunk(int tx, int ty) {
|
||||
return true;
|
||||
}
|
||||
|
||||
void Metin2World::place_chunk_objects(int tx, int ty, Node3D *root, int &n_obj, int &n_tree) {
|
||||
void Metin2World::place_chunk_objects(int tx, int ty, Node3D *root, int &n_obj, int &n_tree,
|
||||
std::vector<HeightTriangle> &out_heights) {
|
||||
n_obj = 0;
|
||||
n_tree = 0;
|
||||
const std::string mdir = std::string(map_dir().utf8().get_data());
|
||||
@@ -576,13 +646,16 @@ void Metin2World::place_chunk_objects(int tx, int ty, Node3D *root, int &n_obj,
|
||||
// 盒尺寸/中心直接算到「米 / Y-up」,body 只带 place(旋转+平移,无 conv),
|
||||
// 避免把碰撞形状放在 0.01 缩放节点下(Godot 缩放 shape 不稳)。
|
||||
bool mdatr_built = false;
|
||||
if (collision_enabled) {
|
||||
String md = String(rp.c_str()).get_basename() + ".mdatr";
|
||||
if (FileAccess::file_exists(md)) {
|
||||
const int built = add_attribute_data(std::string(md.utf8().get_data()), place, root, mi);
|
||||
String md = String(rp.c_str()).get_basename() + ".mdatr";
|
||||
if (FileAccess::file_exists(md)) {
|
||||
const std::string md_str(md.utf8().get_data());
|
||||
if (collision_enabled) {
|
||||
const int built = add_attribute_data(md_str, place, root, mi, &out_heights);
|
||||
mdatr_built = built > 0;
|
||||
if (mdatr_built)
|
||||
++objects_mdatr_built;
|
||||
} else {
|
||||
load_attribute_heights(md_str, place, out_heights);
|
||||
}
|
||||
}
|
||||
// A malformed/missing attribute resource still gets a conservative
|
||||
@@ -880,11 +953,52 @@ double Metin2World::sample_height(double gx_m, double gz_m) const {
|
||||
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;
|
||||
double terrain_h = 0.0;
|
||||
if (c && c->hm) {
|
||||
const double lx = mx_cm - double(tx) * fmt::m2coord::CHUNK_CM;
|
||||
const double ly = my_abs_cm - double(ty) * fmt::m2coord::CHUNK_CM;
|
||||
terrain_h = fmt::terrain_height_at(*c->hm, lx, ly, setting.height_scale) * fmt::m2coord::CM_TO_M;
|
||||
}
|
||||
|
||||
// 40250 CMapOutdoor::GetHeight(fx, fy):
|
||||
// Check placed objects' height data (bridges, stairs, platforms from .mdatr).
|
||||
// If object height > terrain height, return fMAX(fObjectHeight, fTerrainHeight).
|
||||
float obj_h = -25000.0f;
|
||||
bool obj_found = false;
|
||||
|
||||
const float fgx = float(gx_m);
|
||||
const float fgz = float(gz_m);
|
||||
|
||||
for (int dtx = -1; dtx <= 1; ++dtx) {
|
||||
int ntx = tx + dtx;
|
||||
if (ntx < 0 || ntx >= setting.map_size_x)
|
||||
continue;
|
||||
for (int dty = -1; dty <= 1; ++dty) {
|
||||
int nty = ty + dty;
|
||||
if (nty < 0 || nty >= setting.map_size_y)
|
||||
continue;
|
||||
const Chunk *nc = chunk_at(ntx, nty);
|
||||
if (!nc || nc->height_triangles.empty())
|
||||
continue;
|
||||
for (const auto &tri : nc->height_triangles) {
|
||||
if (fgx < tri.min_x || fgx > tri.max_x ||
|
||||
fgz < tri.min_z || fgz > tri.max_z)
|
||||
continue;
|
||||
if (is_in_triangle_2d(tri.v0.x, tri.v0.z, tri.v1.x, tri.v1.z, tri.v2.x, tri.v2.z, fgx, fgz)) {
|
||||
float h = (tri.d - tri.normal.x * fgx - tri.normal.z * fgz) / tri.normal.y;
|
||||
if (!obj_found || h > obj_h) {
|
||||
obj_h = h;
|
||||
obj_found = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (obj_found && (c == nullptr || !c->hm || obj_h > terrain_h))
|
||||
return double(obj_h);
|
||||
|
||||
return terrain_h;
|
||||
}
|
||||
|
||||
Ref<godot::Image> Metin2World::load_dds(const String &path) const {
|
||||
|
||||
@@ -37,6 +37,16 @@ class Metin2World : public godot::Node3D {
|
||||
GDCLASS(Metin2World, godot::Node3D)
|
||||
|
||||
public:
|
||||
struct HeightTriangle {
|
||||
godot::Vector3 v0;
|
||||
godot::Vector3 v1;
|
||||
godot::Vector3 v2;
|
||||
float min_x = 0.0f, max_x = 0.0f;
|
||||
float min_z = 0.0f, max_z = 0.0f;
|
||||
godot::Vector3 normal;
|
||||
float d = 0.0f;
|
||||
};
|
||||
|
||||
Metin2World();
|
||||
~Metin2World() override;
|
||||
|
||||
@@ -154,6 +164,7 @@ private:
|
||||
std::shared_ptr<fmt::AttrMap> am;
|
||||
godot::Node3D *root = nullptr; // 该区块的全部场景节点(terrain + water + 对象 + 树)
|
||||
int objects = 0, trees = 0;
|
||||
std::vector<HeightTriangle> height_triangles;
|
||||
};
|
||||
struct AmbienceSource {
|
||||
int tile_x = 0, tile_y = 0;
|
||||
@@ -173,7 +184,8 @@ private:
|
||||
|
||||
godot::String map_dir() const;
|
||||
bool build_chunk(int tx, int ty);
|
||||
void place_chunk_objects(int tx, int ty, godot::Node3D *root, int &n_obj, int &n_tree);
|
||||
void place_chunk_objects(int tx, int ty, godot::Node3D *root, int &n_obj, int &n_tree,
|
||||
std::vector<HeightTriangle> &out_heights);
|
||||
void place_chunk_ambience(int tx, int ty);
|
||||
void unload_chunk(int idx);
|
||||
void stream_update();
|
||||
|
||||
@@ -7,6 +7,25 @@
|
||||
|
||||
namespace mtnet::classic {
|
||||
|
||||
namespace {
|
||||
uint8_t normalize_wire_ch_type(uint8_t type) {
|
||||
switch (type) {
|
||||
case WIRE_CHRTYPE_MONSTER: return CHRTYPE_MONSTER;
|
||||
case WIRE_CHRTYPE_NPC: return CHRTYPE_NPC;
|
||||
case WIRE_CHRTYPE_STONE: return CHRTYPE_STONE;
|
||||
case WIRE_CHRTYPE_WARP: return CHRTYPE_WARP;
|
||||
case WIRE_CHRTYPE_DOOR: return CHRTYPE_DOOR;
|
||||
case WIRE_CHRTYPE_BUILDING: return CHRTYPE_BUILDING;
|
||||
case WIRE_CHRTYPE_PC:
|
||||
case WIRE_CHRTYPE_POLYMORPH_PC:
|
||||
return CHRTYPE_PC;
|
||||
case WIRE_CHRTYPE_HORSE: return CHRTYPE_HORSE;
|
||||
case WIRE_CHRTYPE_GOTO: return CHRTYPE_GOTO;
|
||||
default: return type;
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void ClassicParser::set_npclist_path(std::string path) {
|
||||
if (path == m_npclist_path) {
|
||||
return;
|
||||
@@ -191,9 +210,11 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
|
||||
if (!fill(p, header, body, len)) {
|
||||
return false;
|
||||
}
|
||||
m_empire = p.empire;
|
||||
m_world.mut_spawn_main(p.vid, p.race,
|
||||
std::string(p.name, strnlen(p.name, sizeof(p.name))), (float)p.x, (float)p.y,
|
||||
(float)p.z);
|
||||
m_world.mut_skill_group(p.skill_group);
|
||||
return true;
|
||||
}
|
||||
case HDR_GC_MAIN_CHARACTER3_BGM: {
|
||||
@@ -201,9 +222,11 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
|
||||
if (!fill(p, header, body, len)) {
|
||||
return false;
|
||||
}
|
||||
m_empire = p.empire;
|
||||
m_world.mut_spawn_main(p.vid, p.race,
|
||||
std::string(p.name, strnlen(p.name, sizeof(p.name))), (float)p.x, (float)p.y,
|
||||
(float)p.z);
|
||||
m_world.mut_skill_group(p.skill_group);
|
||||
// §9.1: route the map's background-music track; volume unspecified.
|
||||
m_world.mut_map_bgm(
|
||||
std::string(p.bgm_name, strnlen(p.bgm_name, sizeof(p.bgm_name))), -1.0f);
|
||||
@@ -214,9 +237,11 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
|
||||
if (!fill(p, header, body, len)) {
|
||||
return false;
|
||||
}
|
||||
m_empire = p.empire;
|
||||
m_world.mut_spawn_main(p.vid, p.race,
|
||||
std::string(p.name, strnlen(p.name, sizeof(p.name))), (float)p.x, (float)p.y,
|
||||
(float)p.z);
|
||||
m_world.mut_skill_group(p.skill_group);
|
||||
// §9.1: track + server-authored volume (0..1).
|
||||
m_world.mut_map_bgm(
|
||||
std::string(p.bgm_name, strnlen(p.bgm_name, sizeof(p.bgm_name))),
|
||||
@@ -233,7 +258,8 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
|
||||
if (is_invisible_race(p.race)) {
|
||||
return true;
|
||||
}
|
||||
if (p.type == CHRTYPE_PC || p.type == CHRTYPE_NPC) {
|
||||
const uint8_t ch_type = normalize_wire_ch_type(p.type);
|
||||
if (ch_type == CHRTYPE_PC || ch_type == CHRTYPE_NPC) {
|
||||
// Two-packet actor: GC_CHARACTER_ADD carries no name / guild /
|
||||
// parts, so stash a bare record and wait for
|
||||
// GC_CHAR_ADDITIONAL_INFO to merge + flush it. The world model
|
||||
@@ -242,7 +268,7 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
|
||||
e = Entity{};
|
||||
e.vid = p.vid;
|
||||
e.race = p.race;
|
||||
e.ch_type = p.type;
|
||||
e.ch_type = ch_type;
|
||||
e.x = (float)p.x;
|
||||
e.y = (float)p.y;
|
||||
e.z = (float)p.z;
|
||||
@@ -257,7 +283,7 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
|
||||
// Monster / stone / door / object: appended immediately, name from
|
||||
// npclist.txt (race -> code, like CPythonNonPlayer::GetName); every
|
||||
// PC-specific field stays 0.
|
||||
m_world.mut_spawn(p.vid, p.race, p.type, npc_name_for(p.race), (float)p.x, (float)p.y,
|
||||
m_world.mut_spawn(p.vid, p.race, ch_type, npc_name_for(p.race), (float)p.x, (float)p.y,
|
||||
(float)p.z, p.angle, p.moving_speed, p.attack_speed);
|
||||
return true;
|
||||
}
|
||||
@@ -266,7 +292,7 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
|
||||
if (!fill(p, header, body, len)) {
|
||||
return false;
|
||||
}
|
||||
m_world.mut_spawn(p.vid, p.race, p.type,
|
||||
m_world.mut_spawn(p.vid, p.race, normalize_wire_ch_type(p.type),
|
||||
std::string(p.name, strnlen(p.name, sizeof(p.name))), (float)p.x, (float)p.y,
|
||||
(float)p.z, p.angle, p.moving_speed, p.attack_speed);
|
||||
m_world.mut_char_info(p.vid, {}, p.parts, p.empire, (int32_t)p.guild, 0,
|
||||
@@ -908,7 +934,19 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
|
||||
std::memcpy(&si, cur, sizeof(si));
|
||||
cur += sizeof(si);
|
||||
if (si.vnum != 0) {
|
||||
tab.items.push_back({si.vnum, si.price, si.count, si.display_pos});
|
||||
ShopEntry entry;
|
||||
entry.vnum = si.vnum;
|
||||
entry.price = si.price;
|
||||
entry.count = si.count;
|
||||
entry.pos = (uint8_t)j;
|
||||
for (int k = 0; k < ITEM_SOCKET_SLOT_MAX_NUM && k < ITEM_SOCKET_MAX_NUM; ++k) {
|
||||
entry.sockets[k] = si.sockets[k];
|
||||
}
|
||||
for (int k = 0; k < ITEM_ATTRIBUTE_SLOT_MAX_NUM && k < ITEM_ATTRIBUTE_MAX_NUM; ++k) {
|
||||
entry.attrs[k].type = si.attrs[k].type;
|
||||
entry.attrs[k].value = si.attrs[k].value;
|
||||
}
|
||||
tab.items.push_back(std::move(entry));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -973,8 +1011,13 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
|
||||
}
|
||||
ShopUpdateItem update;
|
||||
std::memcpy(&update, cur, sizeof(update));
|
||||
ItemAttr attrs[ITEM_ATTRIBUTE_SLOT_MAX_NUM] = {};
|
||||
for (int k = 0; k < ITEM_ATTRIBUTE_SLOT_MAX_NUM && k < ITEM_ATTRIBUTE_MAX_NUM; ++k) {
|
||||
attrs[k].type = update.item.attrs[k].type;
|
||||
attrs[k].value = update.item.attrs[k].value;
|
||||
}
|
||||
m_world.mut_shop_update_item(update.pos, update.item.vnum, update.item.price,
|
||||
update.item.count);
|
||||
update.item.count, update.item.sockets, attrs);
|
||||
return true;
|
||||
}
|
||||
case SHOP_SUB_UPDATE_PRICE: {
|
||||
|
||||
@@ -57,8 +57,10 @@ inline constexpr int GUILD_MARK_WIDTH = 16;
|
||||
inline constexpr int GUILD_MARK_HEIGHT = 12;
|
||||
inline constexpr int MARK_BLOCK_TOTAL_COUNT = 80;
|
||||
|
||||
// CHRTYPE_* — the domain of Entity::ch_type and packet_add_char.bType
|
||||
// (client GameType.h / server char.h). Values line up with the Entity comment.
|
||||
// CHRTYPE_* is the protocol-neutral Entity::ch_type domain used by the Godot
|
||||
// presentation layer. Do not put packet_add_char.bType into Entity verbatim:
|
||||
// the 40250 server's ECharType wire order is MONSTER=0, NPC=1, ... PC=6,
|
||||
// whereas the presentation layer historically uses PC=0, NPC=1, MONSTER=2.
|
||||
enum : uint8_t {
|
||||
CHRTYPE_PC = 0,
|
||||
CHRTYPE_NPC = 1,
|
||||
@@ -72,6 +74,22 @@ enum : uint8_t {
|
||||
CHRTYPE_GOTO = 9,
|
||||
};
|
||||
|
||||
// Exact 40250 server/common/length.h ECharType values carried on the classic
|
||||
// packet wire. Keep these names separate from CHRTYPE_* so tests cannot
|
||||
// accidentally manufacture a packet with the normalized presentation value.
|
||||
enum : uint8_t {
|
||||
WIRE_CHRTYPE_MONSTER = 0,
|
||||
WIRE_CHRTYPE_NPC = 1,
|
||||
WIRE_CHRTYPE_STONE = 2,
|
||||
WIRE_CHRTYPE_WARP = 3,
|
||||
WIRE_CHRTYPE_DOOR = 4,
|
||||
WIRE_CHRTYPE_BUILDING = 5,
|
||||
WIRE_CHRTYPE_PC = 6,
|
||||
WIRE_CHRTYPE_POLYMORPH_PC = 7,
|
||||
WIRE_CHRTYPE_HORSE = 8,
|
||||
WIRE_CHRTYPE_GOTO = 9,
|
||||
};
|
||||
|
||||
// Races the reference client never spawns — REF/UserInterface/
|
||||
// PythonNetworkStreamPhaseGameActor.cpp:66 IsInvisibleRace(). GC_CHARACTER_ADD
|
||||
// for one of these is dropped before any pending/stash step.
|
||||
|
||||
@@ -63,6 +63,9 @@ Entity &EntityStore::touch(uint32_t vid, bool &created) {
|
||||
if (created) {
|
||||
Entity e;
|
||||
e.vid = vid;
|
||||
if (vid == m_main_vid) {
|
||||
e.is_main = true;
|
||||
}
|
||||
it = m_ents.emplace(vid, e).first;
|
||||
}
|
||||
return it->second;
|
||||
@@ -113,6 +116,7 @@ void EntityStore::mut_spawn_main(uint32_t vid, uint16_t race, const std::string
|
||||
e.z = z;
|
||||
e.is_main = true;
|
||||
e.moving = false;
|
||||
e.state_queue.clear();
|
||||
m_main_vid = vid;
|
||||
m_changes.push_back({created ? ChangeKind::Spawn : ChangeKind::MainSet, vid});
|
||||
if (!created) {
|
||||
@@ -140,6 +144,10 @@ void EntityStore::mut_spawn_full(const Entity &src) {
|
||||
if (e.vid == m_main_vid) {
|
||||
e.is_main = true;
|
||||
}
|
||||
if (e.is_main) {
|
||||
e.moving = false;
|
||||
e.state_queue.clear();
|
||||
}
|
||||
m_ents[src.vid] = e;
|
||||
m_changes.push_back({ChangeKind::Spawn, src.vid});
|
||||
if (e.is_main) {
|
||||
@@ -210,12 +218,26 @@ void EntityStore::mut_despawn(uint32_t vid) {
|
||||
// past (or 0) still applies synchronously.
|
||||
void EntityStore::mut_move(uint32_t vid, float angle_deg, uint8_t func, float tx, float ty,
|
||||
uint32_t dur_ms, uint8_t arg, uint32_t chk_time) {
|
||||
if (vid == m_main_vid) {
|
||||
return;
|
||||
}
|
||||
auto it = m_ents.find(vid);
|
||||
if (it == m_ents.end()) {
|
||||
if (it == m_ents.end() || it->second.is_main) {
|
||||
return;
|
||||
}
|
||||
Entity &e = it->second;
|
||||
e.state_queue.push_back({chk_time, tx, ty, angle_deg, func, arg, dur_ms});
|
||||
// 40250 CInstanceBase::PushTCPState EMA network latency smoothing:
|
||||
uint32_t scheduled_time = chk_time;
|
||||
if (m_server_frame_ms != 0 && chk_time != 0) {
|
||||
int32_t nNetworkGap = (int32_t)m_server_frame_ms - (int32_t)chk_time;
|
||||
if (e.average_network_gap == 0) {
|
||||
e.average_network_gap = nNetworkGap;
|
||||
} else {
|
||||
e.average_network_gap = (e.average_network_gap * 70 + nNetworkGap * 30) / 100;
|
||||
}
|
||||
scheduled_time = chk_time + (uint32_t)std::max(0, e.average_network_gap);
|
||||
}
|
||||
e.state_queue.push_back({scheduled_time, tx, ty, angle_deg, func, arg, dur_ms});
|
||||
drain_state_queue(e);
|
||||
m_changes.push_back({ChangeKind::Move, vid});
|
||||
}
|
||||
@@ -351,6 +373,9 @@ void EntityStore::drain_state_queue(Entity &e) {
|
||||
|
||||
void EntityStore::process_states() {
|
||||
for (auto &kv : m_ents) {
|
||||
if (kv.second.is_main) {
|
||||
continue;
|
||||
}
|
||||
drain_state_queue(kv.second);
|
||||
}
|
||||
}
|
||||
@@ -412,7 +437,14 @@ void EntityStore::mut_set_point(uint8_t type, int32_t value, uint32_t vid) {
|
||||
}
|
||||
Entity &e = it->second;
|
||||
switch (type) {
|
||||
case POINT_HP: e.hp = value; break;
|
||||
case POINT_HP:
|
||||
e.hp = value;
|
||||
if (e.hp <= 0) {
|
||||
e.dead = true;
|
||||
e.moving = false;
|
||||
e.func = FUNC_WAIT;
|
||||
}
|
||||
break;
|
||||
case POINT_MAX_HP: e.max_hp = value; break;
|
||||
case POINT_SP: e.sp = value; break;
|
||||
case POINT_MAX_SP: e.max_sp = value; break;
|
||||
@@ -859,7 +891,8 @@ void EntityStore::mut_shop_open(uint32_t vid, const std::vector<ShopTab> &tabs)
|
||||
m_shop_dirty = true;
|
||||
}
|
||||
|
||||
void EntityStore::mut_shop_update_item(uint8_t pos, uint32_t vnum, uint32_t price, uint8_t count) {
|
||||
void EntityStore::mut_shop_update_item(uint8_t pos, uint32_t vnum, uint32_t price, uint8_t count,
|
||||
const int32_t *sockets, const ItemAttr *attrs) {
|
||||
if (!m_shop_open || pos >= SHOP_HOST_ITEM_MAX_NUM) {
|
||||
return;
|
||||
}
|
||||
@@ -871,18 +904,47 @@ void EntityStore::mut_shop_update_item(uint8_t pos, uint32_t vnum, uint32_t pric
|
||||
if (vnum == 0) {
|
||||
items.erase(items.begin() + (long)i);
|
||||
} else {
|
||||
items[i] = {vnum, price, count, pos};
|
||||
items[i].vnum = vnum;
|
||||
items[i].price = price;
|
||||
items[i].count = count;
|
||||
items[i].pos = pos;
|
||||
if (sockets) {
|
||||
for (int k = 0; k < ITEM_SOCKET_SLOT_MAX_NUM; ++k) {
|
||||
items[i].sockets[k] = sockets[k];
|
||||
}
|
||||
}
|
||||
if (attrs) {
|
||||
for (int k = 0; k < ITEM_ATTRIBUTE_SLOT_MAX_NUM; ++k) {
|
||||
items[i].attrs[k] = attrs[k];
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (vnum != 0) {
|
||||
items.push_back({vnum, price, count, pos});
|
||||
ShopEntry entry;
|
||||
entry.vnum = vnum;
|
||||
entry.price = price;
|
||||
entry.count = count;
|
||||
entry.pos = pos;
|
||||
if (sockets) {
|
||||
for (int k = 0; k < ITEM_SOCKET_SLOT_MAX_NUM; ++k) {
|
||||
entry.sockets[k] = sockets[k];
|
||||
}
|
||||
}
|
||||
if (attrs) {
|
||||
for (int k = 0; k < ITEM_ATTRIBUTE_SLOT_MAX_NUM; ++k) {
|
||||
entry.attrs[k] = attrs[k];
|
||||
}
|
||||
}
|
||||
items.push_back(std::move(entry));
|
||||
}
|
||||
};
|
||||
update(m_shop_items);
|
||||
if (!m_shop_tabs.empty()) {
|
||||
update(m_shop_tabs[0].items);
|
||||
}
|
||||
m_shop_slot_updates.push_back(pos);
|
||||
m_shop_dirty = true;
|
||||
}
|
||||
|
||||
@@ -896,6 +958,7 @@ void EntityStore::mut_shop_close() {
|
||||
m_shop_vid = 0;
|
||||
m_shop_items.clear();
|
||||
m_shop_tabs.clear();
|
||||
m_shop_slot_updates.clear();
|
||||
m_shop_selling_price = 0;
|
||||
m_shop_dirty = true;
|
||||
}
|
||||
@@ -1581,6 +1644,7 @@ void EntityStore::apply(uint16_t header, const void *body, uint16_t len) {
|
||||
e.z = (float)p.z;
|
||||
e.is_main = true;
|
||||
e.moving = false;
|
||||
e.state_queue.clear();
|
||||
m_main_vid = p.vid;
|
||||
m_changes.push_back({created ? ChangeKind::Spawn : ChangeKind::MainSet, p.vid});
|
||||
if (!created) {
|
||||
@@ -1596,6 +1660,11 @@ void EntityStore::apply(uint16_t header, const void *body, uint16_t len) {
|
||||
std::memcpy(&p, body, sizeof(p));
|
||||
bool created;
|
||||
Entity &e = touch(p.vid, created);
|
||||
if (p.vid == m_main_vid) {
|
||||
e.is_main = true;
|
||||
e.moving = false;
|
||||
e.state_queue.clear();
|
||||
}
|
||||
e.race = p.race;
|
||||
e.ch_type = p.type;
|
||||
e.angle = p.angle;
|
||||
@@ -1616,6 +1685,11 @@ void EntityStore::apply(uint16_t header, const void *body, uint16_t len) {
|
||||
std::memcpy(&p, body, sizeof(p));
|
||||
bool created;
|
||||
Entity &e = touch(p.vid, created);
|
||||
if (p.vid == m_main_vid) {
|
||||
e.is_main = true;
|
||||
e.moving = false;
|
||||
e.state_queue.clear();
|
||||
}
|
||||
e.race = p.race;
|
||||
e.ch_type = p.type;
|
||||
e.angle = p.angle;
|
||||
@@ -1701,9 +1775,12 @@ void EntityStore::apply(uint16_t header, const void *body, uint16_t len) {
|
||||
}
|
||||
GCMove p;
|
||||
std::memcpy(&p, body, sizeof(p));
|
||||
if (p.vid == m_main_vid) {
|
||||
return;
|
||||
}
|
||||
// CLIENT-GAP §3.2: same TCP state queue as the classic backend.
|
||||
// m2dev heading is rot*360/256.
|
||||
mut_move(p.vid, (float)p.rot * (360.0f / 256.0f), p.func, (float)p.x, (float)p.y,
|
||||
// Server sends (int)GetRotation() / 5 (char.cpp SendMovePacket), client decodes rot * 5.0f.
|
||||
mut_move(p.vid, (float)p.rot * 5.0f, p.func, (float)p.x, (float)p.y,
|
||||
p.duration, p.arg, p.time);
|
||||
return;
|
||||
}
|
||||
@@ -2056,7 +2133,14 @@ void EntityStore::apply(uint16_t header, const void *body, uint16_t len) {
|
||||
if (it != m_ents.end()) {
|
||||
Entity &e = it->second;
|
||||
switch (p.type) {
|
||||
case POINT_HP: e.hp = p.value; break;
|
||||
case POINT_HP:
|
||||
e.hp = p.value;
|
||||
if (e.hp <= 0) {
|
||||
e.dead = true;
|
||||
e.moving = false;
|
||||
e.func = FUNC_WAIT;
|
||||
}
|
||||
break;
|
||||
case POINT_MAX_HP: e.max_hp = p.value; break;
|
||||
case POINT_SP: e.sp = p.value; break;
|
||||
case POINT_MAX_SP: e.max_sp = p.value; break;
|
||||
@@ -3578,7 +3662,7 @@ void EntityStore::tick() {
|
||||
m_ticked = true;
|
||||
for (auto &kv : m_ents) {
|
||||
Entity &e = kv.second;
|
||||
if (!e.moving) {
|
||||
if (!e.moving || e.is_main) {
|
||||
continue;
|
||||
}
|
||||
const float speed = motion_move_speed(e);
|
||||
@@ -3596,8 +3680,12 @@ void EntityStore::set_motion_speed(uint32_t vid, float walk_cm_s, float run_cm_s
|
||||
dbg_ignored("set_motion_speed", vid);
|
||||
return;
|
||||
}
|
||||
it->second.walk_motion_speed = walk_cm_s > 0.0f ? walk_cm_s : 0.0f;
|
||||
it->second.run_motion_speed = run_cm_s > 0.0f ? run_cm_s : 0.0f;
|
||||
float w = walk_cm_s > 0.0f ? walk_cm_s : 0.0f;
|
||||
float r = run_cm_s > 0.0f ? run_cm_s : 0.0f;
|
||||
if (w <= 0.0f && r > 0.0f) w = r;
|
||||
if (r <= 0.0f && w > 0.0f) r = w;
|
||||
it->second.walk_motion_speed = w;
|
||||
it->second.run_motion_speed = r;
|
||||
}
|
||||
|
||||
// Reference CActorInstance::Move(): SetLoopMotion(m_isWalking ? WALK : RUN, 0.15,
|
||||
@@ -3608,7 +3696,10 @@ void EntityStore::set_motion_speed(uint32_t vid, float walk_cm_s, float run_cm_s
|
||||
// the on-foot motions, so a mounted actor (horse motion set not measured) also
|
||||
// uses the lerp.
|
||||
float EntityStore::motion_move_speed(const Entity &e) {
|
||||
const float base = e.walk_mode == WALKMODE_WALK ? e.walk_motion_speed : e.run_motion_speed;
|
||||
float base = e.walk_mode == WALKMODE_WALK ? e.walk_motion_speed : e.run_motion_speed;
|
||||
if (base <= 0.0f) {
|
||||
base = e.walk_mode == WALKMODE_WALK ? e.run_motion_speed : e.walk_motion_speed;
|
||||
}
|
||||
if (base <= 0.0f || e.moving_speed > 1100 || e.mount_vnum != 0) {
|
||||
return 0.0f;
|
||||
}
|
||||
@@ -3655,31 +3746,47 @@ void EntityStore::advance_walk_by_motion(Entity &e, float speed, uint32_t dt_ms)
|
||||
const float cur_len = std::sqrt(cx * cx + cy * cy);
|
||||
const float next_len = std::sqrt(nx * nx + ny * ny);
|
||||
|
||||
if (total - cur_len < -MOVE_OVERSHOOT_CM) {
|
||||
// latency overran Dst: re-source here, turn back toward Dst, and stop at
|
||||
// the next arrival instead of carrying on with FUNC_MOVE.
|
||||
e.sx = e.x;
|
||||
e.sy = e.y;
|
||||
e.angle = heading_deg(e.tx - e.x, e.ty - e.y);
|
||||
if (e.mov_after_func == FUNC_MOVE) {
|
||||
e.mov_after_func = FUNC_WAIT;
|
||||
}
|
||||
} else if (cur_len <= total && total <= next_len) {
|
||||
if (e.dead || e.knock_down) {
|
||||
e.moving = false;
|
||||
e.skip_collision = false;
|
||||
if (e.ch_type != 0) {
|
||||
// Non-PC (monsters/NPCs): stop cleanly upon reaching Dst, snap to (tx, ty),
|
||||
// and never turn 180° back-and-forth across latency/overshoot.
|
||||
if ((cur_len <= total && total <= next_len) || (total - cur_len < 0.0f)) {
|
||||
if (e.dead || e.knock_down) {
|
||||
e.moving = false;
|
||||
e.skip_collision = false;
|
||||
return;
|
||||
}
|
||||
e.x = e.tx;
|
||||
e.y = e.ty;
|
||||
finish_walk(e, true);
|
||||
return;
|
||||
}
|
||||
if (e.mov_after_func == FUNC_MOVE) {
|
||||
// FUNC_MOVE: keep walking past Dst until the next state command (or
|
||||
// the overshoot guard above) — this is what keeps a moving remote PC
|
||||
// from stop-starting between its periodic move packets.
|
||||
e.x = next_x;
|
||||
e.y = next_y;
|
||||
} else {
|
||||
if (total - cur_len < -MOVE_OVERSHOOT_CM) {
|
||||
// latency overran Dst: re-source here, turn back toward Dst, and stop at
|
||||
// the next arrival instead of carrying on with FUNC_MOVE.
|
||||
e.sx = e.x;
|
||||
e.sy = e.y;
|
||||
e.angle = heading_deg(e.tx - e.x, e.ty - e.y);
|
||||
if (e.mov_after_func == FUNC_MOVE) {
|
||||
e.mov_after_func = FUNC_WAIT;
|
||||
}
|
||||
} else if (cur_len <= total && total <= next_len) {
|
||||
if (e.dead || e.knock_down) {
|
||||
e.moving = false;
|
||||
e.skip_collision = false;
|
||||
return;
|
||||
}
|
||||
if (e.mov_after_func == FUNC_MOVE) {
|
||||
// FUNC_MOVE: keep walking past Dst until the next state command (or
|
||||
// the overshoot guard above) — this is what keeps a moving remote PC
|
||||
// from stop-starting between its periodic move packets.
|
||||
e.x = next_x;
|
||||
e.y = next_y;
|
||||
return;
|
||||
}
|
||||
finish_walk(e, true);
|
||||
return;
|
||||
}
|
||||
finish_walk(e, true);
|
||||
return;
|
||||
}
|
||||
e.x = next_x;
|
||||
e.y = next_y;
|
||||
|
||||
@@ -68,7 +68,7 @@ static constexpr uint32_t MOVE_MAX_TICK_MS = 250;
|
||||
struct Entity {
|
||||
uint32_t vid = 0;
|
||||
uint16_t race = 0;
|
||||
uint8_t ch_type = 0; // CHRTYPE: 0 PC, 1 NPC, 2 MONSTER, 3 STONE, 4 WARP, ...
|
||||
uint8_t ch_type = 0; // normalized CHRTYPE: 0 PC, 1 NPC, 2 MONSTER, 3 STONE, ...
|
||||
std::string name;
|
||||
std::string shop_sign; // GC_SHOP_SIGN; empty when the private shop is closed
|
||||
uint16_t parts[CHR_EQUIPPART_NUM] = {0, 0, 0, 0};
|
||||
@@ -102,6 +102,7 @@ struct Entity {
|
||||
// __EnableSkipCollision(): a walk kicked off by a network state packet ignores
|
||||
// actor-vs-actor collision until it arrives.
|
||||
std::deque<StateCmd> state_queue;
|
||||
int32_t average_network_gap = 0; // 40250 CInstanceBase::m_nAverageNetworkGap EMA filter
|
||||
uint8_t mov_after_func = FUNC_WAIT;
|
||||
uint8_t mov_after_arg = 0;
|
||||
float mov_after_rot = 0;
|
||||
@@ -305,6 +306,8 @@ struct ShopEntry {
|
||||
uint32_t price = 0;
|
||||
uint8_t count = 0;
|
||||
uint8_t pos = 0; // slot index within its tab (0..SHOP_HOST_ITEM_MAX_NUM-1)
|
||||
int32_t sockets[ITEM_SOCKET_SLOT_MAX_NUM] = {0, 0, 0};
|
||||
ItemAttr attrs[ITEM_ATTRIBUTE_SLOT_MAX_NUM] = {};
|
||||
};
|
||||
|
||||
// One shelf/tab of a SHOP_GC_START_EX shop. A plain SHOP_GC_START shop is
|
||||
@@ -653,7 +656,8 @@ public:
|
||||
|
||||
// --- NPC shop (GC_SHOP) --- a single-shelf shop is one unnamed tab.
|
||||
void mut_shop_open(uint32_t vid, const std::vector<ShopTab> &tabs);
|
||||
void mut_shop_update_item(uint8_t pos, uint32_t vnum, uint32_t price, uint8_t count);
|
||||
void mut_shop_update_item(uint8_t pos, uint32_t vnum, uint32_t price, uint8_t count,
|
||||
const int32_t *sockets = nullptr, const ItemAttr *attrs = nullptr);
|
||||
void mut_shop_update_price(int32_t price);
|
||||
void mut_shop_close();
|
||||
void mut_shop_error(const std::string &code);
|
||||
@@ -858,6 +862,11 @@ public:
|
||||
m_shop_errors.clear();
|
||||
return v;
|
||||
}
|
||||
std::vector<uint8_t> drain_shop_slot_updates() {
|
||||
auto v = std::move(m_shop_slot_updates);
|
||||
m_shop_slot_updates.clear();
|
||||
return v;
|
||||
}
|
||||
|
||||
// --- P8 exchange ---
|
||||
const ExchangeState &exchange() const { return m_exchange; }
|
||||
@@ -1293,6 +1302,7 @@ private:
|
||||
bool m_shop_dirty = false;
|
||||
int32_t m_shop_selling_price = 0;
|
||||
std::vector<std::string> m_shop_errors;
|
||||
std::vector<uint8_t> m_shop_slot_updates;
|
||||
ExchangeState m_exchange;
|
||||
bool m_exchange_dirty = false;
|
||||
Item m_safebox[SAFEBOX_MAX_NUM];
|
||||
|
||||
@@ -284,12 +284,16 @@ void M2Client::_bind_methods() {
|
||||
ADD_SIGNAL(MethodInfo("friend_invite_ask", PropertyInfo(Variant::STRING, "name")));
|
||||
ADD_SIGNAL(MethodInfo("shop_opened", PropertyInfo(Variant::INT, "vid")));
|
||||
ADD_SIGNAL(MethodInfo("shop_closed"));
|
||||
ADD_SIGNAL(MethodInfo("shop_updated", PropertyInfo(Variant::INT, "pos")));
|
||||
ADD_SIGNAL(MethodInfo("shop_error", PropertyInfo(Variant::STRING, "kind")));
|
||||
ADD_SIGNAL(MethodInfo("exchange_changed"));
|
||||
ADD_SIGNAL(MethodInfo("safebox_opened", PropertyInfo(Variant::INT, "size")));
|
||||
ADD_SIGNAL(MethodInfo("safebox_closed"));
|
||||
ADD_SIGNAL(MethodInfo("safebox_changed"));
|
||||
ADD_SIGNAL(MethodInfo("safebox_password_required"));
|
||||
ADD_SIGNAL(MethodInfo("safebox_wrong_password"));
|
||||
ADD_SIGNAL(MethodInfo("mall_opened", PropertyInfo(Variant::INT, "size")));
|
||||
ADD_SIGNAL(MethodInfo("mall_closed"));
|
||||
ADD_SIGNAL(MethodInfo("mall_changed"));
|
||||
ADD_SIGNAL(MethodInfo("mall_password_required"));
|
||||
ADD_SIGNAL(MethodInfo("private_shop_open_requested"));
|
||||
@@ -530,6 +534,7 @@ void M2Client::connect_to_server(const String &auth_host, int auth_port, const S
|
||||
classic_last_empire = -1;
|
||||
shop_open_seen = false;
|
||||
mall_open_seen = false;
|
||||
safebox_open_seen = false;
|
||||
last_game_phase = -1;
|
||||
if (!classic_sess->connect(std::string(auth_host.utf8().get_data()), (uint16_t)auth_port,
|
||||
std::string(g_host.utf8().get_data()), (uint16_t)g_port,
|
||||
@@ -580,6 +585,7 @@ void M2Client::disconnect_from_server() {
|
||||
}
|
||||
shop_open_seen = false;
|
||||
mall_open_seen = false;
|
||||
safebox_open_seen = false;
|
||||
selected_pid = 0;
|
||||
if (stage != Stage::Idle) {
|
||||
set_stage(Stage::Idle);
|
||||
@@ -1277,6 +1283,19 @@ static Dictionary shop_entry_dict(const mtnet::ShopEntry &s) {
|
||||
d["vnum"] = (int)s.vnum;
|
||||
d["price"] = (int)s.price;
|
||||
d["count"] = (int)s.count;
|
||||
Array sockets;
|
||||
for (int k = 0; k < mtnet::ITEM_SOCKET_SLOT_MAX_NUM; ++k) {
|
||||
sockets.push_back(s.sockets[k]);
|
||||
}
|
||||
d["sockets"] = sockets;
|
||||
Array attrs;
|
||||
for (int k = 0; k < mtnet::ITEM_ATTRIBUTE_SLOT_MAX_NUM; ++k) {
|
||||
Dictionary ad;
|
||||
ad["type"] = (int)s.attrs[k].type;
|
||||
ad["value"] = (int)s.attrs[k].value;
|
||||
attrs.push_back(ad);
|
||||
}
|
||||
d["attrs"] = attrs;
|
||||
return d;
|
||||
}
|
||||
|
||||
@@ -1466,6 +1485,31 @@ int M2Client::get_safebox_gold() const {
|
||||
const mtnet::EntityStore *world = active_world();
|
||||
return world ? (int)world->safebox_gold() : 0;
|
||||
}
|
||||
static Dictionary item_dict(const mtnet::Item &it) {
|
||||
Dictionary d;
|
||||
d["vnum"] = (int)it.vnum;
|
||||
d["count"] = (int)it.count;
|
||||
d["flags"] = (int)it.flags;
|
||||
d["anti_flags"] = (int)it.anti_flags;
|
||||
Array sockets;
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
sockets.push_back(it.sockets[i]);
|
||||
}
|
||||
d["sockets"] = sockets;
|
||||
Array attrs;
|
||||
for (int i = 0; i < 7; ++i) {
|
||||
if (it.attrs[i].type == 0) {
|
||||
continue;
|
||||
}
|
||||
Dictionary a;
|
||||
a["type"] = (int)it.attrs[i].type;
|
||||
a["value"] = (int)it.attrs[i].value;
|
||||
attrs.push_back(a);
|
||||
}
|
||||
d["attrs"] = attrs;
|
||||
return d;
|
||||
}
|
||||
|
||||
Array M2Client::get_safebox_items() const {
|
||||
Array out;
|
||||
const mtnet::EntityStore *world = active_world();
|
||||
@@ -1477,10 +1521,8 @@ Array M2Client::get_safebox_items() const {
|
||||
if (it.empty()) {
|
||||
continue;
|
||||
}
|
||||
Dictionary d;
|
||||
Dictionary d = item_dict(it);
|
||||
d["cell"] = c;
|
||||
d["vnum"] = (int)it.vnum;
|
||||
d["count"] = (int)it.count;
|
||||
out.push_back(d);
|
||||
}
|
||||
return out;
|
||||
@@ -1507,10 +1549,8 @@ Array M2Client::get_mall_items() const {
|
||||
if (it.empty()) {
|
||||
continue;
|
||||
}
|
||||
Dictionary d;
|
||||
Dictionary d = item_dict(it);
|
||||
d["cell"] = c;
|
||||
d["vnum"] = (int)it.vnum;
|
||||
d["count"] = (int)it.count;
|
||||
out.push_back(d);
|
||||
}
|
||||
return out;
|
||||
@@ -2476,7 +2516,7 @@ static Dictionary entity_dict(const mtnet::Entity &e) {
|
||||
Dictionary d;
|
||||
d["vid"] = (int)e.vid;
|
||||
d["race"] = (int)e.race;
|
||||
d["ch_type"] = (int)e.ch_type; // 0 PC, 1 NPC, 2 MONSTER, ...
|
||||
d["ch_type"] = (int)e.ch_type; // normalized: 0 PC, 1 NPC, 2 MONSTER, ...
|
||||
d["name"] = String::utf8(e.name.c_str());
|
||||
d["shop_sign"] = String::utf8(e.shop_sign.c_str());
|
||||
d["is_main"] = e.is_main;
|
||||
@@ -2701,30 +2741,6 @@ bool M2Client::pickup_item(int ground_vid) {
|
||||
return game && game->send_item_pickup((uint32_t)ground_vid);
|
||||
}
|
||||
|
||||
static Dictionary item_dict(const mtnet::Item &it) {
|
||||
Dictionary d;
|
||||
d["vnum"] = (int)it.vnum;
|
||||
d["count"] = (int)it.count;
|
||||
d["flags"] = (int)it.flags;
|
||||
d["anti_flags"] = (int)it.anti_flags;
|
||||
Array sockets;
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
sockets.push_back(it.sockets[i]);
|
||||
}
|
||||
d["sockets"] = sockets;
|
||||
Array attrs;
|
||||
for (int i = 0; i < 7; ++i) {
|
||||
if (it.attrs[i].type == 0) {
|
||||
continue;
|
||||
}
|
||||
Dictionary a;
|
||||
a["type"] = (int)it.attrs[i].type;
|
||||
a["value"] = (int)it.attrs[i].value;
|
||||
attrs.push_back(a);
|
||||
}
|
||||
d["attrs"] = attrs;
|
||||
return d;
|
||||
}
|
||||
|
||||
Array M2Client::get_inventory() const {
|
||||
Array out;
|
||||
@@ -2930,6 +2946,7 @@ void M2Client::warp_to_game_server(const String &host, int port) {
|
||||
classic_last_empire = -1;
|
||||
shop_open_seen = false;
|
||||
mall_open_seen = false;
|
||||
safebox_open_seen = false;
|
||||
dead_seen.clear();
|
||||
classic_sess = std::make_unique<mtnet::classic::ClassicSession>();
|
||||
classic_sess->set_wire_trace(OS::get_singleton()->get_environment("MT_NET_TRACE") == "1");
|
||||
@@ -3299,6 +3316,9 @@ void M2Client::pump_classic() {
|
||||
emit_signal("shop_closed");
|
||||
}
|
||||
}
|
||||
for (uint8_t pos : w.drain_shop_slot_updates()) {
|
||||
emit_signal("shop_updated", (int)pos);
|
||||
}
|
||||
for (const auto &err : w.drain_shop_errors()) {
|
||||
emit_signal("shop_error", String::utf8(err.c_str()));
|
||||
}
|
||||
@@ -3325,12 +3345,21 @@ void M2Client::pump_classic() {
|
||||
emit_signal("exchange_changed");
|
||||
}
|
||||
if (w.safebox_dirty()) {
|
||||
bool safe_now = w.safebox_open();
|
||||
if (safe_now && !safebox_open_seen) {
|
||||
emit_signal("safebox_opened", w.safebox_size());
|
||||
} else if (!safe_now && safebox_open_seen) {
|
||||
emit_signal("safebox_closed");
|
||||
}
|
||||
safebox_open_seen = safe_now;
|
||||
emit_signal("safebox_changed");
|
||||
}
|
||||
if (w.mall_dirty()) {
|
||||
bool mall_now = w.mall_open();
|
||||
if (mall_now && !mall_open_seen) {
|
||||
emit_signal("mall_opened", w.mall_size());
|
||||
} else if (!mall_now && mall_open_seen) {
|
||||
emit_signal("mall_closed");
|
||||
}
|
||||
mall_open_seen = mall_now;
|
||||
emit_signal("mall_changed");
|
||||
@@ -3433,6 +3462,7 @@ void M2Client::pump_classic() {
|
||||
classic_last_stage = -1;
|
||||
shop_open_seen = false;
|
||||
mall_open_seen = false;
|
||||
safebox_open_seen = false;
|
||||
dead_seen.clear();
|
||||
set_stage(Stage::GameConnect);
|
||||
return;
|
||||
@@ -3826,6 +3856,9 @@ void M2Client::pump_game() {
|
||||
emit_signal("shop_closed");
|
||||
}
|
||||
}
|
||||
for (uint8_t pos : w.drain_shop_slot_updates()) {
|
||||
emit_signal("shop_updated", (int)pos);
|
||||
}
|
||||
for (const auto &err : w.drain_shop_errors()) {
|
||||
emit_signal("shop_error", String::utf8(err.c_str()));
|
||||
}
|
||||
@@ -3833,12 +3866,21 @@ void M2Client::pump_game() {
|
||||
emit_signal("exchange_changed");
|
||||
}
|
||||
if (w.safebox_dirty()) {
|
||||
bool safe_now = w.safebox_open();
|
||||
if (safe_now && !safebox_open_seen) {
|
||||
emit_signal("safebox_opened", w.safebox_size());
|
||||
} else if (!safe_now && safebox_open_seen) {
|
||||
emit_signal("safebox_closed");
|
||||
}
|
||||
safebox_open_seen = safe_now;
|
||||
emit_signal("safebox_changed");
|
||||
}
|
||||
if (w.mall_dirty()) {
|
||||
bool mall_now = w.mall_open();
|
||||
if (mall_now && !mall_open_seen) {
|
||||
emit_signal("mall_opened", w.mall_size());
|
||||
} else if (!mall_now && mall_open_seen) {
|
||||
emit_signal("mall_closed");
|
||||
}
|
||||
mall_open_seen = mall_now;
|
||||
emit_signal("mall_changed");
|
||||
|
||||
@@ -370,6 +370,7 @@ private:
|
||||
bool enter_game_sent = false;
|
||||
bool shop_open_seen = false; // last shop_open() state we emitted a signal for
|
||||
bool mall_open_seen = false;
|
||||
bool safebox_open_seen = false;
|
||||
uint32_t last_login_key = 0; // for a cross-server GC_WARP reconnect
|
||||
void warp_to_game_server(const godot::String &host, int port);
|
||||
std::unordered_set<uint32_t> dead_seen; // vids we've already fired entity_dead for
|
||||
|
||||
@@ -49,7 +49,11 @@ Ref<StandardMaterial3D> material_for(const std::string &tex_name, bool alpha_ble
|
||||
mat->set_albedo(Color(0.7f, 0.7f, 0.7f));
|
||||
mat->set_roughness(1.0f);
|
||||
mat->set_texture_filter(StandardMaterial3D::TEXTURE_FILTER_LINEAR_WITH_MIPMAPS_ANISOTROPIC);
|
||||
mat->set_cull_mode(StandardMaterial3D::CULL_BACK);
|
||||
mat->set_cull_mode(StandardMaterial3D::CULL_DISABLED);
|
||||
mat->set_diffuse_mode(StandardMaterial3D::DIFFUSE_LAMBERT_WRAP);
|
||||
mat->set_specular_mode(StandardMaterial3D::SPECULAR_DISABLED);
|
||||
mat->set_feature(StandardMaterial3D::FEATURE_BACKLIGHT, true);
|
||||
mat->set_backlight(Color(0.2f, 0.2f, 0.2f));
|
||||
|
||||
String t = String(tex_name.c_str()).to_lower();
|
||||
bool kw_alpha = t.find("leaf") != -1 || t.find("grass") != -1 || t.find("fence") != -1 ||
|
||||
@@ -78,6 +82,10 @@ Ref<StandardMaterial3D> material_for(const std::string &tex_name, bool alpha_ble
|
||||
if (tex.is_valid()) {
|
||||
mat->set_texture(StandardMaterial3D::TEXTURE_ALBEDO, tex);
|
||||
mat->set_albedo(Color(1, 1, 1));
|
||||
mat->set_feature(StandardMaterial3D::FEATURE_EMISSION, true);
|
||||
mat->set_texture(StandardMaterial3D::TEXTURE_EMISSION, tex);
|
||||
mat->set_emission(Color(1.0f, 1.0f, 1.0f));
|
||||
mat->set_emission_energy_multiplier(0.18f);
|
||||
}
|
||||
return mat;
|
||||
}
|
||||
|
||||
@@ -228,10 +228,13 @@ int main() {
|
||||
mc.y = 200000;
|
||||
mc.z = 3;
|
||||
mc.empire = 1;
|
||||
mc.skill_group = 2;
|
||||
feed(s, raw(mc));
|
||||
CHECK(s.world().main_vid() == 7777, "main vid set");
|
||||
const auto *me = s.world().get(7777);
|
||||
CHECK(me && me->is_main && me->x == 100000.f && me->name == "Warrior", "main entity");
|
||||
CHECK(s.world().skill_group() == 2, "skill_group propagated to world");
|
||||
CHECK(s.parser().empire() == 1, "empire propagated to parser");
|
||||
auto version = drain(s);
|
||||
// §1.5: EUROPE-family locale sends CG_CLIENT_VERSION2 (0xf1) with the
|
||||
// fixed ymir epoch string; same 67-byte layout as CG_CLIENT_VERSION.
|
||||
@@ -248,7 +251,7 @@ int main() {
|
||||
add.header = HDR_GC_CHARACTER_ADD;
|
||||
add.vid = 8888;
|
||||
add.race = 101;
|
||||
add.type = CHRTYPE_NPC;
|
||||
add.type = WIRE_CHRTYPE_NPC;
|
||||
add.x = 100500;
|
||||
add.y = 200500;
|
||||
add.moving_speed = 150;
|
||||
@@ -279,7 +282,7 @@ int main() {
|
||||
mob.header = HDR_GC_CHARACTER_ADD;
|
||||
mob.vid = 8890;
|
||||
mob.race = 101;
|
||||
mob.type = CHRTYPE_MONSTER;
|
||||
mob.type = WIRE_CHRTYPE_MONSTER;
|
||||
mob.x = 101000;
|
||||
mob.y = 201000;
|
||||
feed(s, raw(mob));
|
||||
@@ -292,7 +295,7 @@ int main() {
|
||||
ghost.header = HDR_GC_CHARACTER_ADD;
|
||||
ghost.vid = 8891;
|
||||
ghost.race = 20025;
|
||||
ghost.type = CHRTYPE_MONSTER;
|
||||
ghost.type = WIRE_CHRTYPE_MONSTER;
|
||||
feed(s, raw(ghost));
|
||||
CHECK(s.world().get(8891) == nullptr && s.parser().pending_actor_count() == 0 &&
|
||||
s.world().size() == 3, "invisible race dropped");
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#include "../src/net/classic/classic_session.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <array>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
@@ -74,7 +75,8 @@ int main(int argc, char **argv) {
|
||||
const int enter_delay_ms = std::getenv("MT_CLASSIC_ENTER_DELAY") ?
|
||||
std::atoi(std::getenv("MT_CLASSIC_ENTER_DELAY")) : 8000;
|
||||
session.set_auto_entergame_delay(enter_delay_ms);
|
||||
session.set_wire_trace(std::getenv("MT_NET_TRACE") != nullptr);
|
||||
session.set_wire_trace(std::getenv("MT_NET_TRACE") != nullptr &&
|
||||
std::string(std::getenv("MT_NET_TRACE")) == "1");
|
||||
const bool use_auth = std::getenv("MT_CLASSIC_AUTH") != nullptr;
|
||||
const bool use_direct_enter = std::getenv("MT_CLASSIC_DIRECT") != nullptr;
|
||||
const int auth_port = std::getenv("MT_CLASSIC_AUTH_PORT") ?
|
||||
@@ -140,12 +142,25 @@ int main(int argc, char **argv) {
|
||||
}
|
||||
if (session.stage() == mtnet::INetSession::Stage::InGame) {
|
||||
const auto &world = session.world();
|
||||
std::array<int, 10> entity_types{};
|
||||
for (uint32_t vid : world.vids()) {
|
||||
if (const auto *entity = world.get(vid); entity && entity->ch_type < entity_types.size()) {
|
||||
++entity_types[entity->ch_type];
|
||||
}
|
||||
}
|
||||
int inventory_count = 0;
|
||||
for (int cell = 0; cell < mtnet::INVENTORY_MAX_NUM; ++cell) {
|
||||
if (!world.inv_slot(cell).empty()) ++inventory_count;
|
||||
}
|
||||
std::printf("PASS PHASE_GAME main_vid=%u entities=%zu inventory_slots=%d points_level=%d\n",
|
||||
world.main_vid(), world.size(), inventory_count, world.points().level());
|
||||
std::printf("PASS PHASE_GAME main_vid=%u entities=%zu inventory_slots=%d points_level=%d skill_group=%u\n",
|
||||
world.main_vid(), world.size(), inventory_count, world.points().level(),
|
||||
(unsigned)world.skill_group());
|
||||
std::printf(" entity_types pc=%d npc=%d monster=%d stone=%d warp=%d door=%d building=%d horse=%d goto=%d\n",
|
||||
entity_types[mtnet::classic::CHRTYPE_PC], entity_types[mtnet::classic::CHRTYPE_NPC],
|
||||
entity_types[mtnet::classic::CHRTYPE_MONSTER], entity_types[mtnet::classic::CHRTYPE_STONE],
|
||||
entity_types[mtnet::classic::CHRTYPE_WARP], entity_types[mtnet::classic::CHRTYPE_DOOR],
|
||||
entity_types[mtnet::classic::CHRTYPE_BUILDING], entity_types[mtnet::classic::CHRTYPE_HORSE],
|
||||
entity_types[mtnet::classic::CHRTYPE_GOTO]);
|
||||
if (const mtnet::Entity *me = world.get(world.main_vid())) {
|
||||
std::printf(" main race=%u pos_cm=(%.0f,%.0f,%.0f) parts=[%u,%u,%u,%u] name=%s\n",
|
||||
me->race, me->x, me->y, me->z, me->parts[0], me->parts[1], me->parts[2],
|
||||
@@ -158,6 +173,19 @@ int main(int argc, char **argv) {
|
||||
(unsigned)item.count);
|
||||
}
|
||||
}
|
||||
for (int pos = 0; pos < mtnet::QUICKSLOT_MAX_NUM; ++pos) {
|
||||
const mtnet::QuickSlot slot = world.quickslot(pos);
|
||||
if (slot.type != 0) {
|
||||
std::printf(" quickslot pos=%d type=%u ref=%u\n", pos,
|
||||
(unsigned)slot.type, (unsigned)slot.position);
|
||||
}
|
||||
}
|
||||
for (int id = 0; id < mtnet::SKILL_MAX_NUM; ++id) {
|
||||
if (world.skill_level(id) != 0) {
|
||||
std::printf(" skill id=%d level=%u master=%u\n", id,
|
||||
(unsigned)world.skill_level(id), (unsigned)world.skill_master(id));
|
||||
}
|
||||
}
|
||||
session.disconnect();
|
||||
return 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user