完善客户端功能并同步差距文档

This commit is contained in:
shenlei
2026-09-03 18:40:01 +09:00
parent f93a172296
commit 13ccb8d02d
135 changed files with 21881 additions and 1259 deletions
+31 -9
View File
@@ -3,7 +3,7 @@
#
# ./build-macos-client.sh [debug|release]
#
# 产物:build/export/mtgodot-poc.app + 旁边一个 assets 符号链接(不进包,2.1G)。
# 产物:build/export/mtgodot-poc.app(资源和 BGM 自包含在 Contents/Resources/)。
# 依赖:Godot 4.7.1 的 macOS 导出模板(editor 里 “管理导出模板” 装,或放
# ~/Library/Application Support/Godot/export_templates/4.7.1.stable/macos.zip)。
set -euo pipefail
@@ -13,14 +13,32 @@ CFG="${1:-debug}"
OUT="$REPO/build/export"
APP="$OUT/mtgodot-poc.app"
GODOT="${GODOT:-godot}"
MAC_ARCHES="${MT_MAC_ARCHES:-universal}"
BUILD_TYPE="Debug"
if [ "$CFG" = release ]; then
BUILD_TYPE="Release"
fi
echo "== 1. GDExtension .dylib =="
if [ "$MAC_ARCHES" = universal ]; then
CMAKE_ARCHES="arm64;x86_64"
else
CMAKE_ARCHES="$MAC_ARCHES"
fi
cmake -S . -B build -DCMAKE_BUILD_TYPE="$BUILD_TYPE" -DCMAKE_OSX_ARCHITECTURES="$CMAKE_ARCHES"
cmake --build build --target mtgodot -j8
DYLIB="project/bin/libmtgodot.macos.template_${CFG}.dylib"
if [ ! -f "$DYLIB" ]; then
# CMake 目前只出 debug 名;release 就复用它
cp -f project/bin/libmtgodot.macos.template_debug.dylib "$DYLIB"
fi
if [ "$MAC_ARCHES" = universal ]; then
LIPO_INFO="$(lipo -info "$DYLIB")"
case "$LIPO_INFO" in
*x86_64*arm64*|*arm64*x86_64*) ;;
*) echo "错误:GDExtension 不是 universal$LIPO_INFO" >&2; exit 1 ;;
esac
fi
echo "== 2. 导出 =="
mkdir -p "$OUT"
@@ -30,16 +48,20 @@ else
"$GODOT" --headless --path project --export-debug macOS "$APP"
fi
echo "== 3. 资源(不打进包=="
# .app 同级放一个 assets 链接;AssetRoot 会在 <.app 同级>/assets 找到它。
ln -sfn "$REPO/assets" "$OUT/assets"
ln -sfn "$REPO/bgm" "$OUT/bgm"
echo "== 3. 资源(自包含=="
mkdir -p "$APP/Contents/Resources"
ditto "$REPO/assets" "$APP/Contents/Resources/assets"
ditto "$REPO/bgm" "$APP/Contents/Resources/bgm"
test -f "$APP/Contents/Resources/assets/asset_index.txt"
echo "== 4. ad-hoc 签名(Apple Silicon 直跑=="
codesign --force --deep --sign - "$APP" 2>/dev/null || true
echo "== 4. 签名(默认 ad-hocCODESIGN_IDENTITY 可切正式证书=="
CODESIGN_IDENTITY="${CODESIGN_IDENTITY:--}"
find "$APP/Contents" -type f \( -name '*.dylib' -o -name '*.so' -o -name '*.a' \) -exec \
codesign --force --options runtime --timestamp=none --sign "$CODESIGN_IDENTITY" {} \; 2>/dev/null || true
codesign --force --options runtime --timestamp=none --sign "$CODESIGN_IDENTITY" "$APP" 2>/dev/null || true
xattr -dr com.apple.quarantine "$APP" 2>/dev/null || true
echo
echo "OK -> $APP"
echo "跑: open '$APP' (用 $OUT/assets"
echo "或: MT_ASSETS='$REPO/assets' '$APP/Contents/MacOS/mtgodot-poc' (看控制台日志)"
echo "跑: open '$APP'"
echo "开发覆盖资源:MT_ASSETS='$REPO/assets' '$APP/Contents/MacOS/mtgodot-poc'"
+5 -1
View File
@@ -5,6 +5,7 @@ cd "$(dirname "$0")"
CONFIG="${1:-Debug}" # Debug | Release
JOBS="${JOBS:-$(sysctl -n hw.ncpu)}"
MAC_ARCHES="${MT_MAC_ARCHES:-$(uname -m)}" # arm64, x86_64, or universal
# godot-cpp + the vendored deps (zstd, libsodium-cmake and its nested libsodium)
# are submodules — recurse. miniLZO is vendored in-tree, nothing to fetch.
@@ -15,7 +16,10 @@ if [ ! -f extension/godot-cpp/CMakeLists.txt ] \
git submodule update --init --recursive
fi
cmake -S . -B build -DCMAKE_BUILD_TYPE="$CONFIG"
if [ "$MAC_ARCHES" = universal ]; then
MAC_ARCHES="arm64;x86_64"
fi
cmake -S . -B build -DCMAKE_BUILD_TYPE="$CONFIG" -DCMAKE_OSX_ARCHITECTURES="$MAC_ARCHES"
cmake --build build --config "$CONFIG" -j "$JOBS"
echo
+5116 -291
View File
File diff suppressed because it is too large Load Diff
+1482 -127
View File
File diff suppressed because one or more lines are too long
+12
View File
@@ -87,6 +87,10 @@ if(BUILD_TESTING AND MT_HOST_BUILD)
target_link_libraries(net_entity_test PRIVATE mtnet)
add_test(NAME net.entity_store COMMAND $<TARGET_FILE:net_entity_test>)
add_executable(net_state_queue_test tests/net_state_queue_test.cpp)
target_link_libraries(net_state_queue_test PRIVATE mtnet)
add_test(NAME net.state_queue COMMAND $<TARGET_FILE:net_state_queue_test>)
# 40250 "classic" backend (MT_PROTOCOL=classic).
add_executable(net_classic_wire_test tests/net_classic_wire_test.cpp)
target_include_directories(net_classic_wire_test PRIVATE src/net)
@@ -117,6 +121,10 @@ if(BUILD_TESTING AND MT_HOST_BUILD)
target_link_libraries(net_text_codec_test PRIVATE mtnet)
add_test(NAME net.text_codec COMMAND $<TARGET_FILE:net_text_codec_test>)
add_executable(net_bounds_test tests/net_bounds_test.cpp)
target_link_libraries(net_bounds_test PRIVATE mtnet)
add_test(NAME net.bounds COMMAND $<TARGET_FILE:net_bounds_test>)
add_executable(net_mark_test tests/net_mark_test.cpp)
target_link_libraries(net_mark_test PRIVATE mtnet mt3p::minilzo)
add_test(NAME net.guild_mark COMMAND $<TARGET_FILE:net_mark_test>)
@@ -131,6 +139,10 @@ if(BUILD_TESTING AND MT_HOST_BUILD)
if(DEFINED ENV{M2_ASSETS})
set_tests_properties(proto.item_mob PROPERTIES ENVIRONMENT "M2_ASSETS=$ENV{M2_ASSETS}")
endif()
add_executable(proto_item_layout_test tests/proto_item_layout_test.cpp)
target_link_libraries(proto_item_layout_test PRIVATE mtproto)
add_test(NAME proto.item_layout COMMAND $<TARGET_FILE:proto_item_layout_test>)
endif()
# --- the extension library ---
+290 -3
View File
@@ -1,8 +1,16 @@
#include "environment_builder.h"
#include "asset_io.h"
#include "dxt.h"
#include <godot_cpp/classes/environment.hpp>
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/image_texture.hpp>
#include <godot_cpp/classes/procedural_sky_material.hpp>
#include <godot_cpp/classes/shader.hpp>
#include <godot_cpp/classes/shader_material.hpp>
#include <godot_cpp/classes/sky.hpp>
#include <godot_cpp/classes/texture2d.hpp>
#include <godot_cpp/core/object.hpp>
#include <m2_coord.h>
@@ -18,9 +26,158 @@ namespace {
Color rgba(const fmt::Rgba &c) { return Color(c[0], c[1], c[2], c[3]); }
float luma(const fmt::Rgba &c) { return 0.2126f * c[0] + 0.7152f * c[1] + 0.0722f * c[2]; }
const char *SRC_SKYBOX = R"(shader_type sky;
render_mode use_debanding;
uniform sampler2D front_tex : source_color, filter_linear_mipmap_anisotropic;
uniform sampler2D back_tex : source_color, filter_linear_mipmap_anisotropic;
uniform sampler2D left_tex : source_color, filter_linear_mipmap_anisotropic;
uniform sampler2D right_tex : source_color, filter_linear_mipmap_anisotropic;
uniform sampler2D top_tex : source_color, filter_linear_mipmap_anisotropic;
uniform sampler2D bottom_tex : source_color, filter_linear_mipmap_anisotropic;
uniform bool has_front = false;
uniform bool has_back = false;
uniform bool has_left = false;
uniform bool has_right = false;
uniform bool has_top = false;
uniform bool has_bottom = false;
uniform vec4 gradient_top = vec4(0.2, 0.3, 0.6, 1.0);
uniform vec4 gradient_horizon = vec4(0.5, 0.6, 0.8, 1.0);
uniform vec4 gradient_bottom = vec4(0.2, 0.2, 0.25, 1.0);
uniform sampler2D cloud_tex : source_color, repeat_enable, filter_linear_mipmap_anisotropic;
uniform bool has_cloud = false;
uniform vec2 cloud_scale = vec2(2000.0, 2000.0);
uniform float cloud_height = 300.0;
uniform vec2 cloud_texture_scale = vec2(4.0, 4.0);
uniform vec2 cloud_speed = vec2(0.001, 0.001);
uniform vec4 cloud_tint = vec4(0.0, 0.0, 0.0, 0.0);
vec3 gradient_color(vec3 direction) {
float height = clamp(direction.y * 0.5 + 0.5, 0.0, 1.0);
if (height < 0.5)
return mix(gradient_bottom.rgb, gradient_horizon.rgb, height * 2.0);
return mix(gradient_horizon.rgb, gradient_top.rgb, (height - 0.5) * 2.0);
}
vec3 apply_cloud(vec3 color, vec3 direction) {
if (!has_cloud || direction.y <= 0.001 || cloud_height <= 0.0 ||
cloud_scale.x <= 0.001 || cloud_scale.y <= 0.001)
return color;
// ClientVS22 renders a finite horizontal quad at camera.z + CloudHeight.
// After the Metin2 -> Godot conversion this is camera.y + height. The
// source quad maps source +Y -> U=0 and source +X -> V=1; source Y is
// Godot -Z, so keep that orientation here.
float distance_to_cloud = cloud_height / direction.y;
vec2 plane = direction.xz * distance_to_cloud;
if (abs(plane.x) > cloud_scale.x || abs(plane.y) > cloud_scale.y)
return color;
vec2 uv = vec2(
0.5 + 0.5 * plane.y / cloud_scale.y,
0.5 + 0.5 * plane.x / cloud_scale.x);
uv = fract(uv * cloud_texture_scale + TIME * cloud_speed);
// SkyBox.cpp uses MODULATEINVALPHA_ADDCOLOR followed by ONE /
// INVSRCCOLOR blending: cloud.rgb + sky * (1 - cloud.rgb).
vec4 texel = texture(cloud_tex, uv);
vec3 cloud_rgb = clamp(texel.rgb * (1.0 - cloud_tint.a) + cloud_tint.rgb,
0.0, 1.0);
return cloud_rgb + color * (1.0 - cloud_rgb);
}
void sky() {
vec3 direction = normalize(EYEDIR);
vec3 color = gradient_color(direction);
float ax = abs(direction.x);
float ay = abs(direction.y);
float az = abs(direction.z);
vec2 uv;
// ClientVS22 uses Metin2 Z-up faces. After x,z,-y conversion:
// front=+Z, back=-Z, left=+X, right=-X, top=+Y, bottom=-Y.
if (az >= ax && az >= ay) {
if (direction.z > 0.0) {
uv = vec2(0.5 - 0.5 * direction.x / az,
0.5 - 0.5 * direction.y / az);
if (has_front)
color = texture(front_tex, uv).rgb;
} else {
uv = vec2(0.5 + 0.5 * direction.x / az,
0.5 - 0.5 * direction.y / az);
if (has_back)
color = texture(back_tex, uv).rgb;
}
} else if (ax >= ay) {
if (direction.x > 0.0) {
uv = vec2(0.5 + 0.5 * direction.z / ax,
0.5 - 0.5 * direction.y / ax);
if (has_left)
color = texture(left_tex, uv).rgb;
} else {
uv = vec2(0.5 - 0.5 * direction.z / ax,
0.5 - 0.5 * direction.y / ax);
if (has_right)
color = texture(right_tex, uv).rgb;
}
} else if (direction.y > 0.0) {
uv = vec2(0.5 - 0.5 * direction.x / ay,
0.5 - 0.5 * direction.z / ay);
if (has_top)
color = texture(top_tex, uv).rgb;
} else {
uv = vec2(0.5 - 0.5 * direction.x / ay,
0.5 - 0.5 * direction.z / ay);
if (has_bottom)
color = texture(bottom_tex, uv).rgb;
}
COLOR = apply_cloud(color, direction);
}
)";
Ref<ImageTexture> solid_texture(const Color &color) {
PackedByteArray pixels;
pixels.resize(4);
uint8_t *p = pixels.ptrw();
p[0] = (uint8_t)std::round(std::clamp(color.r, 0.0f, 1.0f) * 255.0f);
p[1] = (uint8_t)std::round(std::clamp(color.g, 0.0f, 1.0f) * 255.0f);
p[2] = (uint8_t)std::round(std::clamp(color.b, 0.0f, 1.0f) * 255.0f);
p[3] = (uint8_t)std::round(std::clamp(color.a, 0.0f, 1.0f) * 255.0f);
Ref<godot::Image> image = godot::Image::create_from_data(
1, 1, false, godot::Image::FORMAT_RGBA8, pixels);
image->generate_mipmaps();
return ImageTexture::create_from_image(image);
}
Ref<ImageTexture> load_texture(const std::string &name, const fmt::AssetResolver *resolver) {
if (name.empty() || resolver == nullptr)
return Ref<ImageTexture>();
const std::string path = resolver->resolve(name, nullptr);
if (path.empty())
return Ref<ImageTexture>();
Ref<godot::Image> image;
const String godot_path(path.c_str());
if (godot_path.get_extension().to_lower() == "dds") {
const mtgodot::Image decoded = mtgodot::dds_from_file(godot_path);
if (!decoded.ok())
return Ref<ImageTexture>();
PackedByteArray pixels;
pixels.resize((int64_t)decoded.rgba.size());
std::copy(decoded.rgba.begin(), decoded.rgba.end(), pixels.ptrw());
image = godot::Image::create_from_data(decoded.w, decoded.h, false,
godot::Image::FORMAT_RGBA8, pixels);
} else {
image = godot::Image::load_from_file(godot_path);
}
if (image.is_null() || image->is_empty())
return Ref<ImageTexture>();
image->generate_mipmaps();
return ImageTexture::create_from_image(image);
}
} // namespace
EnvNodes apply_environment(const fmt::Environment &env, Node *parent) {
EnvNodes apply_environment(const fmt::Environment &env, Node *parent,
const fmt::AssetResolver *resolver) {
EnvNodes out;
// --- DirectionalLight (Background) ---
@@ -30,6 +187,8 @@ EnvNodes apply_environment(const fmt::Environment &env, Node *parent) {
out.sun->set_name("Sun");
parent->add_child(out.sun);
}
out.sun->set_cull_mask(1u << 0); // background / terrain layer
out.sun->set_visible(env.dir_light.bg_enable);
// .msenv Direction 是 Metin2 Z-up 向量(光传播方向)。
fmt::m2coord::Vec3 d = fmt::m2coord::direction_to_godot(
env.dir_light.direction[0], env.dir_light.direction[1], env.dir_light.direction[2]);
@@ -56,6 +215,26 @@ EnvNodes apply_environment(const fmt::Environment &env, Node *parent) {
// 角色/物体的间接补光:暖色,能量取自 Material.Ambient
out.sun->set_param(Light3D::PARAM_SPECULAR, 0.4f);
// ClientVS22 keeps the character light separate from the background light
// (MapOutdoor::OnBeginEnvironment -> SpeedTree::SetLight). Godot's light
// cull mask is the closest exact scene-level equivalent: static world
// geometry is layer 1, Metin2Model geometry is layer 2.
out.character_light = Object::cast_to<DirectionalLight3D>(
parent->get_node_or_null(NodePath("CharacterLight")));
if (!out.character_light) {
out.character_light = memnew(DirectionalLight3D);
out.character_light->set_name("CharacterLight");
parent->add_child(out.character_light);
}
out.character_light->set_cull_mask(1u << 1);
out.character_light->set_visible(env.dir_light.ch_enable);
out.character_light->set_transform(Transform3D(b, Vector3(0, 0, 0)));
out.character_light->set_color(rgba(env.dir_light.ch_diffuse));
out.character_light->set_param(Light3D::PARAM_ENERGY,
std::clamp(0.9f + luma(env.dir_light.ch_diffuse) * 0.4f, 0.7f, 1.6f));
out.character_light->set_shadow(false);
out.character_light->set_param(Light3D::PARAM_SPECULAR, 0.4f);
// --- WorldEnvironment ---
out.world_env =
Object::cast_to<WorldEnvironment>(parent->get_node_or_null(NodePath("WorldEnv")));
@@ -68,7 +247,12 @@ EnvNodes apply_environment(const fmt::Environment &env, Node *parent) {
if (e.is_null())
e.instantiate();
// 天空:SkyBox Gradient -> ProceduralSky 的三段色(zenith / horizon / ground
// 天空:SkyBox Gradient -> ProceduralSky 的三段色(zenith / horizon / ground
// 参考端的 texture mode 是独立六面 quadGodot Sky shader 用 EYEDIR 采样
// 同一套六面图,坐标和 UV 依照 EterLib/SkyBox.cpp 的面顶点顺序转换。
int sky_face_count = 0;
bool sky_texture_applied = false;
bool sky_cloud_applied = false;
{
Ref<ProceduralSkyMaterial> psm;
psm.instantiate();
@@ -83,7 +267,79 @@ EnvNodes apply_environment(const fmt::Environment &env, Node *parent) {
psm->set_sun_angle_max(6.0f);
Ref<Sky> sky;
sky.instantiate();
sky->set_material(psm);
std::array<Ref<ImageTexture>, 6> faces;
std::array<bool, 6> face_loaded{};
for (int i = 0; i < 6; ++i) {
if (env.sky.face_textures[i].empty())
continue;
faces[i] = load_texture(env.sky.face_textures[i], resolver);
face_loaded[i] = faces[i].is_valid();
if (face_loaded[i])
++sky_face_count;
}
Ref<ImageTexture> cloud_texture;
if (!env.sky.cloud_texture.empty())
cloud_texture = load_texture(env.sky.cloud_texture, resolver);
const bool use_texture_sky = env.sky.texture_render_mode && sky_face_count > 0;
sky_texture_applied = use_texture_sky;
if (use_texture_sky) {
Ref<Shader> shader;
shader.instantiate();
shader->set_code(String(SRC_SKYBOX));
Ref<ShaderMaterial> material;
material.instantiate();
material->set_shader(shader);
Color fallback = g.empty() ? Color(0.35f, 0.45f, 0.7f, 1.0f) : rgba(g.front());
Color horizon = g.size() < 2 ? fallback : rgba(g[g.size() / 2]);
Color ground = g.empty() ? Color(0.2f, 0.2f, 0.25f, 1.0f) : rgba(g.back());
material->set_shader_parameter("gradient_top", fallback);
material->set_shader_parameter("gradient_horizon", horizon);
material->set_shader_parameter("gradient_bottom", ground);
static constexpr const char *kFaceParams[6] = {
"front_tex", "back_tex", "left_tex", "right_tex", "top_tex", "bottom_tex"};
static constexpr const char *kFaceFlags[6] = {
"has_front", "has_back", "has_left", "has_right", "has_top", "has_bottom"};
for (int i = 0; i < 6; ++i) {
if (!faces[i].is_valid())
faces[i] = solid_texture(i == 5 ? ground : horizon);
material->set_shader_parameter(kFaceParams[i], faces[i]);
material->set_shader_parameter(kFaceFlags[i], face_loaded[i]);
}
if (cloud_texture.is_valid()) {
material->set_shader_parameter("cloud_tex", cloud_texture);
material->set_shader_parameter("has_cloud", true);
material->set_shader_parameter("cloud_scale", Vector2(
std::max(env.sky.cloud_scale[0] * (float)fmt::m2coord::CM_TO_M, 0.01f),
std::max(env.sky.cloud_scale[1] * (float)fmt::m2coord::CM_TO_M, 0.01f)));
material->set_shader_parameter("cloud_height",
std::max(env.sky.cloud_height * (float)fmt::m2coord::CM_TO_M, 0.01f));
material->set_shader_parameter("cloud_texture_scale", Vector2(
env.sky.cloud_texture_scale[0], env.sky.cloud_texture_scale[1]));
material->set_shader_parameter("cloud_speed", Vector2(
env.sky.cloud_speed[0], env.sky.cloud_speed[1]));
Color tint = Color(0, 0, 0, 0);
if (!env.sky.cloud_color.empty())
tint = rgba(env.sky.cloud_color.front());
material->set_shader_parameter("cloud_tint", tint);
sky_cloud_applied = true;
}
sky->set_material(material);
} else {
sky->set_material(psm);
}
// Reference SkyBox scrolls clouds in its own renderer. Godot's procedural
// sky has no equivalent UV-speed control, but sky_cover preserves the real
// cloud asset and alpha/color instead of silently dropping CloudTextureFileName.
if (!env.sky.cloud_texture.empty() && !use_texture_sky) {
if (cloud_texture.is_valid()) {
psm->set_sky_cover(cloud_texture);
Color tint = Color(1, 1, 1, 1);
if (!env.sky.cloud_color.empty())
tint = rgba(env.sky.cloud_color.front());
psm->set_sky_cover_modulate(tint);
sky_cloud_applied = true;
}
}
e->set_sky(sky);
e->set_background(godot::Environment::BG_SKY);
}
@@ -145,6 +401,37 @@ EnvNodes apply_environment(const fmt::Environment &env, Node *parent) {
e->set_ssao_radius(1.2f);
e->set_ssao_intensity(0.9f);
// Keep parsed reference values visible to the runtime/debug probe even where
// Godot has no 1:1 mapping (six cube faces, D3D blend factors, cloud motion,
// and lens flare). This also prevents a future builder from silently losing
// the fields while the renderer equivalent is being implemented.
out.world_env->set_meta("msenv_sky_texture_mode", env.sky.texture_render_mode);
out.world_env->set_meta("msenv_sky_face_count", sky_face_count);
out.world_env->set_meta("msenv_sky_texture_mode_applied", sky_texture_applied);
out.world_env->set_meta("msenv_sky_cloud_applied", sky_cloud_applied);
out.world_env->set_meta("msenv_sky_scale",
Vector3(env.sky.scale[0], env.sky.scale[1], env.sky.scale[2]));
out.world_env->set_meta("msenv_cloud_scale",
Vector2(env.sky.cloud_scale[0], env.sky.cloud_scale[1]));
out.world_env->set_meta("msenv_cloud_height", env.sky.cloud_height);
out.world_env->set_meta("msenv_cloud_texture_scale",
Vector2(env.sky.cloud_texture_scale[0], env.sky.cloud_texture_scale[1]));
out.world_env->set_meta("msenv_cloud_speed",
Vector2(env.sky.cloud_speed[0], env.sky.cloud_speed[1]));
out.world_env->set_meta("msenv_filter_enabled", env.filter.enable);
out.world_env->set_meta("msenv_filter_color", rgba(env.filter.color));
out.world_env->set_meta("msenv_filter_alpha_src", env.filter.alpha_src);
out.world_env->set_meta("msenv_filter_alpha_dest", env.filter.alpha_dest);
out.world_env->set_meta("msenv_lens_flare_enabled", env.lens_flare.enable);
out.world_env->set_meta("msenv_lens_flare_texture", env.lens_flare.main_flare_texture.c_str());
out.world_env->set_meta("msenv_background_light_enabled", env.dir_light.bg_enable);
out.world_env->set_meta("msenv_character_light_enabled", env.dir_light.ch_enable);
out.world_env->set_meta("msenv_background_light_ambient", rgba(env.dir_light.bg_ambient));
out.world_env->set_meta("msenv_character_light_ambient", rgba(env.dir_light.ch_ambient));
// MapUtil.cpp defaults; Environment_Load has no wind token to override these.
out.world_env->set_meta("msenv_wind_strength", 0.2f);
out.world_env->set_meta("msenv_wind_random", 0.0f);
out.world_env->set_environment(e);
return out;
}
+8 -3
View File
@@ -3,19 +3,24 @@
#include <godot_cpp/classes/directional_light3d.hpp>
#include <godot_cpp/classes/world_environment.hpp>
#include <asset_resolver.h>
#include <environment.h>
// W5 —— .msenv(已由 formats/environment 解析)-> Godot 光照 / 天空 / 雾 / 色调。
// SHINSOO §9-W5。DirectionalLight.Background 驱动场景主光;Character 光留待角色材质
// uniform(§9-W5 note)。云 / lens flare 留 R2。
// SHINSOO §9-W5。背景 / 角色方向光按参考端分成两个可见性层;云贴图接入
// ProceduralSkyMaterial 的 sky covertexture-mode 下再用 Godot sky shader 叠加
// 参考端的有限云层平面与 UV 滚动。六面天空、滤色和 lens flare 仍保持显式待办,
// 不把不等价的近似标成完成。
namespace mtgodot {
struct EnvNodes {
godot::DirectionalLight3D *sun = nullptr;
godot::DirectionalLight3D *character_light = nullptr;
godot::WorldEnvironment *world_env = nullptr;
};
// 在 parent 下建 / 配 DirectionalLight3D + WorldEnvironment。已存在则复用。
EnvNodes apply_environment(const fmt::Environment &env, godot::Node *parent);
EnvNodes apply_environment(const fmt::Environment &env, godot::Node *parent,
const fmt::AssetResolver *resolver = nullptr);
} // namespace mtgodot
+87
View File
@@ -17,6 +17,9 @@
#include <godot_cpp/classes/file_access.hpp>
#include <godot_cpp/variant/array.hpp>
#include <godot_cpp/variant/dictionary.hpp>
#include <godot_cpp/variant/packed_int32_array.hpp>
#include <combo_table.h>
using namespace godot;
@@ -47,6 +50,11 @@ void Metin2AnimPlayer::_bind_methods() {
ClassDB::bind_method(D_METHOD("get_accumulation"), &Metin2AnimPlayer::get_accumulation);
ClassDB::bind_method(D_METHOD("get_events"), &Metin2AnimPlayer::get_events);
ClassDB::bind_method(D_METHOD("get_loop_data"), &Metin2AnimPlayer::get_loop_data);
ClassDB::bind_method(D_METHOD("get_motion_data"), &Metin2AnimPlayer::get_motion_data);
ClassDB::bind_static_method("Metin2AnimPlayer",
D_METHOD("parse_combo_tables", "py_path"), &Metin2AnimPlayer::parse_combo_tables);
ClassDB::bind_static_method("Metin2AnimPlayer",
D_METHOD("make_combo_key", "motion_mode", "combo_type"), &Metin2AnimPlayer::make_combo_key);
// Fired when playback time crosses a .msa MotionEventData entry.
ADD_SIGNAL(MethodInfo("motion_event",
@@ -187,6 +195,85 @@ Dictionary Metin2AnimPlayer::get_loop_data() const {
return d;
}
// CLIENT-GAP §3.3 / §3.5 — the currently-loaded .msa's ComboInputData / AttackingData.
// `next_combo` is the reference普攻节奏 (CRaceMotionData::GetNextComboTime); when the
// motion has no ComboInputData it falls back to MotionDuration * 0.9.
Dictionary Metin2AnimPlayer::get_motion_data() const {
const fmt::Msa &m = msa_metadata;
Dictionary d;
d["duration"] = duration > 0.0 ? duration : m.duration;
d["has_combo_input"] = m.has_combo_input;
d["pre_input_time"] = m.combo_pre_input_time;
d["direct_input_time"] = m.combo_direct_input_time;
d["input_limit_time"] = m.combo_input_limit_time;
d["link_time"] = m.combo_link_time;
const double dur = duration > 0.0 ? duration : m.duration;
d["next_combo"] = m.has_combo_input && m.combo_direct_input_time > 0.0
? (double)m.combo_direct_input_time
: dur * 0.9;
d["has_attacking_data"] = m.has_attacking_data;
d["attacking_type"] = m.attacking_type;
d["motion_type"] = m.motion_type;
d["hitting_type"] = m.hitting_type;
d["attack_start_time"] = m.attack_start_time;
d["attack_end_time"] = m.attack_end_time;
d["stiffen_time"] = m.stiffen_time;
d["invisible_time"] = m.invisible_time;
d["external_force"] = m.external_force;
d["hit_limit_count"] = m.hit_limit_count;
// CLIENT-GAP §3.5 修改 1 —— THitDataContainer 多命中窗(多段挥击 / 双持 = 多个窗)。
// [{start_time, end_time, bone, weapon_length, samples:[{time, last_pos:Vector3, pos:Vector3}]}]
Array windows;
for (const fmt::Msa::HitWindow &w : m.hit_windows) {
Dictionary wd;
wd["start_time"] = w.start_time;
wd["end_time"] = w.end_time;
wd["bone"] = String::utf8(w.bone_name.c_str());
wd["weapon_length"] = w.weapon_length;
Array samples;
for (const fmt::Msa::HitSample &s : w.samples) {
Dictionary sd;
sd["time"] = s.time;
sd["last_pos"] = Vector3(s.last_pos[0], s.last_pos[1], s.last_pos[2]);
sd["pos"] = Vector3(s.pos[0], s.pos[1], s.pos[2]);
samples.push_back(sd);
}
wd["samples"] = samples;
windows.push_back(wd);
}
d["hit_windows"] = windows;
return d;
}
int Metin2AnimPlayer::make_combo_key(int motion_mode, int combo_type) {
return (int)fmt::make_combo_key((uint16_t)motion_mode, (uint16_t)combo_type);
}
Dictionary Metin2AnimPlayer::parse_combo_tables(const String &py_path) {
Dictionary out;
fmt::PlayerComboTables tables;
std::string err;
if (!fmt::parse_player_combo_tables_file(
std::string(py_path.utf8().get_data()), tables, &err)) {
UtilityFunctions::push_warning(
String("Metin2AnimPlayer.parse_combo_tables: ") + String(err.c_str()));
return out;
}
for (int c = 0; c < fmt::COMBO_CLASS_COUNT; ++c) {
Dictionary cls;
for (const auto &kv : tables.klass(c).combos) {
PackedInt32Array segs;
segs.resize((int)kv.second.size());
for (int i = 0; i < (int)kv.second.size(); ++i) {
segs.set(i, (int)kv.second[i]);
}
cls[(int)kv.first] = segs;
}
out[c] = cls;
}
return out;
}
void Metin2AnimPlayer::_ready() {
set_process(true);
reload();
+10
View File
@@ -68,6 +68,16 @@ public:
godot::Vector3 get_accumulation() const { return accumulation; }
godot::Array get_events() const; // [{type,start_time,effect,sound,pos}, ...]
godot::Dictionary get_loop_data() const;
godot::Dictionary get_motion_data() const; // {duration,next_combo,attack_start_time,...}
// CLIENT-GAP §3.5 — parse the PC combo tables out of playersettingmodule.py
// (chrmgr.ReserveComboAttackNew / RegisterComboAttackNew → CRaceData). Returns
// { class_idx:int -> { combo_key:int -> PackedInt32Array(段号 14..21) } }
// with class_idx 0=warrior 1=assassin 2=sura 3=shaman and
// combo_key = (motion_mode << 16) | combo_type. Empty Dictionary on failure.
static godot::Dictionary parse_combo_tables(const godot::String &py_path);
// (motion_mode << 16) | combo_type — mirrors RaceData.h MAKE_COMBO_KEY.
static int make_combo_key(int motion_mode, int combo_type);
// NaN-scan every animation in anim_path across [0,dur]; returns a report string.
godot::String selfcheck(int samples = 24);
+3
View File
@@ -265,6 +265,7 @@ void Metin2Model::_set_lod(int n) {
if (mi->get_mesh().is_valid() && material_mode != String("standard")) {
lod_prev_mi = memnew(MeshInstance3D);
lod_prev_mi->set_name("LodGhost");
lod_prev_mi->set_layer_mask(1u << 1);
lod_prev_mi->set_mesh(mi->get_mesh());
add_child(lod_prev_mi);
lod_prev_mi->set_transform(mi->get_transform());
@@ -457,6 +458,7 @@ void Metin2Model::reload() {
if (mesh->get_surface_count() > 0) {
mi = memnew(MeshInstance3D);
mi->set_name("MeshInstance3D");
mi->set_layer_mask(1u << 1); // CharacterLight visibility layer.
mi->set_mesh(mesh);
add_child(mi);
mi->set_owner(get_owner() ? get_owner() : this);
@@ -722,6 +724,7 @@ void Metin2Model::_load_attach(const String &gr2_rel, const String &bone_name,
slot_mi = memnew(MeshInstance3D);
slot_mi->set_name(node_name);
slot_mi->set_layer_mask(1u << 1); // equipment follows the character light.
slot_mi->set_mesh(wmesh);
add_child(slot_mi);
+133 -26
View File
@@ -45,6 +45,7 @@
#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstdlib>
using namespace godot;
@@ -90,9 +91,14 @@ void Metin2World::_bind_methods() {
ClassDB::bind_method(D_METHOD("sample_height", "gx_m", "gz_m"), &Metin2World::sample_height);
ClassDB::bind_method(D_METHOD("sample_attribute", "gx_m", "gz_m"), &Metin2World::sample_attribute);
ClassDB::bind_method(D_METHOD("is_blocked", "gx_m", "gz_m"), &Metin2World::is_blocked);
ClassDB::bind_method(D_METHOD("get_fishing_rotation", "gx_m", "gz_m", "heading_deg"),
&Metin2World::get_fishing_rotation);
ClassDB::bind_method(D_METHOD("can_fishing_position", "gx_m", "gz_m", "heading_deg"),
&Metin2World::can_fishing_position);
ClassDB::bind_method(D_METHOD("load_dds", "path"), &Metin2World::load_dds);
ClassDB::bind_method(D_METHOD("chunk_dir", "tx", "ty"), &Metin2World::chunk_dir);
ClassDB::bind_method(D_METHOD("get_load_report"), &Metin2World::get_load_report);
ClassDB::bind_method(D_METHOD("get_ambience_sources"), &Metin2World::get_ambience_sources);
ClassDB::bind_method(D_METHOD("bake_asset_index", "out_path"), &Metin2World::bake_asset_index);
ADD_PROPERTY(PropertyInfo(Variant::STRING, "assets_root", PROPERTY_HINT_GLOBAL_DIR),
@@ -248,6 +254,7 @@ bool Metin2World::build_chunk(int tx, int ty) {
MeshInstance3D *pm = memnew(MeshInstance3D);
pm->set_name(Pn > 1 ? vformat("Terrain_%d_%d", pi, pj) : String("Terrain"));
pm->set_layer_mask(1u << 0); // Background light visibility layer.
pm->set_mesh(pmesh);
if (Pn > 1 && terrain_patch_view > 0.0f) {
// 硬剔除(无半透明淡出),避免远景地形变透明
@@ -299,6 +306,7 @@ bool Metin2World::build_chunk(int tx, int ty) {
continue;
MeshInstance3D *w = memnew(MeshInstance3D);
w->set_name("Water");
w->set_layer_mask(1u << 0);
w->set_mesh(p.mesh);
croot->add_child(w);
++water_pieces;
@@ -314,6 +322,8 @@ bool Metin2World::build_chunk(int tx, int ty) {
ck.root = croot;
if (objects_enabled && registry_ok && resolver)
place_chunk_objects(tx, ty, croot, ck.objects, ck.trees);
if (registry_ok)
place_chunk_ambience(tx, ty);
objects_placed += ck.objects;
trees_placed += ck.trees;
chunks.push_back(std::move(ck));
@@ -400,6 +410,7 @@ void Metin2World::place_chunk_objects(int tx, int ty, Node3D *root, int &n_obj,
MeshInstance3D *mi = memnew(MeshInstance3D);
mi->set_name(String(p->name.c_str()) + "_" + String::num_uint64(o.crc));
mi->set_layer_mask(1u << 0); // Background light visibility layer.
mi->set_mesh(mesh);
mi->set_transform(xform);
// ShadowFlag: 客户端把 isShadowFlag 物体丢进动态阴影贴图(= 我们的实时投影);
@@ -462,6 +473,7 @@ void Metin2World::place_chunk_objects(int tx, int ty, Node3D *root, int &n_obj,
mm->set_instance_transform(i, kv.second.xforms[i]);
MultiMeshInstance3D *mmi = memnew(MultiMeshInstance3D);
mmi->set_name(String("Trees_") + String(kv.first.c_str()).get_file().get_basename());
mmi->set_layer_mask(1u << 0);
mmi->set_multimesh(mm);
// §3.3: 树影已烘焙进 shadowmap.dds;proxy 几何的实时投影形状也不对 -> 默认不投。
mmi->set_cast_shadows_setting(tree_shadows ? GeometryInstance3D::SHADOW_CASTING_SETTING_ON
@@ -474,6 +486,46 @@ void Metin2World::place_chunk_objects(int tx, int ty, Node3D *root, int &n_obj,
}
}
void Metin2World::place_chunk_ambience(int tx, int ty) {
// Area ambience is a separate file in ClientVS22. The .pra property owns
// play type / interval / sound names; Audio owns the runtime instances.
const std::string mdir = std::string(map_dir().utf8().get_data());
fmt::AreaAmbienceData ad;
std::string e;
const std::string base = mdir + "/" + fmt::m2coord::tile_dir(tx, ty) + "/";
bool loaded = fmt::parse_area_ambience_file(base + "areaambiencedata.txt", ad, &e);
if (!loaded)
loaded = fmt::parse_area_ambience_file(base + "AreaAmbienceData.txt", ad, &e);
if (!loaded)
return; // 老地图可能没有环境声文件。
for (size_t i = 0; i < ad.objects.size(); ++i) {
const fmt::AreaAmbienceObject &o = ad.objects[i];
const fmt::Property *p = registry.find(o.crc);
if (!p || p->type != fmt::PropertyType::Ambience)
continue;
AmbienceSource source;
source.tile_x = tx;
source.tile_y = ty;
source.object_index = (int)i;
const fmt::m2coord::Vec3 g = fmt::m2coord::position_to_godot(o.x, o.y, o.z);
source.position = Vector3((float)g.x, (float)g.y, (float)g.z);
source.range_cm = std::max(0, (int)std::lround(o.range));
source.max_volume_area_percentage = (float)std::strtof(
p->get("maxvolumeareapercentage", "0").c_str(), nullptr);
source.play_interval = (float)std::strtof(
p->get("playinterval", "0").c_str(), nullptr);
source.play_interval_variation = (float)std::strtof(
p->get("playintervalvariation", "0").c_str(), nullptr);
source.play_type = p->get("playtype", "LOOP").c_str();
const std::string sounds = p->get("ambiencesoundvector");
if (!sounds.empty())
source.sounds.push_back(sounds);
if (!source.sounds.empty() && source.range_cm > 0)
ambience_sources.push_back(std::move(source));
}
}
bool Metin2World::load_map() {
unload_map();
@@ -487,39 +539,35 @@ bool Metin2World::load_map() {
}
setting_ok = true;
// splat 前置:TextureSet + AssetResolver(整盘扫描,一次)
// AssetResolver 是地图对象、环境声属性和 splat 共用的资源索引。
// 即使关闭 splat,也要保留这条链路,避免环境声依赖渲染开关。
splat_ready = false;
if (splat_enabled) {
const std::string root = std::string(assets_root.utf8().get_data());
resolver = std::make_shared<fmt::AssetResolver>();
std::string re;
// asset_index.txt 存在就装载(PCK/移动端必走),否则扫盘(桌面开发)。
if (!resolver->build_or_load(root, fmt::AssetResolver::default_priority(), &re)) {
UtilityFunctions::push_warning(String("[Metin2World] AssetResolver: ") + re.c_str());
resolver.reset();
}
if (splat_enabled && resolver) {
std::string ts_rel = setting.texture_set;
for (char &c : ts_rel) {
if (c == '\\')
c = '/';
c = (char)std::tolower((unsigned char)c);
}
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 {
if (fmt::parse_texture_set_file(root + "/textureset/" + ts_rel, texture_set, &tse))
splat_ready = true;
else
UtilityFunctions::push_warning(String("[Metin2World] TextureSet: ") + tse.c_str());
}
}
// Property CRC 注册表(一次;splat 已建 resolver
// Property CRC 注册表(对象和环境声共用;不依赖 objects_enabled)。
registry_ok = false;
if (objects_enabled) {
const std::string root(assets_root.utf8().get_data());
std::string re;
// resolver 已建(splat 阶段):用它的文件清单,避免再 std::filesystem 扫盘
// PCK 里扫不了)。没 resolver 时退回目录递归。
{
// resolver 已建:用它的文件清单,避免再 std::filesystem 扫盘(PCK 里扫不了)。
bool ok = resolver
? registry.scan_list(root, resolver->all_rel(), &re)
: registry.scan(root + "/Property", &re);
@@ -554,12 +602,9 @@ bool Metin2World::load_map() {
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);
apply_environment(env, this, resolver.get());
env_ok = true;
} else {
UtilityFunctions::push_warning(String("[Metin2World] .msenv: ") + ee.c_str());
@@ -588,6 +633,9 @@ void Metin2World::unload_chunk(int idx) {
--chunks_built;
if (c.root)
c.root->queue_free();
ambience_sources.erase(std::remove_if(ambience_sources.begin(), ambience_sources.end(),
[&c](const AmbienceSource &s) { return s.tile_x == c.tx && s.tile_y == c.ty; }),
ambience_sources.end());
chunks.erase(chunks.begin() + idx);
}
@@ -637,11 +685,12 @@ void Metin2World::unload_map() {
while (!chunks.empty())
unload_chunk((int)chunks.size() - 1);
stream_queue.clear();
ambience_sources.clear();
if (objects_root) {
objects_root->queue_free();
objects_root = nullptr;
}
for (const char *nm : {"Sun", "WorldEnv"})
for (const char *nm : {"Sun", "CharacterLight", "WorldEnv"})
if (Node *n = get_node_or_null(NodePath(nm)))
n->queue_free();
water_pieces = 0;
@@ -736,6 +785,42 @@ int Metin2World::sample_attribute(double gx_m, double gz_m) const {
return c->am->data[size_t(ay) * fmt::ATTRMAP_XY + ax];
}
double Metin2World::get_fishing_rotation(double gx_m, double gz_m, double heading_deg) const {
// InstanceBaseMotion.cpp uses c_fFishingDistance=600.0f and
// ELPlainCoord_GetRotatedPixelPosition (sin(rot), cos(rot)). The POC
// world frame is the MapCoord frame, where heading -> world yaw is
// heading + 90 degrees; using the same conversion keeps the probe in
// the coordinate frame used by player_controller / MapCoord.
constexpr double DISTANCE_M = 6.0;
constexpr double WATER_ATTRIBUTE = 1 << 1; // PRTerrainLib::ATTRIBUTE_WATER
constexpr double DEG_TO_RAD = 3.14159265358979323846 / 180.0;
auto normalized = [](double d) {
d = std::fmod(d, 360.0);
return d < 0.0 ? d + 360.0 : d;
};
auto water_at = [&](double candidate_heading) {
const double yaw = (candidate_heading + 90.0) * DEG_TO_RAD;
const double x = gx_m + std::sin(yaw) * DISTANCE_M;
const double z = gz_m + std::cos(yaw) * DISTANCE_M;
return (sample_attribute(x, z) & int(WATER_ATTRIBUTE)) != 0;
};
for (int step = 0; step <= 18; ++step) {
const double offset = double(step * 10);
const double right = heading_deg + offset;
if (water_at(right))
return normalized(right);
const double left = heading_deg - offset;
if (water_at(left))
return normalized(left);
}
return -1.0;
}
bool Metin2World::can_fishing_position(double gx_m, double gz_m, double heading_deg) const {
return get_fishing_rotation(gx_m, gz_m, heading_deg) >= 0.0;
}
Dictionary Metin2World::get_load_report() const {
Dictionary d;
d["map_path"] = map_path;
@@ -756,6 +841,7 @@ Dictionary Metin2World::get_load_report() const {
d["objects_placed"] = objects_placed;
d["objects_skipped"] = objects_skipped;
d["objects_missing_model"] = objects_missing_model;
d["ambience_sources"] = (int)ambience_sources.size();
d["trees_placed"] = trees_placed;
d["tree_species"] = tree_species;
d["objects_mdatr_pending"] = objects_mdatr_pending; // 有 .mdatr 但未建碰撞
@@ -768,6 +854,27 @@ Dictionary Metin2World::get_load_report() const {
return d;
}
Array Metin2World::get_ambience_sources() const {
Array out;
for (const AmbienceSource &s : ambience_sources) {
Dictionary d;
d["key"] = String::num_int64(s.tile_x) + ":" + String::num_int64(s.tile_y) + ":" +
String::num_int64(s.object_index);
d["position"] = s.position;
d["range_cm"] = s.range_cm;
d["max_volume_area_percentage"] = s.max_volume_area_percentage;
d["play_interval"] = s.play_interval;
d["play_interval_variation"] = s.play_interval_variation;
d["play_type"] = s.play_type;
Array sounds;
for (const std::string &sound : s.sounds)
sounds.push_back(String(sound.c_str()));
d["sounds"] = sounds;
out.push_back(d);
}
return out;
}
Dictionary Metin2World::get_perf() const {
Dictionary d;
Performance *pf = Performance::get_singleton();
+22
View File
@@ -3,6 +3,7 @@
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/node3d.hpp>
#include <godot_cpp/classes/ref.hpp>
#include <godot_cpp/variant/array.hpp>
#include <godot_cpp/variant/dictionary.hpp>
#include <godot_cpp/variant/string.hpp>
@@ -84,7 +85,15 @@ public:
// attr.atr 属性字节(bit0=BLOCK, bit1=WATER…);地图外返回 0。
int sample_attribute(double gx_m, double gz_m) const;
bool is_blocked(double gx_m, double gz_m) const { return (sample_attribute(gx_m, gz_m) & 1) != 0; }
// ClientVS22 InstanceBaseMotion::GetFishingRot 等价:在角色前方 600cm
// 按当前 heading ±(0..180°,步长 10°) 扫描 ATTRIBUTE_WATER。
// 返回选中的服务端 heading;没有水面或目标区块未加载时返回 -1。
double get_fishing_rotation(double gx_m, double gz_m, double heading_deg) const;
bool can_fishing_position(double gx_m, double gz_m, double heading_deg) const;
godot::Dictionary get_load_report() const;
// Area ambience sources from AreaAmbienceData + .pra properties. Audio owns
// playback; the world only exposes the reference source records.
godot::Array get_ambience_sources() const;
// 便捷:解一张 DDS 为 Image(HUD 小地图等用;Godot 原生不支持 .dds)。
godot::Ref<godot::Image> load_dds(const godot::String &path) const;
@@ -144,13 +153,26 @@ private:
godot::Node3D *root = nullptr; // 该区块的全部场景节点(terrain + water + 对象 + 树)
int objects = 0, trees = 0;
};
struct AmbienceSource {
int tile_x = 0, tile_y = 0;
int object_index = 0;
godot::Vector3 position;
int range_cm = 0;
float max_volume_area_percentage = 0.0f;
float play_interval = 0.0f;
float play_interval_variation = 0.0f;
godot::String play_type;
std::vector<std::string> sounds;
};
std::vector<Chunk> chunks;
std::vector<std::pair<int, int>> stream_queue; // 待建区块
std::vector<AmbienceSource> ambience_sources;
int stream_budget = 1; // 每帧最多建几个区块(streaming 时)
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_ambience(int tx, int ty);
void unload_chunk(int idx);
void stream_update();
const Chunk *chunk_at(int tx, int ty) const;
+17 -3
View File
@@ -271,6 +271,9 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
(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,
p.alignment, p.pk_mode, p.mount_vnum);
// §2.5 (W1↔W2 G1 wiring): affect flags travel in their own two-word
// segment, not through mut_char_info.
m_world.mut_affect_flags(p.vid, p.affect_flag[0], p.affect_flag[1]);
return true;
}
case HDR_GC_CHARACTER_DEL: { // 2
@@ -286,9 +289,11 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
if (!fill(p, header, body, len)) {
return false;
}
// classic wire heading: deg = bRot * 5 (client NetStream Send/Recv Move)
// classic wire heading: deg = bRot * 5 (client NetStream Send/Recv Move).
// §3.2: p.arg is the motion index, p.time the server command time that
// gates when the queued state command is released.
m_world.mut_move(p.vid, (float)p.rot * 5.0f, p.func, (float)p.x, (float)p.y,
p.duration);
p.duration, p.arg, p.time);
return true;
}
case HDR_GC_CHARACTER_POINTS: { // 16
@@ -367,6 +372,8 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
}
m_world.mut_char_update(p.vid, p.parts, p.moving_speed, p.attack_speed,
(int32_t)p.guild_id, p.alignment, p.pk_mode, p.mount_vnum);
// §2.5 (W1↔W2 G1 wiring): mut_char_update carries no affect segment.
m_world.mut_affect_flags(p.vid, p.affect_flag[0], p.affect_flag[1]);
return true;
}
case HDR_GC_CHARACTER_POSITION: { // 43
@@ -687,9 +694,16 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
m_world.mut_exchange_start(p.arg1);
break;
case EXCHANGE_GC_ITEM_ADD:
{
ItemAttr attrs[ITEM_ATTRIBUTE_MAX_NUM]{};
for (int i = 0; i < ITEM_ATTRIBUTE_MAX_NUM; ++i) {
attrs[i].type = p.attrs[i].type;
attrs[i].value = p.attrs[i].value;
}
m_world.mut_exchange_item(self, static_cast<uint8_t>(p.arg2.cell), p.arg1,
static_cast<uint8_t>(p.arg3));
static_cast<uint8_t>(p.arg3), p.sockets, attrs);
break;
}
case EXCHANGE_GC_ITEM_DEL:
m_world.mut_exchange_item_del(self, static_cast<uint8_t>(p.arg1));
break;
@@ -301,6 +301,10 @@ void ClassicSession::pump() {
set_stage(Stage::Failed);
}
}
// §3.2 — feed the server frame clock (from the GC_HANDSHAKE lDelta exchange)
// so EntityStore::drain_state_queue() gates queued StateCmds on server time
// instead of releasing immediately. 0 until the first handshake lands.
m_world.set_server_frame_ms(m_stream.server_frame_ms());
m_world.tick();
}
@@ -45,6 +45,16 @@ void ClassicStream::set_state(State s) {
}
}
uint32_t ClassicStream::server_frame_ms() const {
// §3.2 — mirror EterLib ELTimer_GetServerFrameMSec(): the handshake stored
// (m_server_time_base, m_client_time_base); the server clock advances with
// local wall time from there. 0 until the first handshake lands.
if (m_client_time_base == 0) {
return 0;
}
return now_ms() - m_client_time_base + m_server_time_base;
}
// --------------------------------------------------------------------- socket
bool ClassicStream::connect(const std::string &host, uint16_t port) {
disconnect();
@@ -99,6 +99,14 @@ public:
bool cipher_active() const { return m_cipher.activated(); }
void set_polarity(bool client) { m_polarity = client; }
// §3.2 — ELTimer_GetServerFrameMSec() equivalent. The GC_HANDSHAKE exchange
// (handle_control HDR_HANDSHAKE) stores m_server_time_base = hs.time + lDelta
// and m_client_time_base = local now; the server frame clock is then
// local_now - m_client_time_base + m_server_time_base. Returns 0 until the
// first handshake has landed (EntityStore treats 0 as "release immediately").
uint32_t server_frame_ms() const;
bool has_server_clock() const { return m_client_time_base != 0; }
private:
void set_state(State s);
bool recv_into_buffer();
+184 -34
View File
@@ -1,6 +1,7 @@
#include "entity_store.h"
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
@@ -65,25 +66,6 @@ void EntityStore::mark_vitals(uint32_t vid) {
m_vitals.push_back(vid);
}
void EntityStore::start_move(Entity &e, float tx, float ty, uint32_t start_ms, uint32_t dur_ms,
uint8_t func) {
e.func = func;
if (func == FUNC_MOVE && dur_ms > 0) {
e.moving = true;
e.sx = e.x;
e.sy = e.y;
e.tx = tx;
e.ty = ty;
e.move_start_ms = start_ms;
e.move_dur_ms = dur_ms;
} else {
// non-move func (attack / wait / skill / instant): snap to the target
e.moving = false;
e.x = tx;
e.y = ty;
}
}
// --- protocol-neutral mutation API (m2dev apply() + classic parser share it) ---
void EntityStore::mut_spawn(uint32_t vid, uint16_t race, uint8_t ch_type, const std::string &name,
@@ -196,18 +178,153 @@ void EntityStore::mut_despawn(uint32_t vid) {
}
}
// CLIENT-GAP §3.2: GC_MOVE no longer mutates position directly. It appends a
// StateCmd to the entity's TCP state queue (reference CInstanceBase::PushTCPState)
// and then drains anything already due, so a command whose chk_time is in the
// 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) {
uint32_t dur_ms, uint8_t arg, uint32_t chk_time) {
auto it = m_ents.find(vid);
if (it == m_ents.end()) {
return;
}
Entity &e = it->second;
e.angle = angle_deg;
start_move(e, tx, ty, m_now, dur_ms, func);
e.state_queue.push_back({chk_time, tx, ty, angle_deg, func, arg, dur_ms});
drain_state_queue(e);
m_changes.push_back({ChangeKind::Move, vid});
}
// ~ CInstanceBase::__CanProcessNetworkStatePacket: a dead or knocked-down actor
// holds its queue (the pending commands stay, they just don't fire yet).
bool EntityStore::can_process_network_state(const Entity &e) {
return !e.dead && !e.knock_down;
}
// ~ CInstanceBase::__IsEnableTCPProcess: the reference blocks TCP processing
// during an un-cancellable emote. We have no emote-lock state on the row yet, so
// this is always true for now (documented deferral, §3.2).
bool EntityStore::is_enable_tcp_process(const Entity &, uint8_t) {
return true;
}
// Kick off a walk toward the command's Dst (reference NEW_SetSrcPixelPosition /
// NEW_SetDstPixelPosition + StartWalking). Records the "after arrival" action and
// enables skip-collision for the duration of the walk.
void EntityStore::begin_state_walk(Entity &e, const StateCmd &c, uint8_t after_func) {
const float dx = c.dst_x - e.x;
const float dy = c.dst_y - e.y;
const float dist = std::sqrt(dx * dx + dy * dy);
// The reference data layer (SNetworkActorData::UpdatePosition) interpolates
// against the server-supplied duration; only when the server omits it do we
// derive one from distance / move speed.
uint32_t dur = c.dur_ms;
if (dur == 0) {
const float speed = e.moving_speed > 0 ? (float)e.moving_speed : 150.0f;
dur = speed > 0.0f ? (uint32_t)(dist / speed * 1000.0f) : 0;
}
if (dur == 0) {
dur = 1; // never a zero-length walk; tick() finishes it next frame
}
e.angle = c.rot;
e.moving = true;
e.func = FUNC_MOVE;
e.sx = e.x;
e.sy = e.y;
e.tx = c.dst_x;
e.ty = c.dst_y;
e.move_start_ms = m_now;
e.move_dur_ms = dur;
e.mov_after_func = after_func;
e.mov_after_arg = c.arg;
e.mov_after_rot = c.rot;
e.skip_collision = true;
}
// The reference eFunc switch (InstanceBase.cpp StateProcess), with its two hard
// thresholds: 1.0 for FUNC_WAIT (walk vs. snap-align) and 50.0 for the attack
// funcs (walk-then-act vs. snap-and-act). No lerp, no jitter buffer.
void EntityStore::apply_state_cmd(Entity &e, const StateCmd &c) {
const float dx = c.dst_x - e.x;
const float dy = c.dst_y - e.y;
const float dir_len = std::sqrt(dx * dx + dy * dy);
switch (c.func) {
case FUNC_WAIT:
if (dir_len > 1.0f) {
begin_state_walk(e, c, FUNC_WAIT);
} else {
e.moving = false;
e.x = c.dst_x;
e.y = c.dst_y;
e.angle = c.rot;
e.func = FUNC_WAIT;
e.mov_after_func = FUNC_WAIT;
e.skip_collision = false;
}
break;
case FUNC_MOVE:
begin_state_walk(e, c, FUNC_MOVE);
break;
case FUNC_COMBO:
case FUNC_ATTACK:
case FUNC_MOB_SKILL:
if (dir_len >= 50.0f) {
begin_state_walk(e, c, c.func);
} else {
e.moving = false;
e.x = c.dst_x;
e.y = c.dst_y;
e.angle = c.rot;
e.func = c.func;
e.mov_after_func = FUNC_WAIT;
e.skip_collision = false;
}
break;
default:
// FUNC_EMOTION / FUNC_SKILL|idx and anything else: snap + set func,
// same as the pre-§3.2 non-move path.
e.moving = false;
e.x = c.dst_x;
e.y = c.dst_y;
e.angle = c.rot;
e.func = c.func;
e.mov_after_func = FUNC_WAIT;
e.skip_collision = false;
break;
}
}
// Release the due prefix of one entity's queue (reference StateProcess while-loop).
void EntityStore::drain_state_queue(Entity &e) {
while (!e.state_queue.empty()) {
const StateCmd &front = e.state_queue.front();
// server-frame gate (reference: ELTimer_GetServerFrameMSec() < m_dwChkTime):
// only when the clock is aligned (non-zero). STATE_QUEUE_MAX_WAIT_MS is a
// staleness valve — a chk_time further ahead than that means the server's
// dwTime is not in our frame-clock timebase (skew / re-stamp mismatch), so
// release now rather than freeze the actor. Real-server dwTime semantics
// still pending validation (§3.2).
if (m_server_frame_ms != 0 && front.chk_time > m_server_frame_ms &&
front.chk_time - m_server_frame_ms <= STATE_QUEUE_MAX_WAIT_MS) {
break;
}
if (!can_process_network_state(e)) {
break;
}
if (!is_enable_tcp_process(e, front.func)) {
break;
}
StateCmd cmd = front;
e.state_queue.pop_front();
apply_state_cmd(e, cmd);
}
}
void EntityStore::process_states() {
for (auto &kv : m_ents) {
drain_state_queue(kv.second);
}
}
void EntityStore::mut_set_points(const int32_t *pts, int n) {
// CLIENT-GAP §2.4: GC_CHARACTER_POINTS is a full snapshot. The wire array is
// exactly POINT_MAX_NUM (255) ints; clamp a longer count and zero the tail of
@@ -444,6 +561,18 @@ void EntityStore::mut_stun(uint32_t vid) {
mark_vitals(vid);
}
void EntityStore::mut_knockdown(uint32_t vid, bool on) {
auto it = m_ents.find(vid);
if (it == m_ents.end()) {
return;
}
if (it->second.knock_down == on) {
return;
}
it->second.knock_down = on;
m_changes.push_back({ChangeKind::Info, vid});
}
void EntityStore::mut_dead(uint32_t vid) {
auto it = m_ents.find(vid);
if (it == m_ents.end()) {
@@ -1246,13 +1375,20 @@ void EntityStore::mut_exchange_start(uint32_t partner_vid) {
m_exchange_dirty = true;
}
void EntityStore::mut_exchange_item(bool self, uint8_t slot, uint32_t vnum, uint8_t count) {
void EntityStore::mut_exchange_item(bool self, uint8_t slot, uint32_t vnum, uint8_t count,
const int32_t *sockets, const ItemAttr *attrs) {
if (slot >= 12) {
return;
}
ExchangeSlot &item = self ? m_exchange.self_items[slot] : m_exchange.peer_items[slot];
item.vnum = vnum;
item.count = count;
for (int i = 0; i < ITEM_SOCKET_SLOT_MAX_NUM; ++i) {
item.sockets[i] = sockets ? sockets[i] : 0;
}
for (int i = 0; i < ITEM_ATTRIBUTE_SLOT_MAX_NUM; ++i) {
item.attrs[i] = attrs ? attrs[i] : ItemAttr{};
}
m_exchange_dirty = true;
}
@@ -1515,16 +1651,10 @@ void EntityStore::apply(uint16_t header, const void *body, uint16_t len) {
}
GCMove p;
std::memcpy(&p, body, sizeof(p));
auto it = m_ents.find(p.vid);
if (it == m_ents.end()) {
return; // move for an unknown entity
}
Entity &e = it->second;
e.angle = (float)p.rot * (360.0f / 256.0f);
// server sends absolute start time + duration; interpolate against m_now
uint32_t dur = p.duration;
start_move(e, (float)p.x, (float)p.y, m_now, dur, p.func);
m_changes.push_back({ChangeKind::Move, p.vid});
// 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,
p.duration, p.arg, p.time);
return;
}
case GC_CHARACTER_POSITION: {
@@ -2443,6 +2573,12 @@ void EntityStore::apply(uint16_t header, const void *body, uint16_t len) {
if (s) {
s->vnum = p.arg1;
s->count = (uint8_t)p.arg3;
for (int i = 0; i < ITEM_SOCKET_SLOT_MAX_NUM; ++i) {
s->sockets[i] = p.sockets[i];
}
for (int i = 0; i < ITEM_ATTRIBUTE_SLOT_MAX_NUM; ++i) {
s->attrs[i] = p.attrs[i];
}
}
break;
}
@@ -3350,6 +3486,9 @@ void EntityStore::tick() {
m_points_dirty = true;
}
}
// CLIENT-GAP §3.2: release any TCP state commands that have come due, then
// advance the walk. A queued command released here starts its walk this tick.
process_states();
for (auto &kv : m_ents) {
Entity &e = kv.second;
if (!e.moving) {
@@ -3365,7 +3504,18 @@ void EntityStore::tick() {
e.x = e.tx;
e.y = e.ty;
e.moving = false;
e.func = FUNC_WAIT;
e.skip_collision = false;
// reference m_kMovAfterFunc: once the walk reaches Dst, run the
// action the state packet asked for (COMBO / ATTACK / MOB_SKILL);
// otherwise settle to WAIT.
if (e.mov_after_func == FUNC_COMBO || e.mov_after_func == FUNC_ATTACK ||
e.mov_after_func == FUNC_MOB_SKILL) {
e.func = e.mov_after_func;
e.angle = e.mov_after_rot;
} else {
e.func = FUNC_WAIT;
}
e.mov_after_func = FUNC_WAIT;
} else {
e.x = e.sx + (e.tx - e.sx) * t;
e.y = e.sy + (e.ty - e.sy) * t;
+67 -4
View File
@@ -11,6 +11,7 @@
#include "wire.h"
#include <cstdint>
#include <deque>
#include <string>
#include <unordered_map>
#include <vector>
@@ -27,6 +28,29 @@ enum : uint8_t {
FUNC_SKILL = 0x80,
};
// CLIENT-GAP §3.2: one entry of a remote actor's TCP state queue, mirroring the
// reference CInstanceBase::PushTCPState / StateProcess model. `chk_time` is the
// server command time (TPacketGCMove::dwTime); the entry is released once the
// store's server-frame clock reaches it. `arg` carries the motion index for the
// FUNC_COMBO / FUNC_SKILL cases.
struct StateCmd {
uint32_t chk_time = 0;
float dst_x = 0, dst_y = 0;
float rot = 0;
uint8_t func = FUNC_WAIT;
uint8_t arg = 0;
uint32_t dur_ms = 0; // server-supplied walk duration; 0 = derive from distance/speed
};
// Staleness valve for the server-frame gate. The reference StateProcess has no
// such cap, but our server-frame clock is not yet validated against a live
// 40250 server (§3.2): if chk_time is stamped in a timebase we don't share, a
// naive "wait until frame >= chk_time" would freeze the remote actor forever.
// A chk_time more than this far ahead of the store's frame clock is treated as
// out-of-band and released immediately. Deliberate deviation, documented in
// CLIENT-GAP-FIX.md §3.2.
static constexpr uint32_t STATE_QUEUE_MAX_WAIT_MS = 1000;
struct Entity {
uint32_t vid = 0;
uint16_t race = 0;
@@ -51,13 +75,24 @@ struct Entity {
uint32_t move_start_ms = 0;
uint32_t move_dur_ms = 0;
// CLIENT-GAP §3.2: TCP state queue + the "do this once the walk reaches Dst"
// latch (reference m_kMovAfterFunc / m_dwMovAfterArg). `skip_collision` mirrors
// __EnableSkipCollision(): a walk kicked off by a network state packet ignores
// actor-vs-actor collision until it arrives.
std::deque<StateCmd> state_queue;
uint8_t mov_after_func = FUNC_WAIT;
uint8_t mov_after_arg = 0;
float mov_after_rot = 0;
bool skip_collision = false;
// combat / status (0 = unknown until the server sends it)
uint8_t attack_speed = 0; // GC_CHARACTER_ADD[2]/UPDATE bAttackSpeed (x100)
int32_t hp = 0, max_hp = 0;
int32_t sp = 0, max_sp = 0;
int32_t level = 0;
bool dead = false;
bool stunned = false;
bool stunned = false; // server AFFECT_STUN (InstanceBaseEffect.cpp:932)
bool knock_down = false; // knockback motion is playing (CActorInstance::IsKnockDown)
uint32_t mount_vnum = 0; // 0 = on foot (GC_MOUNT / GC_CHAR_ADD_INFO)
int32_t guild = 0;
int16_t alignment = 0;
@@ -262,6 +297,8 @@ struct ShopTab {
struct ExchangeSlot {
uint32_t vnum = 0;
uint8_t count = 0;
int32_t sockets[ITEM_SOCKET_SLOT_MAX_NUM] = {0, 0, 0};
ItemAttr attrs[ITEM_ATTRIBUTE_SLOT_MAX_NUM] = {};
};
struct ExchangeState {
bool active = false;
@@ -458,6 +495,16 @@ public:
};
void set_now(uint32_t now_ms) { m_now = now_ms; }
// CLIENT-GAP §3.2: the server-frame clock the TCP state queue releases against
// (reference ELTimer_GetServerFrameMSec). 0 = "not clock-aligned yet" — while
// it is 0 every queued command is released immediately (degrades to the old
// apply-on-arrival behaviour until the handshake lDelta clock is wired, §3.2/5).
void set_server_frame_ms(uint32_t ms) { m_server_frame_ms = ms; }
uint32_t server_frame_ms() const { return m_server_frame_ms; }
// Release every due entry of every entity's TCP state queue. Called from tick()
// and again right after mut_move() enqueues, so an already-due command applies
// synchronously.
void process_states();
// Feed one complete game-phase packet. `body` points at the packet start
// (header/length included); `len` == that length. Unknown headers ignored.
@@ -491,8 +538,12 @@ public:
void mut_despawn(uint32_t vid);
// angle_deg is the already-decoded compass heading (m2dev: rot*360/256;
// classic: rot*5 — the two wire encodings differ, so decode at the parser).
// CLIENT-GAP §3.2: enqueues a StateCmd on the entity's TCP state queue rather
// than mutating the position directly, then drains anything already due.
// `arg` is the motion index (FUNC_COMBO / FUNC_SKILL); `chk_time` is the
// server command time (0 = release immediately).
void mut_move(uint32_t vid, float angle_deg, uint8_t func, float tx, float ty,
uint32_t dur_ms);
uint32_t dur_ms, uint8_t arg = 0, uint32_t chk_time = 0);
// Full stat block (index = POINT_*). `n` <= 256.
void mut_set_points(const int32_t *pts, int n);
// One stat delta (GC_CHARACTER_POINT_CHANGE equivalent).
@@ -523,6 +574,9 @@ public:
void mut_change_speed(uint32_t vid, uint16_t moving_speed);
void mut_walk_mode(uint32_t vid, uint8_t mode);
void mut_stun(uint32_t vid);
// CLIENT-GAP §3.7 — knockback-motion latch (client-only, animation-bound).
// Set true when a knockdown .msa starts, false when it ends; never from a timer.
void mut_knockdown(uint32_t vid, bool on);
void mut_dead(uint32_t vid);
void mut_motion(uint32_t vid, uint32_t victim_vid, uint16_t motion);
void mut_target(uint32_t vid, uint8_t hp_pct);
@@ -613,7 +667,10 @@ public:
// or deny a pending friend request.
void mut_friend_invite(const std::string &name);
void mut_exchange_start(uint32_t partner_vid);
void mut_exchange_item(bool self, uint8_t slot, uint32_t vnum, uint8_t count);
// `sockets` and `attrs` mirror the 40250 GC_EXCHANGE item payload. They are
// optional for synthetic/server-command updates and default to zero.
void mut_exchange_item(bool self, uint8_t slot, uint32_t vnum, uint8_t count,
const int32_t *sockets = nullptr, const ItemAttr *attrs = nullptr);
void mut_exchange_item_del(bool self, uint8_t slot);
void mut_exchange_gold(bool self, int64_t gold);
void mut_exchange_accept(bool self, bool accepted);
@@ -1098,13 +1155,19 @@ public:
private:
Entity &touch(uint32_t vid, bool &created);
void start_move(Entity &e, float tx, float ty, uint32_t start_ms, uint32_t dur_ms, uint8_t func);
void mark_vitals(uint32_t vid);
Item *mut_slot(uint8_t window, uint16_t cell);
// CLIENT-GAP §3.2 helpers for the TCP state queue.
void drain_state_queue(Entity &e); // release due entries for one entity
void apply_state_cmd(Entity &e, const StateCmd &c); // the FUNC_* switch (1.0 / 50.0 thresholds)
void begin_state_walk(Entity &e, const StateCmd &c, uint8_t after_func); // Src/Dst + skip-collision
static bool can_process_network_state(const Entity &e); // ~ __CanProcessNetworkStatePacket
static bool is_enable_tcp_process(const Entity &e, uint8_t func); // ~ __IsEnableTCPProcess
std::unordered_map<uint32_t, Entity> m_ents;
uint32_t m_main_vid = 0;
uint32_t m_now = 0;
uint32_t m_server_frame_ms = 0;
std::vector<Change> m_changes;
std::vector<uint32_t> m_dirty;
std::string m_bgm_name;
+370 -171
View File
@@ -5,6 +5,7 @@
#include "classic/classic_session.h"
#include "game_client.h"
#include "mark_client.h"
#include "net_bounds.h"
#include <godot_cpp/classes/engine.hpp>
#include <godot_cpp/classes/os.hpp>
@@ -26,6 +27,27 @@ using namespace godot;
namespace mtgodot {
namespace {
bool valid_angle(double degrees) {
return std::isfinite(degrees);
}
bool valid_item_pos(int window, int cell) {
return mtnet::bounds::u8(window) && mtnet::bounds::u16(cell);
}
bool read_int(const Dictionary &dict, const char *key, int64_t &value, int64_t fallback) {
const Variant raw = dict.get(key, fallback);
if (raw.get_type() != Variant::INT) {
return false;
}
value = static_cast<int64_t>(raw);
return true;
}
} // namespace
M2Client::M2Client() {
set_process(true);
}
@@ -56,6 +78,7 @@ void M2Client::_bind_methods() {
ClassDB::bind_method(D_METHOD("attack", "motion", "victim_vid"), &M2Client::attack);
ClassDB::bind_method(D_METHOD("set_target", "victim_vid"), &M2Client::set_target);
ClassDB::bind_method(D_METHOD("say", "type", "text"), &M2Client::say);
ClassDB::bind_method(D_METHOD("send_emoticon", "index"), &M2Client::send_emoticon);
ClassDB::bind_method(D_METHOD("whisper", "to", "text"), &M2Client::whisper);
ClassDB::bind_method(D_METHOD("cast_skill", "motion_idx", "rot_deg", "x", "y"), &M2Client::cast_skill);
ClassDB::bind_method(D_METHOD("use_skill", "skill_id", "target_vid"), &M2Client::use_skill);
@@ -134,6 +157,7 @@ void M2Client::_bind_methods() {
PropertyInfo(Variant::DICTIONARY, "entity")));
ADD_SIGNAL(MethodInfo("chat", PropertyInfo(Variant::INT, "type"),
PropertyInfo(Variant::INT, "vid"), PropertyInfo(Variant::STRING, "text")));
ADD_SIGNAL(MethodInfo("emoticon_requested", PropertyInfo(Variant::INT, "index")));
ADD_SIGNAL(MethodInfo("whisper_received", PropertyInfo(Variant::INT, "sub"),
PropertyInfo(Variant::STRING, "from"), PropertyInfo(Variant::STRING, "text")));
// combat / status
@@ -449,6 +473,12 @@ void M2Client::net_poll() {
void M2Client::connect_to_server(const String &auth_host, int auth_port, const String &g_host,
int g_port, const String &id, const String &pw) {
disconnect_from_server();
if (auth_host.is_empty() || g_host.is_empty() || auth_port <= 0 || g_port <= 0 ||
!mtnet::bounds::u16(auth_port) || !mtnet::bounds::u16(g_port)) {
emit_signal("login_failed", String("invalid server address"));
set_stage(Stage::Failed);
return;
}
game_host = g_host;
game_port = g_port;
account_id = id;
@@ -466,6 +496,19 @@ void M2Client::connect_to_server(const String &auth_host, int auth_port, const S
// then connect to game_host with the returned login ticket.
if (OS::get_singleton()->get_environment("MT_PROTOCOL") == "classic") {
classic_sess = std::make_unique<mtnet::classic::ClassicSession>();
// §1.5 (W1 G1 wiring): CG_CLIENT_VERSION2 reports the running binary's
// filename; feed it the real executable name rather than the built-in
// default so the server-side version gate sees what ClientVS22 sends.
classic_sess->set_executable_name(
std::string(OS::get_singleton()->get_executable_path().get_file().utf8().get_data()));
// §2.2 (W1 G1 wiring): non-PC spawn names come from root/npclist.txt.
// M2Client has no asset-root handle, so the parser falls back to
// $MT_ASSETS/$M2_ASSETS (see npc_names.h default_npclist_path()); an
// explicit set_npclist_path() awaits asset-root plumbing into M2Client.
if (const String assets = OS::get_singleton()->get_environment("MT_ASSETS"); !assets.is_empty()) {
classic_sess->parser().set_npclist_path(
std::string(assets.utf8().get_data()) + "/root/npclist.txt");
}
// The 40250 server expects CG_ENTERGAME shortly after the loading phase
// starts. Keep the live client in the same timing window as m2dev while
// allowing a deployment-specific override for unusually large bursts.
@@ -570,14 +613,23 @@ bool M2Client::enter_game(int index) {
bool M2Client::create_character(int slot, const godot::String &name, int job, int shape,
int con, int intel, int str, int dex) {
const std::string name_wire = std::string(name.utf8().get_data());
const size_t name_limit = classic_sess ? mtnet::classic::CHARACTER_NAME_MAX_LEN :
mtnet::CHARACTER_NAME_MAX_LEN;
if (slot < 0 || slot >= mtnet::PLAYER_PER_ACCOUNT4 || name_wire.empty() ||
name_wire.size() > name_limit || !mtnet::bounds::u16(job) || !mtnet::bounds::u8(shape) ||
!mtnet::bounds::u8(con) || !mtnet::bounds::u8(intel) || !mtnet::bounds::u8(str) ||
!mtnet::bounds::u8(dex)) {
return false;
}
if (classic_sess) {
return classic_sess->create_character(slot, std::string(name.utf8().get_data()), job, shape,
return classic_sess->create_character(slot, name_wire, job, shape,
con, intel, str, dex);
}
if (!game) {
return false;
}
return game->create_character(slot, std::string(name.utf8().get_data()), job, shape,
return game->create_character(slot, name_wire, job, shape,
con, intel, str, dex);
}
@@ -682,7 +734,8 @@ godot::Array M2Client::build_char_list() const {
// --- in-game intents ---
bool M2Client::move(int func, int arg, double rot_deg, int x, int y) {
if (!is_in_game()) {
if (!is_in_game() || !mtnet::bounds::u8(func) || !mtnet::bounds::u8(arg) ||
!valid_angle(rot_deg) || !mtnet::bounds::i32(x) || !mtnet::bounds::i32(y)) {
return false;
}
double r = std::fmod(rot_deg, 360.0);
@@ -719,7 +772,7 @@ bool M2Client::request_warp() {
}
bool M2Client::fishing(double rot_deg) {
if (!is_in_game()) {
if (!is_in_game() || !valid_angle(rot_deg)) {
return false;
}
double r = std::fmod(rot_deg, 360.0);
@@ -757,11 +810,17 @@ bool M2Client::sync_positions(const Array &positions) {
if (!d.has("vid") || !d.has("x") || !d.has("y")) {
return false;
}
const int64_t vid = (int64_t)d["vid"];
const int64_t x = (int64_t)d["x"];
const int64_t y = (int64_t)d["y"];
if (vid < 0 || vid > 0xffffffffLL || x < INT32_MIN || x > INT32_MAX ||
y < INT32_MIN || y > INT32_MAX) {
const Variant raw_vid = d["vid"];
const Variant raw_x = d["x"];
const Variant raw_y = d["y"];
if (raw_vid.get_type() != Variant::INT || raw_x.get_type() != Variant::INT ||
raw_y.get_type() != Variant::INT) {
return false;
}
const int64_t vid = (int64_t)raw_vid;
const int64_t x = (int64_t)raw_x;
const int64_t y = (int64_t)raw_y;
if (!mtnet::bounds::u32(vid) || !mtnet::bounds::i32(x) || !mtnet::bounds::i32(y)) {
return false;
}
if (classic_sess) classic_elements.push_back({(uint32_t)vid, (int32_t)x, (int32_t)y});
@@ -773,7 +832,8 @@ bool M2Client::sync_positions(const Array &positions) {
}
bool M2Client::attack(int motion, int victim_vid) {
if (!is_in_game()) {
if (!is_in_game() || !mtnet::bounds::u8(motion) || victim_vid <= 0 ||
!mtnet::bounds::u32(victim_vid)) {
return false;
}
if (classic_sess) {
@@ -783,7 +843,8 @@ bool M2Client::attack(int motion, int victim_vid) {
}
bool M2Client::set_target(int victim_vid) {
if (!is_in_game()) {
// VID 0 is the stock client's explicit "clear target" packet.
if (!is_in_game() || victim_vid < 0 || !mtnet::bounds::u32(victim_vid)) {
return false;
}
if (classic_sess) {
@@ -793,34 +854,65 @@ bool M2Client::set_target(int victim_vid) {
}
bool M2Client::say(int type, const String &text) {
if (!is_in_game()) {
if (!is_in_game() || !mtnet::bounds::u8(type)) {
return false;
}
const std::string text_wire = std::string(text.utf8().get_data());
if ((classic_sess && text_wire.size() >= 512) ||
(!classic_sess && text_wire.size() > 0xffffu - sizeof(mtnet::CGChat) - 1)) {
return false;
}
if (classic_sess) {
return classic_sess->send_chat((uint8_t)type, std::string(text.utf8().get_data()));
return classic_sess->send_chat((uint8_t)type, text_wire);
}
return game && game->send_chat((uint8_t)type, std::string(text.utf8().get_data()));
return game && game->send_chat((uint8_t)type, text_wire);
}
bool M2Client::send_emoticon(int index) {
// Registration order from playersettingmodule.py. The reference client's
// SendEmoticon sends this token through SendChatPacket, while the received
// token is parsed back into the matching visual effect by the chat handler.
static constexpr const char *kTokens[] = {
"(sweat)", "(money)", "(happy)", "(like)", "(love)", "(angry)",
"(aha)", "(sad)", "(sorry)", "(!)", "(?)", "(fish)"
};
constexpr int kTokenCount = sizeof(kTokens) / sizeof(kTokens[0]);
if (index < 0 || index >= kTokenCount || !say(0 /* CHAT_TYPE_TALKING */, String(kTokens[index]))) {
return false;
}
emit_signal("emoticon_requested", index);
return true;
}
bool M2Client::whisper(const String &to, const String &text) {
const std::string to_wire = std::string(to.utf8().get_data());
const std::string text_wire = std::string(text.utf8().get_data());
if (classic_sess) {
return is_in_game() && classic_sess->send_whisper(std::string(to.utf8().get_data()),
std::string(text.utf8().get_data()));
return is_in_game() && !to_wire.empty() &&
to_wire.size() <= mtnet::classic::CHARACTER_NAME_MAX_LEN && text_wire.size() < 512 &&
classic_sess->send_whisper(to_wire, text_wire);
}
if (!game || !is_in_game()) {
if (!game || !is_in_game() || to_wire.empty() ||
to_wire.size() > mtnet::CHARACTER_NAME_MAX_LEN ||
text_wire.size() > 0xffffu - sizeof(mtnet::CGWhisper) - 1) {
return false;
}
return game->send_whisper(std::string(to.utf8().get_data()),
std::string(text.utf8().get_data()));
return game->send_whisper(to_wire, text_wire);
}
bool M2Client::cast_skill(int motion_idx, double rot_deg, int x, int y) {
// FUNC_SKILL | (motion_idx & 0x7F); reuse move()'s rot/time handling.
return move(0x80 | (motion_idx & 0x7F), 0, rot_deg, x, y);
// FUNC_SKILL | motion_idx; do not silently mask an invalid caller value.
if (motion_idx < 0 || motion_idx > 0x7f) {
return false;
}
return move(0x80 | motion_idx, 0, rot_deg, x, y);
}
bool M2Client::use_skill(int skill_id, int target_vid) {
if (!is_in_game() || skill_id <= 0) {
// §3.8 mod 3: signed params, range-checked before the uint32 wire cast.
// skill_id is a real skill index (0 < id < SKILL_MAX_NUM), not a free u32.
if (!is_in_game() || !mtnet::bounds::skill_index(skill_id) || target_vid < 0 ||
!mtnet::bounds::u32(target_vid)) {
return false;
}
if (classic_sess) {
@@ -857,7 +949,8 @@ bool M2Client::shoot(int skill_id) {
}
bool M2Client::add_fly_targeting(int target_vid, int x, int y) {
if (!is_in_game() || target_vid < 0) {
if (!is_in_game() || target_vid < 0 || !mtnet::bounds::u32(target_vid) ||
!mtnet::bounds::i32(x) || !mtnet::bounds::i32(y)) {
return false;
}
if (classic_sess) return classic_sess->send_add_fly_targeting((uint32_t)target_vid, (int32_t)x, (int32_t)y);
@@ -866,6 +959,10 @@ bool M2Client::add_fly_targeting(int target_vid, int x, int y) {
}
bool M2Client::skill_up(int skill_id) {
// §3.9 / §3.10: /skillup 只发正的、低于 SKILL_MAX_NUM 的技能索引(与 use_skill 同一谓词)。
if (!is_in_game() || !mtnet::bounds::skill_index(skill_id)) {
return false;
}
return say(0 /*CHAT_TYPE_TALKING*/, String("/skillup ") + String::num_int64(skill_id));
}
@@ -906,7 +1003,7 @@ Array M2Client::get_quickslots() const {
}
Dictionary d;
d["pos"] = pos;
d["type"] = (int)qs.type; // 1 item / 2 skill / 3 command / 4 emotion
d["type"] = (int)qs.type; // 1 inventory / 2 skill / 3 emotion / 4 shop (not executable)
d["ref"] = (int)qs.position; // item cell / skill id / ...
out.push_back(d);
}
@@ -914,8 +1011,8 @@ Array M2Client::get_quickslots() const {
}
bool M2Client::quickslot_add(int pos, int type, int ref) {
if (!is_in_game() || pos < 0 || pos >= mtnet::QUICKSLOT_MAX_NUM ||
type < 1 || type > 4 || ref < 0 || ref > 255) {
if (!is_in_game() || !mtnet::bounds::quick_slot(pos) || !mtnet::bounds::u8(type) ||
type < 1 || type > 3 || !mtnet::bounds::u8(ref)) {
return false;
}
if (classic_sess) return classic_sess->send_quickslot_add((uint8_t)pos, (uint8_t)type, (uint8_t)ref);
@@ -924,7 +1021,7 @@ bool M2Client::quickslot_add(int pos, int type, int ref) {
}
bool M2Client::quickslot_del(int pos) {
if (!is_in_game() || pos < 0 || pos >= mtnet::QUICKSLOT_MAX_NUM) {
if (!is_in_game() || !mtnet::bounds::quick_slot(pos)) {
return false;
}
if (classic_sess) return classic_sess->send_quickslot_del((uint8_t)pos);
@@ -933,8 +1030,8 @@ bool M2Client::quickslot_del(int pos) {
}
bool M2Client::quickslot_swap(int pos, int change_pos) {
if (!is_in_game() || pos < 0 || pos >= mtnet::QUICKSLOT_MAX_NUM ||
change_pos < 0 || change_pos >= mtnet::QUICKSLOT_MAX_NUM) {
if (!is_in_game() || !mtnet::bounds::quick_slot(pos) ||
!mtnet::bounds::quick_slot(change_pos)) {
return false;
}
if (classic_sess) return classic_sess->send_quickslot_swap((uint8_t)pos, (uint8_t)change_pos);
@@ -943,7 +1040,7 @@ bool M2Client::quickslot_swap(int pos, int change_pos) {
}
bool M2Client::click_npc(int vid) {
if (!is_in_game()) {
if (!is_in_game() || vid <= 0 || !mtnet::bounds::u32(vid)) {
return false;
}
if (classic_sess) {
@@ -952,7 +1049,7 @@ bool M2Client::click_npc(int vid) {
return game && game->send_on_click((uint32_t)vid);
}
bool M2Client::script_answer(int answer) {
if (!is_in_game()) {
if (!is_in_game() || !mtnet::bounds::u8(answer)) {
return false;
}
if (classic_sess) {
@@ -961,11 +1058,14 @@ bool M2Client::script_answer(int answer) {
return game && game->send_script_answer((uint8_t)answer);
}
bool M2Client::script_button(int idx) {
if (classic_sess) return is_in_game() && idx >= 0 && classic_sess->send_script_button((uint32_t)idx);
return game && is_in_game() && game->send_script_button((uint32_t)idx);
if (!is_in_game() || idx < 0 || !mtnet::bounds::u32(idx)) {
return false;
}
if (classic_sess) return classic_sess->send_script_button((uint32_t)idx);
return game && game->send_script_button((uint32_t)idx);
}
bool M2Client::script_select_item(int selection) {
if (!is_in_game() || selection < 0) {
if (!is_in_game() || selection < 0 || !mtnet::bounds::u32(selection)) {
return false;
}
if (classic_sess) return classic_sess->send_script_select_item((uint32_t)selection);
@@ -977,9 +1077,9 @@ bool M2Client::quest_input(const String &text) {
return game && is_in_game() && game->send_quest_input(std::string(text.utf8().get_data()));
}
bool M2Client::quest_confirm(bool yes, int request_pid) {
if (classic_sess) return is_in_game() && request_pid >= 0 &&
if (classic_sess) return is_in_game() && request_pid >= 0 && mtnet::bounds::u32(request_pid) &&
classic_sess->send_quest_confirm(yes, (uint32_t)request_pid);
return game && is_in_game() && request_pid >= 0 &&
return game && is_in_game() && request_pid >= 0 && mtnet::bounds::u32(request_pid) &&
game->send_quest_confirm(yes, (uint32_t)request_pid);
}
bool M2Client::quest_cancel() {
@@ -1015,20 +1115,26 @@ Array M2Client::get_quests() const {
// --- P8 social / shop / storage ---
bool M2Client::party_invite(int vid) {
if (classic_sess) return is_in_game() && vid > 0 && classic_sess->send_party_invite((uint32_t)vid);
return game && is_in_game() && game->send_party_invite((uint32_t)vid);
if (classic_sess) return is_in_game() && vid > 0 && mtnet::bounds::u32(vid) &&
classic_sess->send_party_invite((uint32_t)vid);
return game && is_in_game() && vid > 0 && mtnet::bounds::u32(vid) &&
game->send_party_invite((uint32_t)vid);
}
bool M2Client::party_answer(int leader_pid, bool accept) {
if (classic_sess) return is_in_game() && leader_pid > 0 &&
if (classic_sess) return is_in_game() && leader_pid > 0 && mtnet::bounds::u32(leader_pid) &&
classic_sess->send_party_answer((uint32_t)leader_pid, accept);
return game && is_in_game() && game->send_party_invite_answer((uint32_t)leader_pid, accept);
return game && is_in_game() && leader_pid > 0 && mtnet::bounds::u32(leader_pid) &&
game->send_party_invite_answer((uint32_t)leader_pid, accept);
}
bool M2Client::party_leave(int pid) {
if (classic_sess) return is_in_game() && pid > 0 && classic_sess->send_party_remove((uint32_t)pid);
return game && is_in_game() && pid >= 0 && game->send_party_remove((uint32_t)pid);
if (classic_sess) return is_in_game() && pid > 0 && mtnet::bounds::u32(pid) &&
classic_sess->send_party_remove((uint32_t)pid);
return game && is_in_game() && pid > 0 && mtnet::bounds::u32(pid) &&
game->send_party_remove((uint32_t)pid);
}
bool M2Client::party_use_skill(int skill_index, int target_vid) {
if (!is_in_game() || skill_index < 0 || skill_index > 255 || target_vid < 0) {
if (!is_in_game() || !mtnet::bounds::u8(skill_index) || target_vid < 0 ||
!mtnet::bounds::u32(target_vid)) {
return false;
}
if (classic_sess) return classic_sess->send_party_use_skill((uint8_t)skill_index, (uint32_t)target_vid);
@@ -1036,12 +1142,15 @@ bool M2Client::party_use_skill(int skill_index, int target_vid) {
return game->send_party_use_skill((uint8_t)skill_index, (uint32_t)target_vid);
}
bool M2Client::party_set_distribute(int mode) {
if (classic_sess) return is_in_game() && mode >= 0 && mode <= 255 &&
if (classic_sess) return is_in_game() && mtnet::bounds::u8(mode) &&
classic_sess->send_party_parameter((uint8_t)mode);
return game && is_in_game() && game->send_party_parameter((uint8_t)mode);
return game && is_in_game() && mtnet::bounds::u8(mode) &&
game->send_party_parameter((uint8_t)mode);
}
bool M2Client::party_set_state(int pid, int role, bool on) {
if (!is_in_game() || pid <= 0 || role < 0 || role > 255) return false;
if (!is_in_game() || pid <= 0 || !mtnet::bounds::u32(pid) || role < 0 || role >= 8) {
return false;
}
if (classic_sess) return classic_sess->send_party_set_state((uint32_t)pid, (uint8_t)role, on);
return game && game->send_party_set_state((uint32_t)pid, (uint8_t)role, on ? 1 : 0);
}
@@ -1130,15 +1239,18 @@ Dictionary M2Client::get_lover() const {
}
bool M2Client::shop_buy(int pos, int count) {
if (classic_sess) return is_in_game() && pos >= 0 && pos <= 255 &&
classic_sess->send_shop_buy((uint8_t)pos, (uint8_t)(count < 1 ? 1 : count));
return game && is_in_game() && game->send_shop_buy((uint8_t)pos, (uint8_t)(count < 1 ? 1 : count));
if (!is_in_game() || !mtnet::bounds::u8(pos) || count < 1 || !mtnet::bounds::u8(count)) {
return false;
}
if (classic_sess) return classic_sess->send_shop_buy((uint8_t)pos, (uint8_t)count);
return game && game->send_shop_buy((uint8_t)pos, (uint8_t)count);
}
bool M2Client::shop_sell(int inv_cell, int count) {
if (classic_sess) return is_in_game() && inv_cell >= 0 && inv_cell <= 255 &&
classic_sess->send_shop_sell((uint8_t)inv_cell, (uint8_t)(count < 1 ? 1 : count));
return game && is_in_game() &&
game->send_shop_sell((uint8_t)inv_cell, (uint8_t)(count < 1 ? 1 : count));
if (!is_in_game() || !mtnet::bounds::u8(inv_cell) || count < 0 || !mtnet::bounds::u8(count)) {
return false;
}
if (classic_sess) return classic_sess->send_shop_sell((uint8_t)inv_cell, (uint8_t)count);
return game->send_shop_sell((uint8_t)inv_cell, (uint8_t)count);
}
bool M2Client::shop_close() {
if (classic_sess) return is_in_game() && classic_sess->send_shop_close();
@@ -1206,27 +1318,34 @@ Dictionary M2Client::get_shop() const {
}
bool M2Client::exchange_start(int vid) {
if (classic_sess) {
return is_in_game() && classic_sess->send_exchange_start((uint32_t)vid);
}
return game && is_in_game() && game->send_exchange_start((uint32_t)vid);
}
bool M2Client::exchange_add_item(int inv_window, int inv_cell, int display_pos) {
if (classic_sess) {
return is_in_game() && classic_sess->send_exchange_item_add((uint8_t)inv_window,
(uint16_t)inv_cell, (uint8_t)display_pos);
}
return game && is_in_game() &&
game->send_exchange_item_add((uint8_t)inv_window, (uint16_t)inv_cell, (uint8_t)display_pos);
}
bool M2Client::exchange_add_gold(int gold) {
if (gold < 0) {
if (!is_in_game() || !mtnet::bounds::u32(vid) || vid <= 0) {
return false;
}
if (classic_sess) {
return is_in_game() && classic_sess->send_exchange_gold((uint32_t)gold);
return classic_sess->send_exchange_start((uint32_t)vid);
}
return game && is_in_game() && game->send_exchange_gold((uint32_t)gold);
return game && game->send_exchange_start((uint32_t)vid);
}
bool M2Client::exchange_add_item(int inv_window, int inv_cell, int display_pos) {
if (!is_in_game() || !valid_item_pos(inv_window, inv_cell) ||
!mtnet::bounds::exchange_slot(display_pos)) {
return false;
}
if (classic_sess) {
return classic_sess->send_exchange_item_add((uint8_t)inv_window, (uint16_t)inv_cell,
(uint8_t)display_pos);
}
return game && game->send_exchange_item_add((uint8_t)inv_window, (uint16_t)inv_cell,
(uint8_t)display_pos);
}
bool M2Client::exchange_add_gold(int gold) {
if (!is_in_game() || !mtnet::bounds::u32(gold)) {
return false;
}
if (classic_sess) {
return classic_sess->send_exchange_gold((uint32_t)gold);
}
return game && game->send_exchange_gold((uint32_t)gold);
}
bool M2Client::exchange_accept() {
if (classic_sess) {
@@ -1260,10 +1379,26 @@ Dictionary M2Client::get_exchange() const {
continue;
}
Dictionary s;
s["slot"] = i;
s["vnum"] = (int)arr[i].vnum;
s["count"] = (int)arr[i].count;
a.push_back(s);
s["slot"] = i;
s["vnum"] = (int)arr[i].vnum;
s["count"] = (int)arr[i].count;
Array sockets;
for (int j = 0; j < mtnet::ITEM_SOCKET_SLOT_MAX_NUM; ++j) {
sockets.push_back(arr[i].sockets[j]);
}
s["sockets"] = sockets;
Array attrs;
for (int j = 0; j < mtnet::ITEM_ATTRIBUTE_SLOT_MAX_NUM; ++j) {
if (arr[i].attrs[j].type == 0) {
continue;
}
Dictionary a;
a["type"] = (int)arr[i].attrs[j].type;
a["value"] = (int)arr[i].attrs[j].value;
attrs.push_back(a);
}
s["attrs"] = attrs;
a.push_back(s);
}
return a;
};
@@ -1273,28 +1408,37 @@ Dictionary M2Client::get_exchange() const {
}
bool M2Client::safebox_checkin(int safe_pos, int inv_window, int inv_cell) {
if (classic_sess) {
return is_in_game() && classic_sess->send_safebox_checkin((uint8_t)safe_pos,
(uint8_t)inv_window, (uint16_t)inv_cell);
if (!is_in_game() || !mtnet::bounds::u8(safe_pos) || !valid_item_pos(inv_window, inv_cell)) {
return false;
}
return game && is_in_game() &&
game->send_safebox_checkin((uint8_t)safe_pos, (uint8_t)inv_window, (uint16_t)inv_cell);
if (classic_sess) {
return classic_sess->send_safebox_checkin((uint8_t)safe_pos, (uint8_t)inv_window,
(uint16_t)inv_cell);
}
return game && game->send_safebox_checkin((uint8_t)safe_pos, (uint8_t)inv_window,
(uint16_t)inv_cell);
}
bool M2Client::safebox_checkout(int safe_pos, int inv_window, int inv_cell) {
if (classic_sess) {
return is_in_game() && classic_sess->send_safebox_checkout((uint8_t)safe_pos,
(uint8_t)inv_window, (uint16_t)inv_cell);
if (!is_in_game() || !mtnet::bounds::u8(safe_pos) || !valid_item_pos(inv_window, inv_cell)) {
return false;
}
return game && is_in_game() &&
game->send_safebox_checkout((uint8_t)safe_pos, (uint8_t)inv_window, (uint16_t)inv_cell);
if (classic_sess) {
return classic_sess->send_safebox_checkout((uint8_t)safe_pos, (uint8_t)inv_window,
(uint16_t)inv_cell);
}
return game && game->send_safebox_checkout((uint8_t)safe_pos, (uint8_t)inv_window,
(uint16_t)inv_cell);
}
bool M2Client::safebox_move(int from_cell, int to_cell, int count) {
if (classic_sess) {
return is_in_game() && classic_sess->send_safebox_move((uint16_t)from_cell,
(uint16_t)to_cell, (uint8_t)(count < 1 ? 1 : count));
if (!is_in_game() || !mtnet::bounds::u16(from_cell) || !mtnet::bounds::u16(to_cell) ||
!mtnet::bounds::u8(count)) {
return false;
}
return game && is_in_game() &&
game->send_safebox_move((uint16_t)from_cell, (uint16_t)to_cell, (uint8_t)(count < 1 ? 1 : count));
if (classic_sess) {
return classic_sess->send_safebox_move((uint16_t)from_cell, (uint16_t)to_cell,
(uint8_t)count);
}
return game && game->send_safebox_move((uint16_t)from_cell, (uint16_t)to_cell, (uint8_t)count);
}
bool M2Client::is_safebox_open() const {
const mtnet::EntityStore *world = active_world();
@@ -1358,9 +1502,12 @@ Array M2Client::get_mall_items() const {
return out;
}
bool M2Client::mall_checkout(int mall_pos, int inv_window, int inv_cell) {
if (!is_in_game() || !mtnet::bounds::u8(mall_pos) || !valid_item_pos(inv_window, inv_cell)) {
return false;
}
if (classic_sess) {
return is_in_game() && classic_sess->send_mall_checkout((uint8_t)mall_pos,
(uint8_t)inv_window, (uint16_t)inv_cell);
return classic_sess->send_mall_checkout((uint8_t)mall_pos, (uint8_t)inv_window,
(uint16_t)inv_cell);
}
return game && game->send_mall_checkout((uint8_t)mall_pos, (uint8_t)inv_window,
(uint16_t)inv_cell);
@@ -1383,38 +1530,76 @@ bool M2Client::mall_password(const String &password) {
// --- private (PC) shop ---
bool M2Client::open_private_shop(const godot::String &sign, const godot::Array &items) {
if (!game && !classic_sess) {
if (!is_in_game() || (!game && !classic_sess) ||
items.size() > mtnet::bounds::PRIVATE_SHOP_ITEM_COUNT) {
return false;
}
const std::string sign_wire = std::string(sign.utf8().get_data());
if (sign_wire.size() > mtnet::SHOP_SIGN_MAX_LEN) {
return false;
}
struct PrivateShopItemInput {
uint32_t vnum;
uint8_t count;
uint8_t inv_window;
uint16_t inv_cell;
uint32_t price;
uint8_t display_pos;
};
std::vector<PrivateShopItemInput> parsed;
parsed.reserve(items.size());
for (int i = 0; i < items.size(); ++i) {
const Variant raw = items[i];
if (raw.get_type() != Variant::DICTIONARY) {
return false;
}
const Dictionary d = raw;
int64_t vnum = 0;
int64_t count = 1;
int64_t inv_window = mtnet::WINDOW_INVENTORY;
int64_t inv_cell = 0;
int64_t price = 0;
int64_t display_pos = i;
if (!read_int(d, "vnum", vnum, 0) || !read_int(d, "count", count, 1) ||
!read_int(d, "inv_window", inv_window, mtnet::WINDOW_INVENTORY) ||
!read_int(d, "inv_cell", inv_cell, 0) || !read_int(d, "price", price, 0) ||
!read_int(d, "display_pos", display_pos, i) || !mtnet::bounds::u32(vnum) ||
!mtnet::bounds::u8(count) || count < 1 || !mtnet::bounds::u8(inv_window) ||
!mtnet::bounds::u16(inv_cell) || !mtnet::bounds::u32(price) ||
display_pos < 0 || !mtnet::bounds::shop_slot(display_pos)) {
return false;
}
parsed.push_back({(uint32_t)vnum, (uint8_t)count, (uint8_t)inv_window,
(uint16_t)inv_cell, (uint32_t)price, (uint8_t)display_pos});
}
if (classic_sess) {
std::vector<mtnet::classic::CGMyShopItem> v;
v.reserve(items.size());
for (int i = 0; i < items.size() && i < 39; ++i) {
Dictionary d = items[i];
v.reserve(parsed.size());
for (const auto &in : parsed) {
mtnet::classic::CGMyShopItem e{};
e.vnum = (uint32_t)(int64_t)d.get("vnum", 0);
e.count = (uint8_t)(int)d.get("count", 1);
e.pos = {(uint8_t)(int)d.get("inv_window", mtnet::WINDOW_INVENTORY),
(uint16_t)(int)d.get("inv_cell", 0)};
e.price = (uint32_t)(int64_t)d.get("price", 0);
e.display_pos = (uint8_t)(int)d.get("display_pos", i);
e.vnum = in.vnum;
e.count = in.count;
e.pos = {in.inv_window, in.inv_cell};
e.price = in.price;
e.display_pos = in.display_pos;
v.push_back(e);
}
return classic_sess->send_private_shop(std::string(sign.utf8().get_data()), v);
return classic_sess->send_private_shop(sign_wire, v);
}
std::vector<mtnet::MyShopItem> v;
for (int i = 0; i < items.size(); ++i) {
Dictionary d = items[i];
v.reserve(parsed.size());
for (const auto &in : parsed) {
mtnet::MyShopItem e{};
e.vnum = (uint32_t)(int64_t)d.get("vnum", 0);
e.count = (uint8_t)(int)d.get("count", 1);
e.pos = {(uint8_t)(int)d.get("inv_window", mtnet::WINDOW_INVENTORY),
(uint16_t)(int)d.get("inv_cell", 0)};
e.price = (uint32_t)(int64_t)d.get("price", 0);
e.display_pos = (uint8_t)(int)d.get("display_pos", i);
e.vnum = in.vnum;
e.count = in.count;
e.pos = {in.inv_window, in.inv_cell};
e.price = in.price;
e.display_pos = in.display_pos;
v.push_back(e);
}
return game->send_open_private_shop(std::string(sign.utf8().get_data()), v);
return game->send_open_private_shop(sign_wire, v);
}
bool M2Client::close_private_shop() {
if (classic_sess) {
@@ -1474,15 +1659,25 @@ Dictionary M2Client::get_cube() const {
return d;
}
bool M2Client::cube_make(int result_index) {
if (classic_sess) return is_in_game() && classic_sess->send_cube_make(result_index);
if (!is_in_game() || result_index < 0 || !mtnet::bounds::u32(result_index)) {
return false;
}
if (classic_sess) return classic_sess->send_cube_make(result_index);
return game && game->send_cube_make(result_index);
}
bool M2Client::cube_request_result_list(int npc_vnum) {
if (classic_sess) return is_in_game() && classic_sess->send_cube_result_list(npc_vnum);
if (!is_in_game() || !mtnet::bounds::u32(npc_vnum)) {
return false;
}
if (classic_sess) return classic_sess->send_cube_result_list(npc_vnum);
return game && game->send_cube_result_list((uint32_t)npc_vnum);
}
bool M2Client::cube_request_materials(int start_index, int count) {
if (classic_sess) return is_in_game() && classic_sess->send_cube_materials(start_index, count);
if (!is_in_game() || !mtnet::bounds::u8(start_index) || count < 1 ||
!mtnet::bounds::u8(count)) {
return false;
}
if (classic_sess) return classic_sess->send_cube_materials(start_index, count);
return game && game->send_cube_material_info(start_index, count);
}
bool M2Client::cube_open() {
@@ -1498,20 +1693,19 @@ bool M2Client::cube_list() {
return game && is_in_game() && game->send_chat(mtnet::CHAT_TYPE_COMMAND, "/cube list");
}
bool M2Client::cube_add_item(int cube_index, int inventory_index) {
if (classic_sess) {
return is_in_game() && classic_sess->send_cube_add_item(cube_index, inventory_index);
}
if (!game || !is_in_game() || cube_index < 0 || cube_index >= 24 ||
inventory_index < 0 || inventory_index > 255) {
if (!is_in_game() || !mtnet::bounds::cube_slot(cube_index) ||
!mtnet::bounds::u8(inventory_index)) {
return false;
}
if (classic_sess) return classic_sess->send_cube_add_item(cube_index, inventory_index);
if (!game) return false;
return game->send_chat(mtnet::CHAT_TYPE_COMMAND, "/cube add " + std::to_string(cube_index) +
" " + std::to_string(inventory_index));
}
bool M2Client::cube_delete_item(int cube_index) {
if (classic_sess) return is_in_game() && classic_sess->send_cube_delete_item(cube_index);
return game && is_in_game() && cube_index >= 0 && cube_index < 24 &&
game->send_chat(mtnet::CHAT_TYPE_COMMAND, "/cube delete " + std::to_string(cube_index));
if (!is_in_game() || !mtnet::bounds::cube_slot(cube_index)) return false;
if (classic_sess) return classic_sess->send_cube_delete_item(cube_index);
return game && game->send_chat(mtnet::CHAT_TYPE_COMMAND, "/cube delete " + std::to_string(cube_index));
}
// --- guild / refine ---
@@ -1571,23 +1765,25 @@ Array M2Client::get_guild_grades() const {
return out;
}
bool M2Client::guild_add_member(int vid) {
if (classic_sess) return is_in_game() && vid > 0 &&
if (classic_sess) return is_in_game() && vid > 0 && mtnet::bounds::u32(vid) &&
classic_sess->send_guild_u32(mtnet::classic::GUILD_CG_ADD_MEMBER, (uint32_t)vid);
return game && is_in_game() && game->send_guild_sub_u32(mtnet::GUILD_CG_ADD_MEMBER, (uint32_t)vid);
return game && is_in_game() && vid > 0 && mtnet::bounds::u32(vid) &&
game->send_guild_sub_u32(mtnet::GUILD_CG_ADD_MEMBER, (uint32_t)vid);
}
bool M2Client::guild_remove_member(int pid) {
if (classic_sess) return is_in_game() && pid > 0 &&
if (classic_sess) return is_in_game() && pid > 0 && mtnet::bounds::u32(pid) &&
classic_sess->send_guild_u32(mtnet::classic::GUILD_CG_REMOVE_MEMBER, (uint32_t)pid);
return game && is_in_game() &&
return game && is_in_game() && pid > 0 && mtnet::bounds::u32(pid) &&
game->send_guild_sub_u32(mtnet::GUILD_CG_REMOVE_MEMBER, (uint32_t)pid);
}
bool M2Client::guild_offer(int amount) {
if (classic_sess) return is_in_game() && amount >= 0 &&
if (classic_sess) return is_in_game() && mtnet::bounds::u32(amount) &&
classic_sess->send_guild_u32(mtnet::classic::GUILD_CG_OFFER, (uint32_t)amount);
return game && is_in_game() && game->send_guild_sub_u32(mtnet::GUILD_CG_OFFER, (uint32_t)amount);
return game && is_in_game() && mtnet::bounds::u32(amount) &&
game->send_guild_sub_u32(mtnet::GUILD_CG_OFFER, (uint32_t)amount);
}
bool M2Client::guild_charge_gsp(int amount) {
if (amount < 0) return false;
if (!mtnet::bounds::i32(amount)) return false;
if (classic_sess) return is_in_game() &&
classic_sess->send_guild_i32(mtnet::classic::GUILD_CG_CHARGE_GSP, (int32_t)amount);
return game && is_in_game() &&
@@ -1602,21 +1798,21 @@ bool M2Client::guild_change_grade_name(int grade, const String &name) {
return game && is_in_game() && game->send_guild_grade_name((uint8_t)grade, value);
}
bool M2Client::guild_change_grade_authority(int grade, int authority) {
if (grade < 1 || grade >= 16 || authority < 0 || authority > 255) return false;
if (grade < 1 || grade >= 16 || !mtnet::bounds::u8(authority)) return false;
if (classic_sess) return is_in_game() &&
classic_sess->send_guild_grade_authority((uint8_t)grade, (uint8_t)authority);
return game && is_in_game() &&
game->send_guild_grade_authority((uint8_t)grade, (uint8_t)authority);
}
bool M2Client::guild_change_member_grade(int pid, int grade) {
if (pid <= 0 || grade < 1 || grade >= 16) return false;
if (pid <= 0 || !mtnet::bounds::u32(pid) || grade < 1 || grade >= 16) return false;
if (classic_sess) return is_in_game() &&
classic_sess->send_guild_member_grade((uint32_t)pid, (uint8_t)grade);
return game && is_in_game() &&
game->send_guild_member_grade((uint32_t)pid, (uint8_t)grade);
}
bool M2Client::guild_change_member_general(int pid, bool enabled) {
if (pid <= 0) return false;
if (pid <= 0 || !mtnet::bounds::u32(pid)) return false;
if (classic_sess) return is_in_game() &&
classic_sess->send_guild_member_general((uint32_t)pid, enabled);
return game && is_in_game() &&
@@ -1629,7 +1825,7 @@ bool M2Client::guild_post_comment(const String &text) {
return game && is_in_game() && game->send_guild_comment(comment);
}
bool M2Client::guild_delete_comment(int comment_id) {
if (comment_id <= 0) return false;
if (comment_id <= 0 || !mtnet::bounds::u32(comment_id)) return false;
if (classic_sess) return is_in_game() &&
classic_sess->send_guild_u32(mtnet::classic::GUILD_CG_DELETE_COMMENT,
(uint32_t)comment_id);
@@ -1642,7 +1838,7 @@ bool M2Client::guild_refresh_comments() {
return game && is_in_game() && game->send_guild_sub(mtnet::GUILD_CG_REFRESH_COMMENT);
}
bool M2Client::guild_answer_invite(int guild_id, bool accept) {
if (guild_id <= 0) return false;
if (guild_id <= 0 || !mtnet::bounds::u32(guild_id)) return false;
if (classic_sess) return is_in_game() &&
classic_sess->send_guild_invite_answer((uint32_t)guild_id, accept);
return game && is_in_game() && game->send_guild_invite_answer((uint32_t)guild_id, accept);
@@ -1726,10 +1922,11 @@ String M2Client::get_guild_name(int guild_id) const {
}
bool M2Client::use_guild_skill(int skill_vnum, int target_vid) {
if (classic_sess) return is_in_game() && skill_vnum >= 0 && target_vid >= 0 &&
classic_sess->send_guild_skill((uint32_t)skill_vnum, (uint32_t)target_vid);
return game && is_in_game() &&
game->send_guild_use_skill((uint32_t)skill_vnum, (uint32_t)target_vid);
if (!is_in_game() || !mtnet::bounds::u32(skill_vnum) || !mtnet::bounds::u32(target_vid)) {
return false;
}
if (classic_sess) return classic_sess->send_guild_skill((uint32_t)skill_vnum, (uint32_t)target_vid);
return game && game->send_guild_use_skill((uint32_t)skill_vnum, (uint32_t)target_vid);
}
bool M2Client::declare_guild_war(const String &guild_name) {
@@ -1739,7 +1936,7 @@ bool M2Client::declare_guild_war(const String &guild_name) {
// --- guild marks ---
bool M2Client::download_guild_marks(const String &host, int port) {
if ((!game && !classic_sess) || port <= 0 || port > 65535 || host.is_empty()) {
if ((!game && !classic_sess) || port <= 0 || !mtnet::bounds::u16(port) || host.is_empty()) {
return false;
}
mark_host = host;
@@ -1777,7 +1974,8 @@ bool M2Client::download_guild_marks(const String &host, int port) {
}
bool M2Client::download_guild_symbol(const String &host, int port, int guild_id) {
if ((!game && !classic_sess) || port <= 0 || port > 65535 || host.is_empty() || guild_id <= 0) {
if ((!game && !classic_sess) || port <= 0 || !mtnet::bounds::u16(port) || host.is_empty() ||
guild_id <= 0 || !mtnet::bounds::u32(guild_id)) {
return false;
}
symbol_guild_id = 0;
@@ -1838,7 +2036,8 @@ Dictionary M2Client::get_mark_server() const {
}
bool M2Client::upload_guild_mark(const String &host, int port, int guild_id, const Ref<Image> &img) {
if ((!game && !classic_sess) || port <= 0 || port > 65535 || host.is_empty() || img.is_null()) {
if ((!game && !classic_sess) || port <= 0 || !mtnet::bounds::u16(port) || host.is_empty() ||
guild_id <= 0 || !mtnet::bounds::u32(guild_id) || img.is_null()) {
return false;
}
Ref<Image> rgba = img;
@@ -1893,7 +2092,8 @@ bool M2Client::upload_guild_mark(const String &host, int port, int guild_id, con
bool M2Client::upload_guild_symbol(const String &host, int port, int guild_id,
const PackedByteArray &file_bytes) {
if ((!game && !classic_sess) || port <= 0 || port > 65535 || host.is_empty() || file_bytes.is_empty()) {
if ((!game && !classic_sess) || port <= 0 || !mtnet::bounds::u16(port) || host.is_empty() ||
guild_id <= 0 || !mtnet::bounds::u32(guild_id) || file_bytes.is_empty()) {
return false;
}
std::vector<uint8_t> bytes(file_bytes.ptr(), file_bytes.ptr() + file_bytes.size());
@@ -2059,9 +2259,11 @@ void M2Client::pump_classic_mark() {
}
}
bool M2Client::refine(int pos, int type) {
if (classic_sess) return is_in_game() && pos >= 0 && pos < mtnet::INVENTORY_MAX_NUM &&
type >= 0 && type <= 255 && classic_sess->send_refine((uint8_t)pos, (uint8_t)type);
return game && is_in_game() && game->send_refine((uint8_t)pos, (uint8_t)type);
if (!is_in_game() || pos < 0 || pos >= mtnet::INVENTORY_MAX_NUM || !mtnet::bounds::u8(type)) {
return false;
}
if (classic_sess) return classic_sess->send_refine((uint8_t)pos, (uint8_t)type);
return game && game->send_refine((uint8_t)pos, (uint8_t)type);
}
bool M2Client::ds_refine(int mode, const Array &cells) {
@@ -2077,7 +2279,7 @@ bool M2Client::ds_refine(int mode, const Array &cells) {
if (classic_sess) {
mtnet::classic::ItemPos grid[mtnet::classic::DS_REFINE_GRID_MAX_NUM] = {};
int n = (int)cells.size();
if (n > mtnet::classic::DS_REFINE_GRID_MAX_NUM) n = mtnet::classic::DS_REFINE_GRID_MAX_NUM;
if (n > mtnet::classic::DS_REFINE_GRID_MAX_NUM) return false;
for (int i = 0; i < n; ++i) {
int cell = (int)cells[i];
if (cell < 0 || cell > 0xffff) return false;
@@ -2088,11 +2290,10 @@ bool M2Client::ds_refine(int mode, const Array &cells) {
if (!game) return false;
mtnet::ItemPos grid[mtnet::DS_REFINE_WINDOW_MAX_NUM] = {};
int n = (int)cells.size();
if (n > mtnet::DS_REFINE_WINDOW_MAX_NUM) {
n = mtnet::DS_REFINE_WINDOW_MAX_NUM;
}
if (n > mtnet::DS_REFINE_WINDOW_MAX_NUM) return false;
for (int i = 0; i < n; ++i) {
int cell = (int)cells[i];
if (cell < 0 || cell > 0xffff) return false;
grid[i].window_type = mtnet::WINDOW_INVENTORY;
grid[i].cell = (uint16_t)cell;
}
@@ -2246,6 +2447,7 @@ static Dictionary entity_dict(const mtnet::Entity &e) {
d["level"] = e.level;
d["dead"] = e.dead;
d["stunned"] = e.stunned;
d["knock_down"] = e.knock_down;
d["mount_vnum"] = (int)e.mount_vnum;
d["guild"] = e.guild;
d["alignment"] = (int)e.alignment;
@@ -2324,6 +2526,11 @@ Dictionary M2Client::get_points() const {
d["next_exp"] = (int64_t)(uint32_t)p.next_exp();
d["gold"] = p.gold();
d["attack_speed"] = p.v[mtnet::POINT_ATT_SPEED];
d["bow_distance"] = p.v[mtnet::POINT_BOW_DISTANCE]; // __GetBowRange() 距离加成
// §3.10 加号按钮门控:三个技能点来源分开取(uicharacter.py skillPageStatDict)。
d["skill_active"] = p.v[mtnet::POINT_SKILL];
d["skill_support"] = p.v[mtnet::POINT_SUB_SKILL];
d["skill_horse"] = p.v[mtnet::POINT_HORSE_SKILL];
d["energy"] = p.energy();
d["energy_end_time"] = p.energy_end_time();
Array values;
@@ -2370,7 +2577,8 @@ Array M2Client::get_affects() const {
// --- items ---
bool M2Client::move_item(int fw, int fc, int tw, int tc, int count) {
if (!is_in_game()) {
if (!is_in_game() || !valid_item_pos(fw, fc) || !valid_item_pos(tw, tc) ||
!mtnet::bounds::u8(count)) {
return false;
}
if (classic_sess) {
@@ -2381,7 +2589,7 @@ bool M2Client::move_item(int fw, int fc, int tw, int tc, int count) {
(uint8_t)count);
}
bool M2Client::use_item(int window, int cell) {
if (!is_in_game()) {
if (!is_in_game() || !valid_item_pos(window, cell)) {
return false;
}
if (classic_sess) {
@@ -2390,7 +2598,7 @@ bool M2Client::use_item(int window, int cell) {
return game && game->send_item_use((uint8_t)window, (uint16_t)cell);
}
bool M2Client::drop_item(int window, int cell, int gold) {
if (!is_in_game()) {
if (!is_in_game() || !valid_item_pos(window, cell) || !mtnet::bounds::u32(gold)) {
return false;
}
if (classic_sess) {
@@ -2399,7 +2607,8 @@ bool M2Client::drop_item(int window, int cell, int gold) {
return game && game->send_item_drop((uint8_t)window, (uint16_t)cell, (uint32_t)gold);
}
bool M2Client::drop_item_count(int window, int cell, int gold, int count) {
if (!is_in_game() || count < 1 || count > 255) return false;
if (!is_in_game() || !valid_item_pos(window, cell) || !mtnet::bounds::u32(gold) ||
count < 1 || !mtnet::bounds::u8(count)) return false;
if (classic_sess) {
return classic_sess->send_item_drop((uint8_t)window, (uint16_t)cell, (uint32_t)gold,
(uint8_t)count);
@@ -2408,19 +2617,17 @@ bool M2Client::drop_item_count(int window, int cell, int gold, int count) {
(uint8_t)count);
}
bool M2Client::use_item_to_item(int sw, int sc, int tw, int tc) {
if (!is_in_game()) return false;
if (!is_in_game() || !valid_item_pos(sw, sc) || !valid_item_pos(tw, tc)) return false;
if (classic_sess) {
if (sw < 0 || sw > 255 || tw < 0 || tw > 255 || sc < 0 || sc > 0xffff || tc < 0 || tc > 0xffff)
return false;
return classic_sess->send_item_use_to_item((uint8_t)sw, (uint16_t)sc, (uint8_t)tw, (uint16_t)tc);
}
if (!game) return false;
return game->send_item_use_to_item((uint8_t)sw, (uint16_t)sc, (uint8_t)tw, (uint16_t)tc);
}
bool M2Client::give_item(int target_vid, int window, int cell, int count) {
if (!is_in_game() || target_vid <= 0 || count < 1 || count > 255) return false;
if (!is_in_game() || target_vid <= 0 || !mtnet::bounds::u32(target_vid) ||
!valid_item_pos(window, cell) || count < 1 || !mtnet::bounds::u8(count)) return false;
if (classic_sess) {
if (window < 0 || window > 255 || cell < 0 || cell > 0xffff) return false;
return classic_sess->send_item_give((uint32_t)target_vid, (uint8_t)window, (uint16_t)cell,
(uint8_t)count);
}
@@ -2428,7 +2635,7 @@ bool M2Client::give_item(int target_vid, int window, int cell, int count) {
return game->send_give_item((uint32_t)target_vid, (uint8_t)window, (uint16_t)cell, (uint8_t)count);
}
bool M2Client::pickup_item(int ground_vid) {
if (!is_in_game()) {
if (!is_in_game() || ground_vid <= 0 || !mtnet::bounds::u32(ground_vid)) {
return false;
}
if (classic_sess) {
@@ -2604,18 +2811,10 @@ void M2Client::_process(double) {
}
}
void M2Client::_notification(int what) {
switch (what) {
case NOTIFICATION_APPLICATION_PAUSED:
suspend();
break;
case NOTIFICATION_APPLICATION_RESUMED:
resume();
break;
default:
break;
}
}
// §9.3: M2Client no longer listens for NOTIFICATION_APPLICATION_PAUSED/RESUMED.
// AppFlow owns the single AppLifecycle coordinator (equivalent to
// CPythonApplication); it drives suspend()/resume() below. Keeping a second
// listener here meant two entry points for the same lifecycle edge.
void M2Client::suspend() {
if (suspended) {
+3 -1
View File
@@ -48,7 +48,6 @@ public:
~M2Client() override;
void _process(double delta) override;
void _notification(int what);
// host/port for auth and game servers; account credentials.
void connect_to_server(const godot::String &auth_host, int auth_port,
@@ -89,6 +88,9 @@ public:
bool attack(int motion, int victim_vid);
bool set_target(int victim_vid);
bool say(int type, const godot::String &text);
// Reference client SendEmoticon: send the registered chat token and let the
// presentation layer play the matching local effect.
bool send_emoticon(int index);
bool whisper(const godot::String &to, const godot::String &text);
// cast: CG_MOVE with func = FUNC_SKILL(0x80) | (motion_idx & 0x7F).
bool cast_skill(int motion_idx, double rot_deg, int x, int y);
+69
View File
@@ -0,0 +1,69 @@
#pragma once
// Public GDScript-facing network arguments arrive as signed integers. The
// wire structs use narrower unsigned fields, so every M2Client entry point
// must validate before converting instead of allowing a negative value to
// wrap into a large packet argument.
#include <cstdint>
#include <limits>
namespace mtnet::bounds {
inline constexpr bool u8(int64_t value) {
return value >= 0 && value <= std::numeric_limits<uint8_t>::max();
}
inline constexpr bool u16(int64_t value) {
return value >= 0 && value <= std::numeric_limits<uint16_t>::max();
}
inline constexpr bool u32(int64_t value) {
return value >= 0 && value <= std::numeric_limits<uint32_t>::max();
}
inline constexpr bool i32(int64_t value) {
return value >= std::numeric_limits<int32_t>::min() &&
value <= std::numeric_limits<int32_t>::max();
}
// Protocol geometry / array limits from ClientVS22 Packet.h and GameType.h.
inline constexpr int QUICK_SLOT_COUNT = 36;
inline constexpr int SHOP_ITEM_SLOT_COUNT = 40;
inline constexpr int PRIVATE_SHOP_ITEM_COUNT = 39;
inline constexpr int EXCHANGE_ITEM_SLOT_COUNT = 12;
inline constexpr int CUBE_ITEM_SLOT_COUNT = 24;
inline constexpr int POINT_COUNT = 255;
inline constexpr int DS_REFINE_SLOT_COUNT = 15;
// Packet.h:2041 SKILL_MAX_NUM; ClickSkillSlot rejects slot >= SKILL_MAX_NUM
// (PythonPlayerSkill.cpp:108) and the aSkill[] index is always below it.
inline constexpr int SKILL_MAX_NUM = 255;
inline constexpr bool quick_slot(int64_t value) {
return value >= 0 && value < QUICK_SLOT_COUNT;
}
inline constexpr bool shop_slot(int64_t value) {
return value >= 0 && value < SHOP_ITEM_SLOT_COUNT;
}
inline constexpr bool private_shop_slot(int64_t value) {
return value >= 0 && value < SHOP_ITEM_SLOT_COUNT;
}
inline constexpr bool exchange_slot(int64_t value) {
return value >= 0 && value < EXCHANGE_ITEM_SLOT_COUNT;
}
inline constexpr bool cube_slot(int64_t value) {
return value >= 0 && value < CUBE_ITEM_SLOT_COUNT;
}
// Real skill intent (CG_USE_SKILL vnum). Reference use path only ever sends a
// positive index below SKILL_MAX_NUM; a caller value outside that is rejected
// rather than masked into the uint32 wire field.
inline constexpr bool skill_index(int64_t value) {
return value > 0 && value < SKILL_MAX_NUM;
}
} // namespace mtnet::bounds
+4
View File
@@ -330,6 +330,10 @@ enum : uint8_t {
POINT_MAX_STAMINA = 10,
POINT_GOLD = 11,
POINT_ATT_SPEED = 17,
POINT_SUB_SKILL = 27, // 辅助/被动技能点 SKILL_SUB — uicharacter.py skillPageStatDict["SUPPORT"]
POINT_SKILL = 28, // 主动技能点 SKILL — skillPageStatDict["ACTIVE"]
POINT_BOW_DISTANCE = 34, // 활 사정거리 증가치 — __GetBowRange() bonus
POINT_HORSE_SKILL = 113, // 马术技能点 — skillPageStatDict["HORSE"] (Packet.h:1641)
POINT_ENERGY = 128,
POINT_ENERGY_END_TIME = 129,
};
+19 -2
View File
@@ -197,15 +197,32 @@ ItemRecord parse_item(const uint8_t *r, uint32_t stride) {
it.sub_type = r[139];
it.weight = r[140];
it.size = r[141];
it.anti_flags = rd_u32(r + 142);
it.flags = rd_u32(r + 146);
it.wear_flags = rd_u32(r + 150);
it.buy_price = rd_u32(r + 158);
it.sell_price = rd_u32(r + 162);
// aLimits[2] (5B each) @166, aApplies[3] (5B each) @176, alValues[6] @191.
// (bSpecular @234 anchors the whole chain.)
for (int i = 0; i < 2; ++i) {
const size_t off = 166 + static_cast<size_t>(i) * 5;
it.limits[i].type = r[off];
it.limits[i].value = static_cast<int32_t>(rd_u32(r + off + 1));
}
for (int i = 0; i < 3; ++i) {
const size_t off = 176 + static_cast<size_t>(i) * 5;
it.applies[i].type = r[off];
it.applies[i].value = static_cast<int32_t>(rd_u32(r + off + 1));
}
for (int i = 0; i < 6; ++i) {
it.values[i] = (int32_t)rd_u32(r + 191 + i * 4);
}
for (int i = 0; i < 3; ++i) {
it.sockets[i] = static_cast<int32_t>(rd_u32(r + 215 + i * 4));
}
it.refined_vnum = rd_u32(r + 227);
it.refine_set = static_cast<uint16_t>(r[231] | (static_cast<uint16_t>(r[232]) << 8));
it.alter_to_magic_pct = r[233];
it.specular = r[234];
it.gain_socket_pct = r[235];
return it;
}
+19
View File
@@ -43,6 +43,16 @@ bool load_proto(const std::string &path, const std::array<uint32_t, 4> &key, Pro
// --- typed views over the leading fields (rest is offset-stable per stride) ---
struct ItemLimit {
uint8_t type = 0;
int32_t value = 0;
};
struct ItemApply {
uint8_t type = 0;
int32_t value = 0;
};
struct ItemRecord {
uint32_t vnum = 0;
uint32_t vnum_range = 0;
@@ -52,11 +62,20 @@ struct ItemRecord {
uint8_t sub_type = 0; // @ 139
uint8_t weight = 0; // @ 140
uint8_t size = 0; // @ 141
uint32_t anti_flags = 0; // @ 142 (ITEM_ANTIFLAG_*; sell = 1 << 8)
uint32_t flags = 0; // @ 146 (ITEM_FLAG_*; count-per-1-gold = 1 << 3)
uint32_t wear_flags = 0; // @ 150
uint32_t buy_price = 0; // @ 158
uint32_t sell_price = 0; // @ 162
std::array<ItemLimit, 2> limits{}; // aLimits[2] @ 166 (type + long)
std::array<ItemApply, 3> applies{}; // aApplies[3] @ 176 (type + long)
int32_t values[6] = {0}; // alValues[6] @ 191 (armor: values[3] = body shape index)
int32_t sockets[3] = {0}; // alSockets[3] @ 215
uint32_t refined_vnum = 0; // dwRefinedVnum @ 227
uint16_t refine_set = 0; // wRefineSet @ 231
uint8_t alter_to_magic_pct = 0; // bAlterToMagicItemPct @ 233
uint8_t specular = 0; // @ 234 -> PARITY §2.7 fSpecular = specular/100
uint8_t gain_socket_pct = 0; // bGainSocketPct @ 235
};
ItemRecord parse_item(const uint8_t *rec, uint32_t stride);
+33
View File
@@ -71,9 +71,31 @@ Dictionary Metin2Proto::item(int vnum) const {
d["sub_type"] = (int)r.sub_type;
d["weight"] = (int)r.weight;
d["size"] = (int)r.size;
d["anti_flags"] = (int)r.anti_flags;
d["flags"] = (int)r.flags;
d["wear_flags"] = (int)r.wear_flags;
d["buy_price"] = (int)r.buy_price;
d["sell_price"] = (int)r.sell_price;
{
Array limits;
for (const mtproto::ItemLimit &limit : r.limits) {
Dictionary entry;
entry["type"] = (int)limit.type;
entry["value"] = (int)limit.value;
limits.push_back(entry);
}
d["limits"] = limits;
}
{
Array applies;
for (const mtproto::ItemApply &apply : r.applies) {
Dictionary entry;
entry["type"] = (int)apply.type;
entry["value"] = (int)apply.value;
applies.push_back(entry);
}
d["applies"] = applies;
}
{
Array vals;
for (int i = 0; i < 6; ++i) {
@@ -81,7 +103,18 @@ Dictionary Metin2Proto::item(int vnum) const {
}
d["values"] = vals; // armor: values[3] = body shape index for the race .msm
}
{
Array sockets;
for (int socket : r.sockets) {
sockets.push_back(socket);
}
d["sockets"] = sockets;
}
d["refined_vnum"] = (int)r.refined_vnum;
d["refine_set"] = (int)r.refine_set;
d["alter_to_magic_pct"] = (int)r.alter_to_magic_pct;
d["specular"] = (int)r.specular;
d["gain_socket_pct"] = (int)r.gain_socket_pct;
return d;
}
+3 -1
View File
@@ -445,7 +445,9 @@ Ref<ArrayMesh> build_proxy_impl(const std::string &species, float height_m,
leaf->set_shader(leaf_shader());
leaf->set_shader_parameter("leaf_tex",
textures.composite.is_valid() ? textures.composite : fallback_leaf_texture(conifer));
leaf->set_shader_parameter("wind_strength", 1.0f);
// MapUtil.cpp initializes fWindStrength to 0.2 and fWindRandom to 0;
// Environment_Load does not read a wind token from .msenv.
leaf->set_shader_parameter("wind_strength", 0.2f);
mesh->surface_set_material(1, leaf);
return mesh;
}
+44
View File
@@ -0,0 +1,44 @@
// Public M2Client argument bounds shared by the Classic and m2dev backends.
#include "../src/net/net_bounds.h"
#include <cstdio>
static int g_fail = 0;
#define CHECK(c, msg) \
do { \
if (!(c)) { \
std::fprintf(stderr, "FAIL: %s\n", msg); \
++g_fail; \
} \
} while (0)
int main() {
using namespace mtnet::bounds;
CHECK(u8(0) && u8(255), "u8 accepts wire range");
CHECK(!u8(-1) && !u8(256), "u8 rejects outside range");
CHECK(u16(65535) && !u16(-1) && !u16(65536), "u16 range");
CHECK(u32(0) && u32(0xffffffffLL) && !u32(-1), "u32 range");
CHECK(i32(-2147483648LL) && i32(2147483647LL), "i32 accepts signed range");
CHECK(!i32(-2147483649LL) && !i32(2147483648LL), "i32 rejects outside range");
CHECK(quick_slot(0) && quick_slot(35), "quick slot endpoints");
CHECK(!quick_slot(-1) && !quick_slot(36), "quick slot outside range");
CHECK(private_shop_slot(0) && private_shop_slot(39), "private shop endpoints");
CHECK(!private_shop_slot(40), "private shop outside range");
CHECK(exchange_slot(0) && exchange_slot(11) && !exchange_slot(12), "exchange range");
CHECK(cube_slot(0) && cube_slot(23) && !cube_slot(24), "cube range");
// §3.8 mod 3: use_skill skill index. 1..254 accepted, 0 / >=255 / negative rejected.
CHECK(SKILL_MAX_NUM == 255, "SKILL_MAX_NUM matches Packet.h");
CHECK(skill_index(1) && skill_index(254), "skill index endpoints");
CHECK(!skill_index(0) && !skill_index(255) && !skill_index(-1), "skill index outside range");
CHECK(!skill_index(0x7fffffffLL + 1), "skill index rejects large value");
if (g_fail) {
std::fprintf(stderr, "%d checks FAILED\n", g_fail);
return 1;
}
std::puts("net_bounds_test: OK");
return 0;
}
+7 -1
View File
@@ -871,9 +871,15 @@ int main() {
ex.arg1 = 27001;
ex.arg2 = {0, 4};
ex.arg3 = 7;
ex.sockets[1] = 9002;
ex.attrs[2] = {17, -33};
feed(s, raw(ex));
CHECK(s.world().exchange().self_items[4].vnum == 27001 &&
s.world().exchange().self_items[4].count == 7, "exchange item add");
s.world().exchange().self_items[4].count == 7 &&
s.world().exchange().self_items[4].sockets[1] == 9002 &&
s.world().exchange().self_items[4].attrs[2].type == 17 &&
s.world().exchange().self_items[4].attrs[2].value == -33,
"exchange item add keeps sockets/attrs");
ex.subheader = EXCHANGE_GC_GOLD_ADD;
ex.arg1 = 123456;
@@ -78,6 +78,30 @@ int main() {
CHECK(out[sizeof(Handshake)] == SEQUENCE_TABLE[0], "time-sync uses next sequence byte");
}
// -------------------------------------------- server frame clock (§3.2)
{
ClassicStream s;
CHECK(s.server_frame_ms() == 0, "server_frame_ms is 0 before any handshake");
CHECK(!s.has_server_clock(), "has_server_clock false before handshake");
Handshake hs{};
hs.header = HDR_HANDSHAKE;
hs.handshake = 0x1234;
hs.time = 500000;
hs.delta = 20;
auto b = raw(hs);
s.feed(b.data(), b.size());
(void)drain(s);
CHECK(s.has_server_clock(), "has_server_clock true after handshake");
uint32_t base = 500000u + 20u; // hs.time + lDelta
uint32_t f1 = s.server_frame_ms();
CHECK(f1 >= base && f1 < base + 5000u,
"server_frame_ms ~ hs.time + lDelta right after handshake");
uint32_t f2 = s.server_frame_ms();
CHECK(f2 >= f1, "server frame clock is monotonic non-decreasing");
}
// ---------------------------------------------------------- GC_PHASE
{
ClassicStream s;
+34 -5
View File
@@ -165,20 +165,41 @@ int main() {
CHECK(!es.get(2)->moving, "stopped at t=1");
CHECK(es.get(2)->func == FUNC_WAIT, "func back to WAIT");
// --- a non-move func snaps ---
// --- §3.2 state queue: a near attack func (dir_len < 50) snaps and acts ---
// entity 2 is at (1500,500); target (1530,500) -> dir_len 30 < 50.
GCMove atk{};
atk.header = GC_MOVE;
atk.length = sizeof(atk);
atk.vid = 2;
atk.func = FUNC_ATTACK;
atk.x = 1600;
atk.y = 600;
atk.x = 1530;
atk.y = 500;
atk.duration = 0;
b = pkt(atk);
es.apply(GC_MOVE, b.data(), (uint16_t)b.size());
CHECK(!es.get(2)->moving && es.get(2)->x == 1600, "attack func snaps position");
CHECK(!es.get(2)->moving && es.get(2)->x == 1530, "near attack func snaps position");
CHECK(es.get(2)->func == FUNC_ATTACK, "func = ATTACK");
// --- §3.2 state queue: a far attack func (dir_len >= 50) walks, then attacks
// on arrival (reference m_kMovAfterFunc). ---
GCMove fatk{};
fatk.header = GC_MOVE;
fatk.length = sizeof(fatk);
fatk.vid = 2;
fatk.func = FUNC_ATTACK;
fatk.x = 1700; // from (1530,500): dir_len 170 >= 50
fatk.y = 500;
fatk.duration = 0;
b = pkt(fatk);
es.set_now(2000);
es.apply(GC_MOVE, b.data(), (uint16_t)b.size());
CHECK(es.get(2)->moving && es.get(2)->func == FUNC_MOVE, "far attack func walks first");
es.set_now(20000); // well past arrival
es.tick();
CHECK(!es.get(2)->moving && std::abs(es.get(2)->x - 1700.0f) < 0.01f,
"far attack func: arrived at Dst");
CHECK(es.get(2)->func == FUNC_ATTACK, "far attack func: attacks on arrival");
// --- movement metadata + batched correction packets ---
{
GCChangeSpeed speed{GC_CHANGE_SPEED, sizeof(GCChangeSpeed), 2, 175};
@@ -964,9 +985,17 @@ int main() {
xi.arg1 = 19; // vnum
xi.arg2.cell = 2; // exchange slot
xi.arg3 = 1; // count
xi.sockets[0] = 7001;
xi.sockets[2] = -1;
xi.attrs[0] = {5, 42};
b = pkt(xi);
es.apply(GC_EXCHANGE, b.data(), (uint16_t)b.size());
CHECK(es.exchange().self_items[2].vnum == 19, "exchange: self item add");
CHECK(es.exchange().self_items[2].vnum == 19 &&
es.exchange().self_items[2].sockets[0] == 7001 &&
es.exchange().self_items[2].sockets[2] == -1 &&
es.exchange().self_items[2].attrs[0].type == 5 &&
es.exchange().self_items[2].attrs[0].value == 42,
"exchange: self item add keeps sockets/attrs");
GCExchange xe{};
xe.header = GC_EXCHANGE;
+159
View File
@@ -0,0 +1,159 @@
// CLIENT-GAP §3.2 — EntityStore TCP state queue: server-frame release gate,
// __CanProcessNetworkStatePacket gate, and the FUNC_* switch with its 1.0 / 50.0
// hard thresholds (no lerp, no jitter buffer). Drives the protocol-neutral
// mutation API directly (same path the classic backend uses).
#include "../src/net/entity_store.h"
#include <cmath>
#include <cstdio>
using namespace mtnet;
static int g_fail = 0;
#define CHECK(c, msg) \
do { \
if (!(c)) { \
std::fprintf(stderr, "FAIL: %s\n", msg); \
++g_fail; \
} \
} while (0)
static void spawn(EntityStore &es, uint32_t vid, float x, float y) {
es.mut_spawn(vid, 101, 0, "Mob", x, y, 0.0f, 0.0f, 200 /*moving_speed*/, 0);
}
int main() {
// --- 1) server-frame release gate ---------------------------------------
{
EntityStore es;
es.set_now(0);
spawn(es, 10, 0, 0);
es.set_server_frame_ms(1000);
// command timestamped in the future: stays queued, entity does not move.
es.mut_move(10, 0.0f, FUNC_MOVE, 500.0f, 0.0f, 0, 0, /*chk_time*/ 2000);
CHECK(es.get(10)->state_queue.size() == 1, "future cmd stays queued");
CHECK(!es.get(10)->moving, "future cmd: not moving yet");
es.process_states();
CHECK(es.get(10)->state_queue.size() == 1, "still queued below chk_time");
es.set_server_frame_ms(2000); // clock reaches the command time
es.process_states();
CHECK(es.get(10)->state_queue.empty(), "cmd released once frame clock catches up");
CHECK(es.get(10)->moving && es.get(10)->func == FUNC_MOVE, "released FUNC_MOVE walks");
// frame clock 0 == "not aligned yet" -> release immediately.
EntityStore es0;
es0.set_now(0);
spawn(es0, 11, 0, 0);
es0.mut_move(11, 0.0f, FUNC_MOVE, 500.0f, 0.0f, 0, 0, /*chk_time*/ 9999);
CHECK(es0.get(11)->state_queue.empty() && es0.get(11)->moving,
"frame clock 0 releases regardless of chk_time");
}
// --- 2) FUNC_WAIT: 1.0 threshold (snap-align vs. walk) ------------------
{
EntityStore es;
es.set_now(0);
spawn(es, 20, 0, 0);
// within 1.0 -> snap align, no walk.
es.mut_move(20, 90.0f, FUNC_WAIT, 0.5f, 0.0f, 0, 0, 0);
CHECK(!es.get(20)->moving, "FUNC_WAIT dir_len<=1.0: no walk");
CHECK(std::abs(es.get(20)->x - 0.5f) < 1e-3f, "FUNC_WAIT dir_len<=1.0: snapped to Dst");
CHECK(es.get(20)->func == FUNC_WAIT && std::abs(es.get(20)->angle - 90.0f) < 1e-3f,
"FUNC_WAIT snap: func + heading applied");
// beyond 1.0 -> walk there, m_kMovAfterFunc == WAIT.
es.mut_move(20, 0.0f, FUNC_WAIT, 100.0f, 0.0f, 0, 0, 0);
CHECK(es.get(20)->moving && es.get(20)->func == FUNC_MOVE, "FUNC_WAIT dir_len>1.0: walks");
CHECK(es.get(20)->mov_after_func == FUNC_WAIT, "FUNC_WAIT walk: after-func WAIT");
CHECK(es.get(20)->skip_collision, "network-driven walk enables skip-collision");
es.set_now(100000);
es.tick();
CHECK(!es.get(20)->moving && es.get(20)->func == FUNC_WAIT, "FUNC_WAIT walk: settles to WAIT");
CHECK(!es.get(20)->skip_collision, "skip-collision cleared on arrival");
}
// --- 3) FUNC_COMBO: 50.0 threshold + after-arrival action --------------
{
EntityStore es;
es.set_now(0);
spawn(es, 30, 0, 0);
// near (dir_len 20 < 50) -> snap + act now, arg is on the row's func.
es.mut_move(30, 45.0f, FUNC_COMBO, 20.0f, 0.0f, 0, /*arg*/ 7, 0);
CHECK(!es.get(30)->moving && es.get(30)->func == FUNC_COMBO,
"FUNC_COMBO dir_len<50: snap + act");
CHECK(std::abs(es.get(30)->x - 20.0f) < 1e-3f, "FUNC_COMBO near: snapped to Dst");
// far (dir_len 200 >= 50) -> walk first, combat + arg deferred to arrival.
es.mut_move(30, 0.0f, FUNC_COMBO, 220.0f, 0.0f, 0, /*arg*/ 9, 0);
CHECK(es.get(30)->moving && es.get(30)->func == FUNC_MOVE, "FUNC_COMBO dir_len>=50: walks");
CHECK(es.get(30)->mov_after_func == FUNC_COMBO && es.get(30)->mov_after_arg == 9,
"FUNC_COMBO far: combo + motion index latched for arrival");
es.set_now(100000);
es.tick();
CHECK(!es.get(30)->moving && es.get(30)->func == FUNC_COMBO,
"FUNC_COMBO far: combo runs on arrival");
CHECK(es.get(30)->mov_after_func == FUNC_WAIT, "after-func latch cleared post-arrival");
}
// --- 4) __CanProcessNetworkStatePacket gate (dead / knocked-down) ------
{
EntityStore es;
es.set_now(0);
spawn(es, 40, 0, 0);
es.mut_knockdown(40, true);
es.mut_move(40, 0.0f, FUNC_MOVE, 300.0f, 0.0f, 0, 0, 0);
CHECK(es.get(40)->state_queue.size() == 1 && !es.get(40)->moving,
"knocked-down: state cmd held, not applied");
es.mut_knockdown(40, false);
es.process_states();
CHECK(es.get(40)->state_queue.empty() && es.get(40)->moving,
"recovered: held state cmd releases");
es.mut_dead(41); // unknown vid: no-op, must not crash
spawn(es, 42, 0, 0);
es.mut_dead(42);
es.mut_move(42, 0.0f, FUNC_MOVE, 300.0f, 0.0f, 0, 0, 0);
CHECK(!es.get(42)->moving && es.get(42)->state_queue.size() == 1,
"dead: state cmd held");
}
// --- 5) queue preserves order and drains multiple due commands --------
{
EntityStore es;
es.set_now(0);
es.set_server_frame_ms(0);
spawn(es, 50, 0, 0);
es.mut_move(50, 0.0f, FUNC_WAIT, 0.2f, 0.0f, 0, 0, 0); // snap (near)
es.mut_move(50, 0.0f, FUNC_ATTACK, 10.0f, 0.0f, 0, 3, 0); // near attack -> snap + act
CHECK(es.get(50)->state_queue.empty(), "both due cmds drained in one pass");
CHECK(es.get(50)->func == FUNC_ATTACK && std::abs(es.get(50)->x - 10.0f) < 1e-3f,
"last cmd wins: ATTACK at (10,0)");
}
// --- 6) staleness valve: chk_time far ahead of the frame clock -----------
// Reference StateProcess has no cap; ours releases a command whose chk_time
// is > STATE_QUEUE_MAX_WAIT_MS ahead of the (unvalidated) server-frame clock
// so a wrong-timebase dwTime can never freeze the actor forever.
{
EntityStore es;
es.set_now(0);
es.set_server_frame_ms(1000);
// exactly at the cap -> still gated.
spawn(es, 60, 0, 0);
es.mut_move(60, 0.0f, FUNC_MOVE, 500.0f, 0.0f, 0, 0,
/*chk_time*/ 1000 + STATE_QUEUE_MAX_WAIT_MS);
CHECK(es.get(60)->state_queue.size() == 1 && !es.get(60)->moving,
"chk_time == frame+cap: still gated");
// one ms past the cap -> treated as out-of-band, released now.
spawn(es, 61, 0, 0);
es.mut_move(61, 0.0f, FUNC_MOVE, 500.0f, 0.0f, 0, 0,
/*chk_time*/ 1000 + STATE_QUEUE_MAX_WAIT_MS + 1);
CHECK(es.get(61)->state_queue.empty() && es.get(61)->moving && es.get(61)->func == FUNC_MOVE,
"chk_time past cap: released immediately (staleness valve)");
}
if (g_fail == 0) {
std::printf("PASS: net_state_queue_test (§3.2 TCP state queue / thresholds / gates)\n");
}
return g_fail == 0 ? 0 : 1;
}
@@ -0,0 +1,79 @@
// Synthetic item_proto record test. It locks the offsets used by the
// ClientVS22 TItemTable layout without requiring the 2.2G asset tree.
#include "../src/proto/proto.h"
#include <array>
#include <cstdint>
#include <cstdio>
#include <cstring>
static int g_fail = 0;
#define CHECK(c, msg) \
do { \
if (!(c)) { \
std::fprintf(stderr, "FAIL: %s\n", msg); \
++g_fail; \
} \
} while (0)
static void put_u32(std::array<uint8_t, 236> &record, size_t offset, uint32_t value) {
record[offset + 0] = static_cast<uint8_t>(value);
record[offset + 1] = static_cast<uint8_t>(value >> 8);
record[offset + 2] = static_cast<uint8_t>(value >> 16);
record[offset + 3] = static_cast<uint8_t>(value >> 24);
}
int main() {
std::array<uint8_t, 236> record{};
put_u32(record, 0, 12345);
put_u32(record, 4, 7);
std::memcpy(record.data() + 8, "TestItem", 9);
std::memcpy(record.data() + 73, "LocaleItem", 11);
record[138] = 3;
record[139] = 4;
record[140] = 2;
record[141] = 1;
put_u32(record, 142, 1u << 8);
put_u32(record, 146, 1u << 3);
put_u32(record, 150, 0x12345678);
put_u32(record, 158, 9000);
put_u32(record, 162, 6000);
record[166] = 1;
put_u32(record, 167, 42);
record[176] = 2;
put_u32(record, 177, -7);
put_u32(record, 191 + 3 * 4, 3);
put_u32(record, 215, 1001);
put_u32(record, 219, -1);
put_u32(record, 227, 12346);
record[231] = 0x34;
record[232] = 0x12;
record[233] = 11;
record[234] = 75;
record[235] = 88;
const mtproto::ItemRecord item = mtproto::parse_item(record.data(), record.size());
CHECK(item.vnum == 12345, "vnum offset");
CHECK(item.vnum_range == 7, "vnum range offset");
CHECK(item.name == "TestItem", "name offset");
CHECK(item.locale_name == "LocaleItem", "locale name offset");
CHECK(item.type == 3 && item.sub_type == 4, "type offsets");
CHECK(item.anti_flags == (1u << 8), "anti_flags offset");
CHECK(item.flags == (1u << 3), "flags offset");
CHECK(item.wear_flags == 0x12345678, "wear_flags offset");
CHECK(item.buy_price == 9000 && item.sell_price == 6000, "price offsets");
CHECK(item.limits[0].type == 1 && item.limits[0].value == 42, "limit offsets");
CHECK(item.applies[0].type == 2 && item.applies[0].value == -7, "apply offsets");
CHECK(item.values[3] == 3, "values offset");
CHECK(item.sockets[0] == 1001 && item.sockets[1] == -1, "socket offsets");
CHECK(item.refined_vnum == 12346 && item.refine_set == 0x1234, "refine offsets");
CHECK(item.alter_to_magic_pct == 11 && item.specular == 75 && item.gain_socket_pct == 88,
"tail offsets");
if (g_fail) {
std::fprintf(stderr, "%d check(s) failed\n", g_fail);
return 1;
}
std::printf("all item layout checks passed\n");
return 0;
}
+13
View File
@@ -4,6 +4,7 @@ add_library(xr_formats STATIC
textscript.cpp # M2 T1: Metin2 token .msa / .msm
msm.cpp # base gr2 + RaceDataScript
msa.cpp # anim gr2 + duration + accumulation +
combo_table.cpp # §3.5: playersettingmodule.py ComboAttackNew -> PC
m2_tokvec.cpp # W0: LoadMultipleTextData Start/End token
m2_coord.cpp # W0: Metin2Godot / / BACKLOG I5
@@ -27,11 +28,23 @@ if(BUILD_TESTING)
add_executable(formats_msa_test tests/msa_test.cpp)
target_link_libraries(formats_msa_test PRIVATE xrender::formats)
add_test(NAME formats.msa_loop_data COMMAND formats_msa_test)
if(DEFINED ENV{M2_ASSETS})
set_tests_properties(formats.msa_loop_data PROPERTIES
ENVIRONMENT "M2_ASSETS=$ENV{M2_ASSETS}")
endif()
add_executable(formats_msm_test tests/msm_test.cpp)
target_link_libraries(formats_msm_test PRIVATE xrender::formats)
add_test(NAME formats.msm_hair COMMAND formats_msm_test)
add_executable(formats_combo_table_test tests/combo_table_test.cpp)
target_link_libraries(formats_combo_table_test PRIVATE xrender::formats)
add_test(NAME formats.combo_table COMMAND formats_combo_table_test)
if(DEFINED ENV{M2_ASSETS})
set_tests_properties(formats.combo_table PROPERTIES
ENVIRONMENT "M2_ASSETS=$ENV{M2_ASSETS}")
endif()
add_executable(formats_map_test tests/map_formats_test.cpp)
target_link_libraries(formats_map_test PRIVATE xrender::formats)
add_test(NAME formats.map_formats COMMAND formats_map_test)
+186
View File
@@ -0,0 +1,186 @@
// M2 §3.5 —— playersettingmodule.py 的 ComboAttackNew 调用 -> PC 连击段表。
#include "combo_table.h"
#include "m2_tokvec.h" // fmt::read_file
#include <cctype>
#include <cstdlib>
namespace fmt {
const std::vector<uint16_t>* ComboTable::get(uint16_t motion_mode, uint16_t combo_type) const {
auto it = combos.find(make_combo_key(motion_mode, combo_type));
return it == combos.end() ? nullptr : &it->second;
}
const ComboTable& PlayerComboTables::klass(int combo_class) const {
static const ComboTable kEmpty;
if (combo_class < 0 || combo_class >= COMBO_CLASS_COUNT) return kEmpty;
return per_class[combo_class];
}
namespace {
std::string trim(const std::string& s) {
size_t a = 0, b = s.size();
while (a < b && std::isspace(static_cast<unsigned char>(s[a]))) ++a;
while (b > a && std::isspace(static_cast<unsigned char>(s[b - 1]))) --b;
return s.substr(a, b - a);
}
bool starts_with(const std::string& s, const char* p) {
return s.rfind(p, 0) == 0;
}
// chr.MOTION_MODE_XXX -> CRaceMotionData::EMode 值。
bool resolve_motion_mode(const std::string& suffix, long& out) {
static const std::map<std::string, long> kMap = {
{"RESERVED", 0}, {"GENERAL", 1}, {"ONEHAND_SWORD", 2}, {"TWOHAND_SWORD", 3},
{"DUALHAND_SWORD", 4}, {"BOW", 5}, {"FAN", 6}, {"BELL", 7}, {"FISHING", 8},
{"HORSE", 9}, {"HORSE_ONEHAND_SWORD", 10}, {"HORSE_TWOHAND_SWORD", 11},
{"HORSE_DUALHAND_SWORD", 12}, {"HORSE_BOW", 13}, {"HORSE_FAN", 14},
{"HORSE_BELL", 15}, {"WEDDING_DRESS", 16},
};
auto it = kMap.find(suffix);
if (it == kMap.end()) return false;
out = it->second;
return true;
}
// COMBO_TYPE_N / COMBO_INDEX_N -> N-1(两者都是从 1 起的人读编号,值从 0 起)。
bool resolve_one_based(const std::string& tail, long& out) {
if (tail.empty()) return false;
char* end = nullptr;
long n = std::strtol(tail.c_str(), &end, 10);
if (end == tail.c_str() || *end != '\0' || n < 1) return false;
out = n - 1;
return true;
}
// 一个实参 token -> 整数。认符号常量,也认裸整数。
bool resolve_token(const std::string& tok_in, long& out) {
const std::string tok = trim(tok_in);
if (tok.empty()) return false;
if (starts_with(tok, "chr.MOTION_MODE_")) {
return resolve_motion_mode(tok.substr(sizeof("chr.MOTION_MODE_") - 1), out);
}
if (starts_with(tok, "chr.MOTION_COMBO_ATTACK_")) {
if (!resolve_one_based(tok.substr(sizeof("chr.MOTION_COMBO_ATTACK_") - 1), out)) return false;
out = COMBO_NAME_COMBO_ATTACK_1 + out; // 0-based n -> 段号 14 + n
return true;
}
if (tok == "chr.MOTION_NORMAL_ATTACK") { out = COMBO_NAME_NORMAL_ATTACK; return true; }
if (starts_with(tok, "COMBO_TYPE_")) { return resolve_one_based(tok.substr(sizeof("COMBO_TYPE_") - 1), out); }
if (starts_with(tok, "COMBO_INDEX_")) { return resolve_one_based(tok.substr(sizeof("COMBO_INDEX_") - 1), out); }
// 裸整数
char* end = nullptr;
long n = std::strtol(tok.c_str(), &end, 10);
if (end == tok.c_str() || *end != '\0') return false;
out = n;
return true;
}
// "a, b, c)"call 名后的部分)-> 解析出的整数实参。分号里没有嵌套括号。
bool parse_call_args(const std::string& after_paren, std::vector<long>& out) {
size_t close = after_paren.find(')');
if (close == std::string::npos) return false;
const std::string inner = after_paren.substr(0, close);
out.clear();
size_t start = 0;
while (start <= inner.size()) {
size_t comma = inner.find(',', start);
std::string tok = inner.substr(start, comma == std::string::npos ? std::string::npos : comma - start);
tok = trim(tok);
if (!tok.empty()) {
long v = 0;
if (!resolve_token(tok, v)) return false;
out.push_back(v);
}
if (comma == std::string::npos) break;
start = comma + 1;
}
return true;
}
int class_from_def_line(const std::string& line) {
if (line.find("def __LoadGameWarriorEx") != std::string::npos) return COMBO_CLASS_WARRIOR;
if (line.find("def __LoadGameAssassinEx") != std::string::npos) return COMBO_CLASS_ASSASSIN;
if (line.find("def __LoadGameSuraEx") != std::string::npos) return COMBO_CLASS_SURA;
if (line.find("def __LoadGameShamanEx") != std::string::npos) return COMBO_CLASS_SHAMAN;
return -1;
}
} // namespace
bool parse_player_combo_tables(const std::string& py_text, PlayerComboTables& out, std::string* err) {
out = PlayerComboTables{};
int cur_class = -1;
size_t pos = 0;
const size_t n = py_text.size();
while (pos < n) {
size_t eol = py_text.find('\n', pos);
std::string line = py_text.substr(pos, eol == std::string::npos ? std::string::npos : eol - pos);
pos = (eol == std::string::npos) ? n : eol + 1;
const std::string t = trim(line);
if (t.empty() || t[0] == '#') continue;
// 顶层 def 换块:只有四个 __LoadGame<Class>Ex 有连击段调用,其它 def 复位。
if (starts_with(line, "def ")) {
cur_class = class_from_def_line(line);
continue;
}
if (cur_class < 0) continue;
const char* kReserve = "chrmgr.ReserveComboAttackNew(";
const char* kRegister = "chrmgr.RegisterComboAttackNew(";
size_t r = t.find(kReserve);
if (r != std::string::npos) {
std::vector<long> args;
if (!parse_call_args(t.substr(r + std::string(kReserve).size()), args) || args.size() != 3) {
if (err) *err = "combo_table: 无法解析 ReserveComboAttackNew: " + t;
return false;
}
const uint32_t key = make_combo_key(static_cast<uint16_t>(args[0]), static_cast<uint16_t>(args[1]));
const long count = args[2] < 0 ? 0 : args[2];
// CRaceData::ReserveComboAttack 用 map::insert —— 已存在的 key 不覆盖。
out.per_class[cur_class].combos.emplace(
key, std::vector<uint16_t>(static_cast<size_t>(count), 0));
continue;
}
size_t g = t.find(kRegister);
if (g != std::string::npos) {
std::vector<long> args;
if (!parse_call_args(t.substr(g + std::string(kRegister).size()), args) || args.size() != 4) {
if (err) *err = "combo_table: 无法解析 RegisterComboAttackNew: " + t;
return false;
}
const uint32_t key = make_combo_key(static_cast<uint16_t>(args[0]), static_cast<uint16_t>(args[1]));
const long idx = args[2];
const uint16_t motion = static_cast<uint16_t>(args[3]);
auto it = out.per_class[cur_class].combos.find(key);
if (it == out.per_class[cur_class].combos.end()) continue; // 未 Reserve:跳过
if (idx < 0 || static_cast<size_t>(idx) >= it->second.size()) continue; // 越界:跳过
it->second[static_cast<size_t>(idx)] = motion;
continue;
}
}
if (err) err->clear();
return true;
}
bool parse_player_combo_tables_file(const std::string& path, PlayerComboTables& out, std::string* err) {
std::string s;
if (!read_file(path, s)) {
if (err) *err = "combo_table: 打不开 " + path;
return false;
}
return parse_player_combo_tables(s, out, err);
}
} // namespace fmt
+92
View File
@@ -0,0 +1,92 @@
// combo_table —— PC 连击段表(CRaceData::TComboAttackDataMap 的等价)。M2 §3.5。
//
// 参考端的 PC 连击段表**不在** .msm 里(.msm 只有 BaseModelFileName + HairData)。
// 它由 playersettingmodule.py 的 chrmgr.ReserveComboAttackNew / RegisterComboAttackNew
// 调用在 CRaceData 上建起来(UserInterface/PythonCharacterManagerModule.cpp →
// CRaceData::ReserveComboAttack / RegisterComboAttackGameLib/RaceData.cpp)。
// 本解析器就把那批调用读成同一张表。
//
// NPC/怪物的连击段走另一条路(RaceManager.cpp __LoadRaceMotionList 读 motlist.txt
// 只在 MODE_GENERAL 下登记 NAME_COMBO_ATTACK_1/2/3,没有段号向量),不在这里。
#pragma once
#include <cstdint>
#include <map>
#include <string>
#include <vector>
namespace fmt {
// CRaceMotionData::EModechr.MOTION_MODE_*)。combo key 的高 16 位。
enum ComboMotionMode : uint16_t {
COMBO_MODE_RESERVED = 0,
COMBO_MODE_GENERAL = 1,
COMBO_MODE_ONEHAND_SWORD = 2,
COMBO_MODE_TWOHAND_SWORD = 3,
COMBO_MODE_DUALHAND_SWORD = 4,
COMBO_MODE_BOW = 5,
COMBO_MODE_FAN = 6,
COMBO_MODE_BELL = 7,
COMBO_MODE_FISHING = 8,
COMBO_MODE_HORSE = 9,
COMBO_MODE_HORSE_ONEHAND_SWORD = 10,
COMBO_MODE_HORSE_TWOHAND_SWORD = 11,
COMBO_MODE_HORSE_DUALHAND_SWORD = 12,
COMBO_MODE_HORSE_BOW = 13,
COMBO_MODE_HORSE_FAN = 14,
COMBO_MODE_HORSE_BELL = 15,
COMBO_MODE_WEDDING_DRESS = 16,
};
// CRaceMotionData::EName 的攻击段(chr.MOTION_NORMAL_ATTACK / chr.MOTION_COMBO_ATTACK_*)。
// ComboIndexVector 里存的就是这些段号。
enum : uint16_t {
COMBO_NAME_NORMAL_ATTACK = 13,
COMBO_NAME_COMBO_ATTACK_1 = 14,
COMBO_NAME_COMBO_ATTACK_8 = 21,
};
// RaceData.h: MAKE_COMBO_KEY(motion_mode, combo_type)
inline uint32_t make_combo_key(uint16_t motion_mode, uint16_t combo_type) {
return (static_cast<uint32_t>(motion_mode) << 16) | static_cast<uint32_t>(combo_type);
}
inline uint16_t combo_key_mode(uint32_t key) { return static_cast<uint16_t>((key >> 16) & 0xFFFF); }
inline uint16_t combo_key_type(uint32_t key) { return static_cast<uint16_t>(key & 0xFFFF); }
// playersettingmodule.py 按 __LoadGame<Class>Ex(race, path) 分四块,男女同一函数体
// (连击段表与性别无关),所以这里按职业收,不按 race。
enum ComboClass : int {
COMBO_CLASS_WARRIOR = 0,
COMBO_CLASS_ASSASSIN = 1,
COMBO_CLASS_SURA = 2,
COMBO_CLASS_SHAMAN = 3,
COMBO_CLASS_COUNT = 4,
};
// CRaceData::TComboAttackDataMap 的等价:MAKE_COMBO_KEY -> ComboIndexVector。
struct ComboTable {
// key = make_combo_key(mode, type)value = 有序的 COMBO_NAME_COMBO_ATTACK_* 段号,
// 下标即 dwComboArrayIndexm_dwcurComboIndex - 1)。
std::map<uint32_t, std::vector<uint16_t>> combos;
// GetComboDataPointer:命中返回段号向量指针,否则 nullptr。
const std::vector<uint16_t>* get(uint16_t motion_mode, uint16_t combo_type) const;
bool empty() const { return combos.empty(); }
size_t key_count() const { return combos.size(); }
};
struct PlayerComboTables {
ComboTable per_class[COMBO_CLASS_COUNT];
// 越界返回一张空表(不抛)。
const ComboTable& klass(int combo_class) const;
};
// 解析 playersettingmodule.py 里的 chrmgr.ReserveComboAttackNew / RegisterComboAttackNew
// 调用,按 def __LoadGame<Class>Ex 分块填 per_class。照 CRaceData 语义:
// Reserve -> combos[key] = vector(count, 0)(重复 key 不覆盖,同 std::map::insert
// Register -> combos[key][index] = motionkey 未 Reserve 或 index 越界则跳过)
// 认得的符号:chr.MOTION_MODE_*、chr.MOTION_COMBO_ATTACK_N=13+N)、
// chr.MOTION_NORMAL_ATTACK=13)、COMBO_TYPE_N=N-1)、COMBO_INDEX_N=N-1)、裸整数。
bool parse_player_combo_tables(const std::string& py_text, PlayerComboTables& out, std::string* err);
bool parse_player_combo_tables_file(const std::string& path, PlayerComboTables& out, std::string* err);
} // namespace fmt
+14
View File
@@ -140,6 +140,7 @@ bool parse_environment(const std::string& text, Environment& out, std::string* e
if (auto* l = root.kv("ScriptType")) out.script_type = l->size() > 1 ? (*l)[1] : "";
out.script_version = root.f("ScriptVersion", 1);
out.reserved = root.i("Reserved") != 0;
if (const Block* dl = root.child("DirectionalLight")) {
if (auto* d = dl->kv("Direction"))
@@ -163,15 +164,28 @@ bool parse_environment(const std::string& text, Environment& out, std::string* e
}
if (const Block* fg = root.child("Fog")) {
out.fog.fog_level = fg->i("foglevel", 0);
out.fog.density_fog = fg->i("IsDensity") != 0;
out.fog.near_distance = fg->f("NearDistance", 1);
out.fog.far_distance = fg->f("FarDistance", 1);
out.fog.color = rgba(fg->kv("Color"));
out.fog.enable = (fg->kv("Enable") && fg->i("Enable") != 0) || out.fog.fog_level > 0;
}
if (const Block* flt = root.child("Filter")) {
out.filter.enable = flt->i("Enable") != 0;
out.filter.color = rgba(flt->kv("Color"));
out.filter.alpha_src = flt->i("AlphaSrc", out.filter.alpha_src);
out.filter.alpha_dest = flt->i("AlphaDest", out.filter.alpha_dest);
}
if (const Block* sb = root.child("SkyBox")) {
out.sky.texture_render_mode = sb->i("BTextureRenderMode") != 0;
for (int k = 0; k < 3; ++k) out.sky.scale[k] = sb->f("Scale", k + 1);
out.sky.gradient_level_upper = sb->i("GradientLevelUpper");
out.sky.gradient_level_lower = sb->i("GradientLevelLower");
static constexpr const char* kFaceKeys[6] = {
"FrontFaceFileName", "BackFaceFileName", "LeftFaceFileName",
"RightFaceFileName", "TopFaceFileName", "BottomFaceFileName"};
for (int k = 0; k < 6; ++k)
out.sky.face_textures[k] = sb->s(kFaceKeys[k]);
for (int k = 0; k < 2; ++k) {
out.sky.cloud_scale[k] = sb->f("CloudScale", k + 1);
out.sky.cloud_texture_scale[k] = sb->f("CloudTextureScale", k + 1);
+22 -10
View File
@@ -13,26 +13,36 @@ using Vec3f = std::array<float, 3>;
using Vec2f = std::array<float, 2>;
struct EnvDirLight {
Vec3f direction{{0, 0, 0}};
Vec3f direction{{0.5f, 0.5f, -0.5f}};
bool bg_enable = false, ch_enable = false;
Rgba bg_diffuse{{1, 1, 1, 1}}, bg_ambient{{0, 0, 0, 1}};
Rgba ch_diffuse{{1, 1, 1, 1}}, ch_ambient{{0, 0, 0, 1}};
Rgba bg_diffuse{{1, 1, 1, 1}}, bg_ambient{{0.5f, 0.5f, 0.5f, 1}};
Rgba ch_diffuse{{1, 1, 1, 1}}, ch_ambient{{0.5f, 0.5f, 0.5f, 1}};
};
struct EnvMaterial {
Rgba diffuse{{1, 1, 1, 1}}, ambient{{1, 1, 1, 1}}, emissive{{0, 0, 0, 1}};
Rgba diffuse{{0.8f, 0.8f, 0.8f, 1}}, ambient{{0.8f, 0.8f, 0.8f, 1}},
emissive{{0.8f, 0.8f, 0.8f, 1}};
};
struct EnvFog {
// 两种写法:老版 `Enable`/`NearDistance`/`FarDistance`A1 实盘用 `foglevel`0=关,>0=开)。
bool enable = false; // Enable != 0 || fog_level > 0
bool density_fog = false; // IsDensity(参考端保留,当前 Godot 用深度雾)
int fog_level = 0; // foglevel
float near_distance = 0, far_distance = 0;
Rgba color{{1, 1, 1, 1}};
float near_distance = 25600.0f * 0.5f, far_distance = 25600.0f * 0.7f;
Rgba color{{0.5f, 0.5f, 0.5f, 1}};
};
struct EnvFilter {
bool enable = false;
Rgba color{{0.3f, 0.1f, 0.1f, 0}};
int alpha_src = 1; // D3DBLEND_ONE
int alpha_dest = 1; // D3DBLEND_ONE
};
struct EnvSkyBox {
Vec3f scale{{1, 1, 1}};
Vec3f scale{{3500, 3500, 3500}};
bool texture_render_mode = false;
int gradient_level_upper = 0, gradient_level_lower = 0;
Vec2f cloud_scale{{1, 1}}, cloud_texture_scale{{1, 1}}, cloud_speed{{0, 0}};
float cloud_height = 0;
std::array<std::string, 6> face_textures{}; // front/back/left/right/top/bottom
Vec2f cloud_scale{{200000, 200000}}, cloud_texture_scale{{4, 4}}, cloud_speed{{0.001f, 0.001f}};
float cloud_height = 30000;
std::string cloud_texture;
std::vector<Rgba> cloud_color; // List CloudColor
std::vector<Rgba> gradient; // List Gradient(成对 = 一段渐变的上下色)
@@ -40,7 +50,7 @@ struct EnvSkyBox {
struct EnvLensFlare {
bool enable = false, main_flare_enable = false;
Rgba brightness_color{{1, 1, 1, 1}};
float max_brightness = 0, main_flare_size = 0;
float max_brightness = 1, main_flare_size = 0.2f;
std::string main_flare_texture;
};
@@ -50,8 +60,10 @@ struct Environment {
EnvDirLight dir_light;
EnvMaterial material;
EnvFog fog;
EnvFilter filter;
EnvSkyBox sky;
EnvLensFlare lens_flare;
bool reserved = false;
};
bool parse_environment(const std::string& text, Environment& out, std::string* err);
+66
View File
@@ -20,6 +20,70 @@ static void loop_data(const Node& root, Msa& out) {
}
}
// `List HitPosition { t lastXYZ XYZ t lastXYZ XYZ ... }` —— 7 个一组切成采样点。
static void hit_positions(const Node& win, std::vector<Msa::HitSample>& out) {
const Node* lst = win.group("HitPosition");
if (!lst || lst->lines.empty()) return;
const std::vector<std::string>& t = lst->lines[0];
for (size_t i = 0; i + 7 <= t.size(); i += 7) {
Msa::HitSample s;
s.time = float(std::atof(t[i].c_str()));
for (int k = 0; k < 3; ++k) s.last_pos[k] = float(std::atof(t[i + 1 + k].c_str()));
for (int k = 0; k < 3; ++k) s.pos[k] = float(std::atof(t[i + 4 + k].c_str()));
out.push_back(s);
}
}
// 一个命中窗(NRaceData::THitData)。老式写法里 win == AttackingData 组本身。
static Msa::HitWindow hit_window(const Node& win) {
Msa::HitWindow w;
w.start_time = win.num("AttackingStartTime");
w.end_time = win.num("AttackingEndTime");
w.bone_name = win.str("AttackingBone");
w.weapon_length = win.num("WeaponLength");
hit_positions(win, w.samples);
return w;
}
// Group ComboInputData / Group AttackingData —— 照 EterGrnLib CGrannyMotion::LoadMotionData
// 与 GameLib NRaceData::LoadMotionAttackData 的字段名读取;缺组时保持默认值。
static void combat_data(const Node& root, Msa& out) {
if (const Node* c = root.group("ComboInputData")) {
out.has_combo_input = true;
out.combo_pre_input_time = c->num("PreInputTime");
out.combo_direct_input_time = c->num("DirectInputTime");
out.combo_input_limit_time = c->num("InputLimitTime");
out.combo_link_time = c->num("LinkTime");
}
if (const Node* a = root.group("AttackingData")) {
out.has_attacking_data = true;
out.attacking_type = a->inum("AttackType", a->inum("AttackingType", 0));
out.motion_type = a->inum("MotionType", out.attacking_type);
out.hitting_type = a->inum("HittingType", 0);
out.stiffen_time = a->num("StiffenTime");
out.invisible_time = a->num("InvisibleTime");
out.external_force = a->num("ExternalForce");
out.hit_limit_count = a->inum("HitLimitCount", 0);
// 新式:HitDataCount N + Group HitData00..0N;老式:无 HitDataCount,组本身即窗。
bool has_children = false;
for (const Node& g : a->groups) {
const std::string& n = g.name;
if (n.size() >= 7 && (n.compare(0, 7, "HitData") == 0 || n.compare(0, 7, "hitdata") == 0)) {
out.hit_windows.push_back(hit_window(g));
has_children = true;
}
}
if (!has_children)
out.hit_windows.push_back(hit_window(*a));
if (!out.hit_windows.empty()) {
out.attack_start_time = out.hit_windows.front().start_time;
out.attack_end_time = out.hit_windows.front().end_time;
}
}
}
bool parse_msa(const std::string& text, Msa& out, std::string* err) {
Node root;
if (!parse_textscript(text, root, err)) return false;
@@ -29,6 +93,7 @@ bool parse_msa(const std::string& text, Msa& out, std::string* err) {
out.duration = root.num("MotionDuration");
vec3(root.find("Accumulation"), out.accumulation);
loop_data(root, out);
combat_data(root, out);
if (const Node* med = root.group("MotionEventData")) {
int count = med->inum("MotionEventDataCount", int(med->groups.size()));
@@ -57,6 +122,7 @@ bool parse_msa_file(const std::string& path, Msa& out, std::string* err) {
out.duration = root.num("MotionDuration");
vec3(root.find("Accumulation"), out.accumulation);
loop_data(root, out);
combat_data(root, out);
if (const Node* med = root.group("MotionEventData"))
for (const Node& ev : med->groups) {
MotionEvent e;
+45
View File
@@ -23,6 +23,51 @@ struct Msa {
bool loop_cancel_enable = false;
float loop_start_time = 0;
float loop_end_time = 0;
// Group ComboInputData —— 连击输入时间窗(EterGrnLib CGrannyMotion::LoadMotionData →
// CRaceMotionData::TComboInputData)。攻击节奏来自这里,不是硬编码间隔。
// PreInputTime → fInputStartTime 连击输入窗开始(可提前缓存下一击)
// DirectInputTime→ fNextComboTime 直接输入阈值 / 下一段连击起点(= 普攻节奏)
// InputLimitTime → fInputEndTime 连击输入窗关闭(超时归零)
// LinkTime → fComboLinkTime 段间衔接时间
bool has_combo_input = false;
float combo_pre_input_time = 0;
float combo_direct_input_time = 0;
float combo_input_limit_time = 0;
float combo_link_time = 0;
// Group AttackingData —— 命中判定窗(GameLib GameType.cpp NRaceData::TMotionAttackData)。
// 两种写法:老式(组内直接 AttackingStartTime/…/List HitPosition,无 HitDataCount=
// 单个命中窗;新式(AttackType + MotionType + HitDataCount N + Group HitData00..0N=
// N 个命中窗(多段挥击 / 旋风斩)。命中帧 / 硬直 / 无敌帧由动作数据驱动。
bool has_attacking_data = false;
int attacking_type = 0; // AttackType / AttackingTypeSAttackData::iAttackType
int motion_type = 0; // MotionType(缺省回退到 attacking_type
int hitting_type = 0; // HittingType
float stiffen_time = 0; // StiffenTime(受击硬直,喂给 §3.7 / 受击方)
float invisible_time = 0; // InvisibleTime(攻击者无敌帧)
float external_force = 0; // ExternalForce(击退力度)
int hit_limit_count = 0; // HitLimitCount0 = 不限)
// HitPosition 单个采样:time + 上一帧位置(xyz) + 当前帧位置(xyz),用于挥击扫掠球判定。
struct HitSample {
float time = 0;
float last_pos[3] = {0, 0, 0};
float pos[3] = {0, 0, 0};
};
// 一个命中窗(NRaceData::THitData)。
struct HitWindow {
float start_time = 0; // AttackingStartTime
float end_time = 0; // AttackingEndTime
std::string bone_name; // AttackingBone
float weapon_length = 0; // WeaponLength
std::vector<HitSample> samples; // List HitPosition
};
std::vector<HitWindow> hit_windows; // = THitDataContainer;老式写法长度为 1
// 兼容旧调用方:第一个命中窗的起止时间(无命中窗时为 0)。
float attack_start_time = 0;
float attack_end_time = 0;
};
bool parse_msa(const std::string& text, Msa& out, std::string* err);
+142
View File
@@ -0,0 +1,142 @@
// M2 §3.5 —— playersettingmodule.py 的 ComboAttackNew 调用 -> PC 连击段表。
// 照 GameLib/RaceData.cpp CRaceData::ReserveComboAttack / RegisterComboAttack 语义。
#include <combo_table.h>
#include <cstdio>
#include <cstdlib>
#include <string>
using namespace fmt;
static int g_fail = 0;
#define CHECK(c, msg) \
do { \
if (!(c)) { \
std::fprintf(stderr, "FAIL: %s\n", (msg)); \
++g_fail; \
} \
} while (0)
// warrior_m/_w 共用的 __LoadGameWarriorEx 片段 + assassin 的 DUALHAND(男限),
// 外加一个非 Ex 的 def 用来验证块复位。缩进 / tab 混用照原文。
static const char *kPy = R"PY(
COMBO_TYPE_1 = 0
COMBO_TYPE_2 = 1
COMBO_TYPE_3 = 2
COMBO_INDEX_1 = 0
COMBO_INDEX_6 = 5
def __LoadGameWarriorEx(race, path):
chrmgr.SelectRace(race)
chrmgr.ReserveComboAttackNew(chr.MOTION_MODE_GENERAL, COMBO_TYPE_1, 1)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_GENERAL, COMBO_TYPE_1, COMBO_INDEX_1, chr.MOTION_COMBO_ATTACK_1)
## Combo Type 1
chrmgr.ReserveComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_1, 4)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_1, COMBO_INDEX_1, chr.MOTION_COMBO_ATTACK_1)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_1, COMBO_INDEX_2, chr.MOTION_COMBO_ATTACK_2)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_1, COMBO_INDEX_3, chr.MOTION_COMBO_ATTACK_3)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_1, COMBO_INDEX_4, chr.MOTION_COMBO_ATTACK_4)
## Combo Type 2 —— 段号跳到 5 / 7(非连续,验证按下标写而非追加)
chrmgr.ReserveComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_2, 5)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_2, COMBO_INDEX_1, chr.MOTION_COMBO_ATTACK_1)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_2, COMBO_INDEX_2, chr.MOTION_COMBO_ATTACK_2)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_2, COMBO_INDEX_3, chr.MOTION_COMBO_ATTACK_3)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_2, COMBO_INDEX_4, chr.MOTION_COMBO_ATTACK_5)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_2, COMBO_INDEX_5, chr.MOTION_COMBO_ATTACK_7)
# 越界的 Register(下标 5 >= size 5):照参考端 AssertLog + return,忽略
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_2, 5, chr.MOTION_COMBO_ATTACK_8)
# 对已存在 key 的重复 Reservemap::insert 不覆盖,原向量保留
chrmgr.ReserveComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_2, 99)
def __LoadGameAssassinEx(race, path):
chrmgr.SelectRace(race)
chrmgr.ReserveComboAttackNew(chr.MOTION_MODE_DUALHAND_SWORD, COMBO_TYPE_3, 6)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_DUALHAND_SWORD, COMBO_TYPE_3, COMBO_INDEX_6, chr.MOTION_COMBO_ATTACK_8)
def __LoadGameNPC():
# 块外的调用不该落到任何职业里
chrmgr.ReserveComboAttackNew(chr.MOTION_MODE_GENERAL, COMBO_TYPE_1, 3)
)PY";
static void check_vec(const std::vector<uint16_t> *v, std::initializer_list<uint16_t> want, const char *msg) {
if (!v) { std::fprintf(stderr, "FAIL: %s (nullptr)\n", msg); ++g_fail; return; }
if (v->size() != want.size()) { std::fprintf(stderr, "FAIL: %s (size %zu != %zu)\n", msg, v->size(), want.size()); ++g_fail; return; }
size_t i = 0;
for (uint16_t w : want) {
if ((*v)[i] != w) { std::fprintf(stderr, "FAIL: %s ([%zu]=%u != %u)\n", msg, i, (*v)[i], w); ++g_fail; return; }
++i;
}
}
int main() {
// ---- key 编码:RaceData.h MAKE_COMBO_KEY -------------------------------
CHECK(make_combo_key(COMBO_MODE_ONEHAND_SWORD, 1) == ((2u << 16) | 1u), "make_combo_key");
CHECK(combo_key_mode(make_combo_key(5, 2)) == 5 && combo_key_type(make_combo_key(5, 2)) == 2, "combo_key split");
// ---- 解析 ------------------------------------------------------------
PlayerComboTables tbl;
std::string err;
if (!parse_player_combo_tables(kPy, tbl, &err)) {
std::fprintf(stderr, "parse failed: %s\n", err.c_str());
return 1;
}
const ComboTable &w = tbl.klass(COMBO_CLASS_WARRIOR);
const ComboTable &a = tbl.klass(COMBO_CLASS_ASSASSIN);
const ComboTable &s = tbl.klass(COMBO_CLASS_SURA);
// GENERAL / type1 —— 单段普攻连击(NAME_COMBO_ATTACK_1 = 14
check_vec(w.get(COMBO_MODE_GENERAL, 0), {14}, "warrior GENERAL type1 = [14]");
// ONEHAND_SWORD / type1 —— 连续 4 段 14..17
check_vec(w.get(COMBO_MODE_ONEHAND_SWORD, 0), {14, 15, 16, 17}, "warrior 1H type1 = [14,15,16,17]");
// ONEHAND_SWORD / type2 —— 5 段,末两段跳到 18 / 20COMBO_ATTACK_5 / _7
check_vec(w.get(COMBO_MODE_ONEHAND_SWORD, 1), {14, 15, 16, 18, 20}, "warrior 1H type2 = [14,15,16,18,20]");
// 越界 Register 被忽略;重复 Reserve 不覆盖(size 仍是 5,不是 99)
CHECK(w.get(COMBO_MODE_ONEHAND_SWORD, 1)->size() == 5, "dup Reserve ignored / OOB Register ignored");
// type3 从没 Reserve 过 -> GetComboDataPointer 应为 nullptr
CHECK(w.get(COMBO_MODE_ONEHAND_SWORD, 2) == nullptr, "warrior 1H type3 absent -> nullptr");
// 职业隔离:DUALHAND 只在 assassin 块里;warrior/sura 没有
check_vec(a.get(COMBO_MODE_DUALHAND_SWORD, 2), {0, 0, 0, 0, 0, 21}, "assassin DUAL type3 idx5 = COMBO_ATTACK_8(21)");
CHECK(w.get(COMBO_MODE_DUALHAND_SWORD, 2) == nullptr, "warrior has no DUALHAND table");
CHECK(s.empty(), "sura block absent in snippet -> empty table");
// 块外(__LoadGameNPC)的 Reserve 不该落进任何职业
size_t total = 0;
for (int c = 0; c < COMBO_CLASS_COUNT; ++c) total += tbl.klass(c).key_count();
CHECK(total == 4, "only the 4 in-Ex-block combo keys registered (2 warrior 1H + 1 warrior GENERAL + 1 assassin DUAL)");
// 越界职业索引 -> 空表,不崩
CHECK(tbl.klass(-1).empty() && tbl.klass(99).empty(), "out-of-range class -> empty");
// ---- 可选:真实 playersettingmodule.py(设了 M2_ASSETS 才跑)----------
if (const char *root = std::getenv("M2_ASSETS")) {
const std::string path = std::string(root) + "/root/playersettingmodule.py";
PlayerComboTables live;
std::string lerr;
if (parse_player_combo_tables_file(path, live, &lerr)) {
check_vec(live.klass(COMBO_CLASS_WARRIOR).get(COMBO_MODE_ONEHAND_SWORD, 0),
{14, 15, 16, 17}, "live warrior 1H type1");
// sura ONEHAND type3 末段回到 COMBO_ATTACK_4 (17)
const auto *sv = live.klass(COMBO_CLASS_SURA).get(COMBO_MODE_ONEHAND_SWORD, 2);
CHECK(sv && sv->size() == 6 && sv->back() == 17, "live sura 1H type3 tail = COMBO_ATTACK_4");
// shaman FAN type1 = 4 连续段
check_vec(live.klass(COMBO_CLASS_SHAMAN).get(COMBO_MODE_FAN, 0),
{14, 15, 16, 17}, "live shaman FAN type1");
std::printf("live playersettingmodule.py: OK\n");
} else {
std::fprintf(stderr, "live parse skipped: %s\n", lerr.c_str());
}
}
if (g_fail == 0) {
std::printf("PASS: formats_combo_table_test (§3.5 PC 连击段表)\n");
}
return g_fail == 0 ? 0 : 1;
}
+27
View File
@@ -199,12 +199,22 @@ Group DirectionalLight
Group Fog
{
foglevel 6
IsDensity 1
Color 0.69 0.74 0.83 1.0
}
Group Filter
{
Enable 1
Color 0.1 0.2 0.3 0.4
AlphaSrc 5
AlphaDest 6
}
Group SkyBox
{
BTextureRenderMode 1
Scale 3500.0 3500.0 3500.0
CloudTextureFileName "d:/ymir work/environment/clouds_zone01.tga"
FrontFaceFileName "d:/ymir work/environment/skybox/front.dds"
List Gradient
{
0.09 0.28 0.65 0.0
@@ -221,6 +231,12 @@ Group SkyBox
CHECK(e.dir_light.bg_enable && e.dir_light.ch_enable, "env bg/ch enable");
CHECK(std::fabs(e.dir_light.bg_diffuse[1] - 0.97f) < 1e-3f, "env bg diffuse");
CHECK(e.fog.enable && e.fog.fog_level == 6, "env fog (foglevel form)");
CHECK(e.fog.density_fog, "env density fog flag");
CHECK(e.filter.enable && e.filter.alpha_src == 5 && e.filter.alpha_dest == 6,
"env screen filter fields");
CHECK(std::fabs(e.filter.color[2] - 0.3f) < 1e-3f, "env screen filter color");
CHECK(e.sky.texture_render_mode && e.sky.face_textures[0].find("front.dds") != std::string::npos,
"env skybox texture mode + front face");
CHECK(std::fabs(e.sky.scale[0] - 3500.0f) < 1e-1f, "env skybox scale");
CHECK(e.sky.cloud_texture.find("clouds_zone01.tga") != std::string::npos, "env cloud tex path");
CHECK(e.sky.gradient.size() == 3, "env List Gradient rows");
@@ -452,6 +468,17 @@ static void test_live(const std::string& assets) {
CHECK(env.script_type == "EnvrionmentData", "live msenv script_type");
CHECK(env.fog.enable && env.sky.gradient.size() >= 8, "live msenv fog + gradient list");
fmt::Environment trent;
CHECK(fmt::parse_environment_file(
assets + "/season2/season2/metin2_map_trent02_a/trent02.msenv", trent, &err),
("live trent02.msenv: " + err).c_str());
CHECK(trent.dir_light.ch_enable && trent.dir_light.ch_ambient[0] > 0.39f,
"live trent character light");
CHECK(!trent.filter.enable && trent.filter.alpha_src == 2 && trent.filter.alpha_dest == 2,
"live trent filter fields");
CHECK(trent.sky.cloud_color.size() == 2 && trent.lens_flare.enable,
"live trent cloud gradient + lens flare");
// Property CRC 注册表
fmt::PropertyRegistry reg;
CHECK(reg.scan(assets + "/Property", &err), ("live Property scan: " + err).c_str());
+197
View File
@@ -30,5 +30,202 @@ Group LoopData
std::fprintf(stderr, "LoopData fields were not preserved\n");
return 1;
}
// A motion with no ComboInputData / AttackingData leaves the flags clear.
if (msa.has_combo_input || msa.has_attacking_data) {
std::fprintf(stderr, "combat groups reported present when absent\n");
return 1;
}
// CLIENT-GAP §3.3 / §3.5 —— real onehand_sword/combo_01.msa field values.
const std::string combo = R"(
ScriptType MotionData
MotionFileName "d:/ymir work/pc/warrior/onehand_sword/combo_01.gr2"
MotionDuration 1.000000
Accumulation 0.00 -131.76 0.00
Group ComboInputData
{
PreInputTime 0.167094
DirectInputTime 0.533333
InputLimitTime 0.602564
LinkTime 0.058889
}
Group AttackingData
{
AttackingType 2
HittingType 2
StiffenTime 0.0
InvisibleTime 0.100000
ExternalForce 8.000000
AttackingStartTime 0.192308
AttackingEndTime 0.315385
}
)";
fmt::Msa cm;
if (!fmt::parse_msa(combo, cm, &err)) {
std::fprintf(stderr, "combo parse failed: %s\n", err.c_str());
return 1;
}
const bool combo_ok = cm.has_combo_input &&
std::fabs(cm.combo_pre_input_time - 0.167094f) < 1e-5f &&
std::fabs(cm.combo_direct_input_time - 0.533333f) < 1e-5f &&
std::fabs(cm.combo_input_limit_time - 0.602564f) < 1e-5f &&
std::fabs(cm.combo_link_time - 0.058889f) < 1e-5f;
const bool atk_ok = cm.has_attacking_data && cm.attacking_type == 2 && cm.hitting_type == 2 &&
std::fabs(cm.attack_start_time - 0.192308f) < 1e-5f &&
std::fabs(cm.attack_end_time - 0.315385f) < 1e-5f &&
std::fabs(cm.invisible_time - 0.1f) < 1e-5f &&
std::fabs(cm.external_force - 8.0f) < 1e-5f;
if (!combo_ok || !atk_ok) {
std::fprintf(stderr, "ComboInputData / AttackingData fields were not preserved\n");
return 1;
}
// §3.5 修改 1 —— 老式内联写法(无 HitDataCount):单个命中窗,含 List HitPosition 采样。
const std::string inl = R"(
ScriptType MotionData
MotionFileName "d:/ymir work/pc/warrior/general/attack.gr2"
MotionDuration 1.0
Group AttackingData
{
AttackingType 1
HittingType 2
StiffenTime 0.0
InvisibleTime 0.5
ExternalForce 0.0
AttackingStartTime 0.456410
AttackingEndTime 0.597436
AttackingBone "equip_right_hand"
WeaponLength 0.0
List HitPosition
{
0.456410 1.0 2.0 3.0 4.0 5.0 6.0
0.464744 7.0 8.0 9.0 10.0 11.0 12.0
}
}
)";
fmt::Msa im;
if (!fmt::parse_msa(inl, im, &err)) {
std::fprintf(stderr, "inline attacking parse failed: %s\n", err.c_str());
return 1;
}
if (im.hit_windows.size() != 1 || im.hit_windows[0].samples.size() != 2 ||
im.hit_windows[0].bone_name != "equip_right_hand" ||
std::fabs(im.hit_windows[0].start_time - 0.456410f) > 1e-5f ||
std::fabs(im.hit_windows[0].end_time - 0.597436f) > 1e-5f ||
std::fabs(im.attack_start_time - 0.456410f) > 1e-5f ||
std::fabs(im.hit_windows[0].samples[1].time - 0.464744f) > 1e-5f ||
std::fabs(im.hit_windows[0].samples[1].last_pos[0] - 7.0f) > 1e-5f ||
std::fabs(im.hit_windows[0].samples[1].pos[2] - 12.0f) > 1e-5f) {
std::fprintf(stderr, "inline HitData window / HitPosition samples not preserved\n");
return 1;
}
// §3.5 修改 1 —— 新式写法:AttackType + MotionType + HitDataCount N + Group HitData0N。
const std::string multi = R"(
ScriptType MotionData
MotionFileName "d:/ymir work/pc/warrior/twohand_sword/combo_05.gr2"
MotionDuration 1.6
Group AttackingData
{
AttackType 0
HittingType 2
StiffenTime 0.0
InvisibleTime 0.2
ExternalForce 5.0
HitLimitCount 3
MotionType 2
HitDataCount 2
Group HitData00
{
AttackingStartTime 0.455475
AttackingEndTime 0.759124
AttackingBone "equip_right_hand"
WeaponLength 150.0
List HitPosition
{
0.455475 54.5 -30.5 71.1 89.9 105.7 19.4
}
}
Group HitData01
{
AttackingStartTime 0.9
AttackingEndTime 1.1
AttackingBone "equip_left_hand"
WeaponLength 120.0
}
}
)";
fmt::Msa mm;
if (!fmt::parse_msa(multi, mm, &err)) {
std::fprintf(stderr, "multi HitData parse failed: %s\n", err.c_str());
return 1;
}
if (mm.attacking_type != 0 || mm.motion_type != 2 || mm.hit_limit_count != 3 ||
mm.hit_windows.size() != 2 ||
std::fabs(mm.hit_windows[0].start_time - 0.455475f) > 1e-5f ||
mm.hit_windows[0].samples.size() != 1 ||
mm.hit_windows[1].bone_name != "equip_left_hand" ||
std::fabs(mm.hit_windows[1].end_time - 1.1f) > 1e-5f ||
mm.hit_windows[1].samples.size() != 0 ||
std::fabs(mm.attack_start_time - 0.455475f) > 1e-5f) {
std::fprintf(stderr, "multi HitData windows not preserved\n");
return 1;
}
// ---- 可选:真实 PC .msa(设了 M2_ASSETS 才跑)----------------------------
if (const char* root = std::getenv("M2_ASSETS")) {
const std::string base = std::string(root) + "/PC/ymir work/pc/warrior/";
fmt::Msa a1;
if (fmt::parse_msa_file(base + "general/attack.msa", a1, &err)) {
if (a1.hit_windows.size() != 1 || a1.hit_windows[0].bone_name != "equip_right_hand" ||
std::fabs(a1.hit_windows[0].start_time - 0.456410f) > 1e-4f ||
std::fabs(a1.hit_windows[0].end_time - 0.597436f) > 1e-4f ||
a1.hit_windows[0].samples.size() != 17) {
std::fprintf(stderr, "live general/attack.msa window/samples wrong "
"(windows=%zu samples=%zu)\n", a1.hit_windows.size(),
a1.hit_windows.empty() ? 0 : a1.hit_windows[0].samples.size());
return 1;
}
}
fmt::Msa c5;
if (fmt::parse_msa_file(base + "twohand_sword/combo_05.msa", c5, &err)) {
if (c5.hit_windows.size() != 1 || c5.motion_type != 2 ||
std::fabs(c5.hit_windows[0].weapon_length - 150.0f) > 1e-3f ||
std::fabs(c5.hit_windows[0].start_time - 0.455475f) > 1e-4f ||
c5.hit_windows[0].samples.size() != 37) {
std::fprintf(stderr, "live twohand_sword/combo_05.msa wrong "
"(windows=%zu samples=%zu)\n", c5.hit_windows.size(),
c5.hit_windows.empty() ? 0 : c5.hit_windows[0].samples.size());
return 1;
}
}
// 真正的双持双命中窗(右手窗 + 左手窗)。
const std::string adh = std::string(root) + "/PC/ymir work/pc/assassin/dualhand_sword/combo_01.msa";
fmt::Msa dh;
if (fmt::parse_msa_file(adh, dh, &err)) {
if (dh.hit_windows.size() != 2 ||
dh.hit_windows[0].bone_name != "Bip01 R Hand" ||
dh.hit_windows[1].bone_name != "Bip01 L Hand" ||
dh.hit_windows[0].samples.size() != 13 ||
dh.hit_windows[1].samples.size() != 13 ||
std::fabs(dh.hit_windows[1].start_time - 0.605128f) > 1e-4f) {
std::fprintf(stderr, "live assassin dualhand combo_01.msa two-window wrong "
"(windows=%zu)\n", dh.hit_windows.size());
return 1;
}
}
fmt::Msa pb;
if (fmt::parse_msa_file(base + "skill/palbang.msa", pb, &err)) {
// AttackingData 后还有 Group MotionEventData —— List 修复后二者都要解析到。
if (pb.hit_windows.size() != 1 || pb.hit_limit_count != 18 ||
std::fabs(pb.hit_windows[0].weapon_length - 130.0f) > 1e-3f ||
pb.events.size() != 3) {
std::fprintf(stderr, "live skill/palbang.msa wrong "
"(windows=%zu events=%zu)\n", pb.hit_windows.size(), pb.events.size());
return 1;
}
}
}
return 0;
}
+6 -3
View File
@@ -48,11 +48,14 @@ bool parse_body(const std::vector<std::string>& t, size_t& i, Node& node, std::s
while (i < t.size()) {
const std::string& tok = t[i];
if (tok == "}") { ++i; return true; }
if (ieq(tok, "Group")) {
if (i + 2 >= t.size()) { if (err) *err = "Group 缺名字 / {"; return false; }
// `Group Name { ... }` 和 `List Name { ... }`CTextFileLoader 的 GetTokenVector
// 都当子节点:Group 体是 key/子组,List 体是一串裸值(收进子节点的单行)。
if (ieq(tok, "Group") || ieq(tok, "List")) {
const char* kind = ieq(tok, "Group") ? "Group" : "List";
if (i + 2 >= t.size()) { if (err) *err = std::string(kind) + " 缺名字 / {"; return false; }
Node sub;
sub.name = t[i + 1];
if (t[i + 2] != "{") { if (err) *err = "Group '" + sub.name + "' 后缺 {"; return false; }
if (t[i + 2] != "{") { if (err) *err = std::string(kind) + " '" + sub.name + "' 后缺 {"; return false; }
i += 3;
if (!parse_body(t, i, sub, err)) return false;
node.groups.push_back(std::move(sub));
+46
View File
@@ -0,0 +1,46 @@
# ambience_test —— AreaAmbienceData + .pra -> Audio source contract.
# godot --headless --path project --script ambience_test.gd
extends SceneTree
var _fail := 0
func _ck(ok: bool, message: String) -> void:
if not ok:
_fail += 1
printerr("FAIL: " + message)
func _init() -> void:
if not ClassDB.class_exists("Metin2World"):
printerr("FAIL: Metin2World extension is not registered")
quit(1)
return
var assets := AssetRoot.path()
var world: Node = ClassDB.instantiate("Metin2World")
world.set("auto_load", false)
world.set("assets_root", assets)
world.set("map_path", "OutdoorA3/metin2_map_a3")
world.set("focus_tile", Vector2i(3, 3))
world.set("load_radius_tiles", 0)
world.set("splat_enabled", false)
world.set("water_enabled", false)
world.set("env_enabled", false)
get_root().add_child(world)
var ok: bool = world.call("load_map")
_ck(ok, "A3 map chunk loads")
var sources: Array = world.call("get_ambience_sources")
_ck(sources.size() == 1, "A3 chunk exposes one ambience source")
if sources.size() == 1:
var source: Dictionary = sources[0]
_ck(int(source.get("range_cm", 0)) == 3300, "ambience range is preserved in cm")
_ck(String(source.get("play_type", "")).to_upper() == "LOOP", "ambience play type is LOOP")
var sounds: Array = source.get("sounds", [])
_ck(sounds.size() == 1 and String(sounds[0]) == "sound/ambience/warp_test.mp3",
".pra sound vector is exposed to Audio")
world.queue_free()
await process_frame
if _fail == 0:
print("PASS: ambience_test (AreaAmbienceData + .pra + world/audio contract)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
+17 -3
View File
@@ -14,6 +14,7 @@ const ChannelStatus = preload("res://net/channel_status.gd")
const LoadingScreen = preload("res://ui/loading_screen.gd")
const CharSelectScreen = preload("res://ui/char_select_screen.gd")
const GameScene = preload("res://game_scene.gd")
const AppLifecycle = preload("res://app_lifecycle.gd")
enum { LOGIN, SELECT, GAME }
@@ -24,15 +25,16 @@ var _assets := ""
var _ui: CanvasLayer # 登录 / 选人的临时界面层
var _loading: CanvasLayer
var _game: Node
var _lifecycle: Node # 全流程唯一生命周期协调者
var build_game_scene := true # 测试里置 false,只跑状态机不建重场景
var _sel_server := 0
var _sel_channel := 1
var _id := "admin"
var _pw := "123456789"
var _id := OS.get_environment("MT_ACCOUNT")
var _pw := OS.get_environment("MT_PASSWORD")
var _chars: Array = []
var _mark_redl_after := 0 # guild-mark re-download cooldown (ticks_msec)
# MT_AUTOLOGIN=1:进 LOGIN 自动连接,收到角色列表自动选第一个(冒烟 / CI 用)。
var auto_login := OS.get_environment("MT_AUTOLOGIN") == "1"
var auto_login := OS.get_environment("MT_AUTOLOGIN") == "1" and not _id.is_empty() and not _pw.is_empty()
func start(assets_root: String = "", injected_client: Node = null) -> void:
_assets = assets_root
@@ -51,6 +53,13 @@ func start(assets_root: String = "", injected_client: Node = null) -> void:
_loading = LoadingScreen.new()
add_child(_loading)
_loading.setup(client)
# §9.3:生命周期属于 AppFlow,而不是会被切屏销毁的 GameScene。
# 这样登录、选人、进游戏始终只有一个通知入口;Audio 在 GameScene
# 完成装配后再绑定,客户端从 start() 起就已经绑定。
_lifecycle = AppLifecycle.new()
_lifecycle.name = "AppLifecycle"
add_child(_lifecycle)
_lifecycle.bind(client)
_goto_login()
if auto_login:
call_deferred("_do_connect")
@@ -133,6 +142,9 @@ func _clear_ui() -> void:
func _goto_login() -> void:
_state = LOGIN
if _lifecycle:
# GameScene 即将释放,不能让生命周期协调者保留悬空 Audio 引用。
_lifecycle.bind(client)
if _game:
_game.queue_free()
_game = null
@@ -165,6 +177,8 @@ func _goto_game() -> void:
add_child(_game)
if _game.has_method("setup"):
await _game.setup(client, _assets) # 协程:分帧,不阻塞 socket
if _lifecycle and is_instance_valid(_game) and _game.has_method("lifecycle_audio"):
_lifecycle.bind(client, _game.lifecycle_audio())
if _loading:
_loading.hide_screen()
+51
View File
@@ -0,0 +1,51 @@
# app_flow_lifecycle_test —— §9.3 生命周期单一属主回归。
# AppFlow 跨登录 / 选人 / 游戏持有唯一 AppLifecycleGameScene 不重复创建。
extends SceneTree
const AppFlow = preload("res://app_flow.gd")
class FakeClient extends Node:
signal phase_changed(phase: String)
signal disconnected(reason: String)
signal char_list(characters: Array)
signal char_name_changed(pid: int, name: String)
signal entered_game()
signal login_failed(reason: String)
signal char_created(slot: int)
signal char_create_failed(reason: int)
signal char_deleted(slot: int)
signal char_delete_failed()
var _fail := 0
func _ck(ok: bool, message: String) -> void:
if not ok:
_fail += 1
printerr("FAIL: " + message)
func _init() -> void:
var client := FakeClient.new()
var flow := AppFlow.new()
flow.build_game_scene = false
get_root().add_child(flow)
flow.start("", client)
await process_frame
var lifecycle_nodes: Array[Node] = []
for child in flow.get_children():
if child.name == "AppLifecycle":
lifecycle_nodes.append(child)
_ck(lifecycle_nodes.size() == 1, "AppFlow owns exactly one AppLifecycle")
_ck(flow.get_node_or_null("AppLifecycle") != null,
"AppLifecycle survives login screen construction")
_ck(client.get_parent() == flow,
"AppFlow keeps the same client beside lifecycle coordinator")
flow.queue_free()
await process_frame
if _fail == 0:
print("PASS: app_flow_lifecycle_test (single lifecycle owner)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
+384 -11
View File
@@ -1,9 +1,19 @@
# Audio —— BGM 交叉淡入淡出 + UI 音效池 + 3D 定位音效。
# 对标客户端 AudioLibBGM`bgm/*.mp3`)、UI wav`**/sound/ui/`)、
# 3D wav`**/sound/<rel>`)。纯资产接线,无解码器移植(`.mss` 不用)。
# 3D wav`**/sound/<rel>`)。`.mss` 只保存按帧触发的声音文件名,
# 在 Godot 侧解析为 60 FPS 的声音实例,再由同一个 Miles 音频入口播放。
# 用 preload("res://audio.gd").new() 造一个,add_child,再 setup()。
extends Node
## MilesLib constants (40250/ClientVS22/source/MilesLib/SoundManager.cpp).
## The reference client keeps the listener at (0, 0, 0) and submits world
## coordinates relative to it, divided by the sound scale.
const SOUND_SCALE := 200.0
const AMBIENCE_SOUND_SCALE := 1000.0
const FADE_SPEED := 0.016
const CHARACTER_SOUND_MIN_INTERVAL := 0.3
const CHARACTER_SOUND_LIMIT_DISTANCE_SQ := 5000.0 * 5000.0
var _assets: String
var _bgm_root: String
var _sound_roots: PackedStringArray = []
@@ -18,6 +28,20 @@ var _ui_next := 0
var master_bgm := 0.6
var master_sfx := 0.9
var _queued_bgm := ""
var sound_scale := SOUND_SCALE
var ambience_sound_scale := AMBIENCE_SOUND_SCALE
var _listener_world := Vector3.ZERO
var _listener_forward := Vector3(0, 0, -1)
var _listener_up := Vector3.UP
var _spatial_root: Node3D
var _listener: AudioListener3D
var _next_3d_id := 1
var _active_3d := {} # int -> AudioStreamPlayer3D
var _character_history := {} # sound path -> last time (seconds)
var _mss_cache := {} # mss path + header -> sound instances
var _ambience_states := {} # source key -> {id, inside, next_time}
const SOUND_FPS := 60.0
# §9.2 前后台音量存/恢复,字段名对齐参考 CSoundManager 的
# m_fBackupMusicVolume / m_fBackupSoundVolumeREF/MilesLib/SoundManager.h:31)。
@@ -34,6 +58,7 @@ func _ready() -> void:
func setup(assets_root: String) -> void:
_assets = assets_root
_sound_roots.clear()
# bgm/ 与 assets/ 同级(<repo>/bgm,见 AssetRoot
_bgm_root = _assets.get_base_dir().path_join("bgm")
# 已知声音根 + 各 patch 的 sound/
@@ -57,6 +82,48 @@ func setup(assets_root: String) -> void:
var pl := AudioStreamPlayer.new()
add_child(pl)
_ui_pool.append(pl)
_ensure_spatial_audio()
func _ensure_spatial_audio() -> void:
if _spatial_root and is_instance_valid(_spatial_root):
return
_spatial_root = Node3D.new()
_spatial_root.name = "MilesSpatialAudio"
add_child(_spatial_root)
_listener = AudioListener3D.new()
_listener.name = "MilesListener"
_spatial_root.add_child(_listener)
_listener.current = true
func set_listener(world_pos: Vector3, forward := Vector3(0, 0, -1), up := Vector3.UP) -> void:
# SetPosition / SetDirection + Update in SoundManager. The Godot listener
# stays at the origin; active instances are re-based just like Miles3D.
_listener_world = world_pos
_listener_forward = forward.normalized() if forward.length_squared() > 0.000001 else Vector3(0, 0, -1)
_listener_up = up.normalized() if up.length_squared() > 0.000001 else Vector3.UP
if _listener:
_listener.position = Vector3.ZERO
_listener.look_at(_listener.position + _listener_forward, _listener_up)
for id in _active_3d:
var pl: AudioStreamPlayer3D = _active_3d[id]
if is_instance_valid(pl):
var scale: float = float(pl.get_meta("miles_scale", sound_scale))
pl.position = (pl.get_meta("miles_world", _listener_world) - _listener_world) * 100.0 / maxf(scale, 0.001)
func update_listener(world_pos: Vector3, forward := Vector3(0, 0, -1), up := Vector3.UP) -> void:
set_listener(world_pos, forward, up)
func set_sound_scale(value: float) -> void:
sound_scale = maxf(value, 0.001)
func set_ambience_sound_scale(value: float) -> void:
ambience_sound_scale = maxf(value, 0.001)
func get_sound_scale() -> float:
return sound_scale
func get_ambience_sound_scale() -> float:
return ambience_sound_scale
# --- resolution -----------------------------------------------------------
@@ -78,9 +145,17 @@ func _load_stream(path: String) -> AudioStream:
func _find_sound(rel: String) -> String:
rel = rel.replace("\\", "/")
var cands := PackedStringArray([rel])
if rel.get_extension().is_empty():
cands = PackedStringArray([rel + ".wav", rel + ".ogg"])
var rels := PackedStringArray([rel])
# ClientVS22's .mss files store paths with the virtual `sound/` prefix,
# while each mounted sound root already points at `<patch>/sound`.
if rel.to_lower().begins_with("sound/"):
rels.append(rel.substr(6))
var cands := PackedStringArray()
for candidate: String in rels:
cands.append(candidate)
if candidate.get_extension().is_empty():
cands.append(candidate + ".wav")
cands.append(candidate + ".ogg")
for root in _sound_roots:
for c in cands:
var p := root.path_join(c)
@@ -95,6 +170,120 @@ func _find_sound(rel: String) -> String:
return p
return ""
# --- MilesLib .mss sound scripts -----------------------------------------
# ClientVS22/ MilesLib/Type.cpp::LoadSoundInformationPiece. A sound script
# contains `SoundDataCount N` followed by exactly N `SoundData%02d time
# "path"` records. The native loader replaces the output vector on every
# call and rejects a missing or malformed record; preserve that contract here.
func load_sound_information_piece(file_path: String, path_header: String = "") -> Array:
var cache_key := file_path + "\n" + path_header
if _mss_cache.has(cache_key):
return _mss_cache[cache_key].duplicate(true)
var empty: Array = []
var f := FileAccess.open(file_path, FileAccess.READ)
if f == null:
_mss_cache[cache_key] = empty
return empty
var count := -1
var records := {}
while not f.eof_reached():
var tokens := _mss_tokens(f.get_line())
if tokens.is_empty():
continue
var key := String(tokens[0]).to_lower()
if key == "sounddatacount":
if tokens.size() != 2:
_mss_cache[cache_key] = empty
return empty
count = int(tokens[1])
continue
if not key.begins_with("sounddata") or key.length() <= 9:
continue
var index_text := key.substr(9)
if not index_text.is_valid_int() or tokens.size() != 3:
continue
var index := int(index_text)
if index < 0:
continue
records[index] = {"time": float(tokens[1]),
"sound": path_header + String(tokens[2])}
if count < 0 or count > 4096:
_mss_cache[cache_key] = empty
return empty
var out: Array = []
for i in count:
if not records.has(i):
_mss_cache[cache_key] = empty
return empty
out.append(records[i])
_mss_cache[cache_key] = out.duplicate(true)
return out
# Exact equivalent of NSound::DataToInstance: `DWORD(time / (1/60))`.
func data_to_sound_instances(sound_data: Array) -> Array:
var out: Array = []
for row in sound_data:
if not row is Dictionary:
continue
var time := maxf(0.0, float(row.get("time", 0.0)))
out.append({"frame": int(time * SOUND_FPS), "sound": String(row.get("sound", "")),
"time": time})
return out
func load_mss_instances(file_path: String, path_header: String = "") -> Array:
return data_to_sound_instances(load_sound_information_piece(file_path, path_header))
# Resolve the virtual `sound/<tail>.mss` generated by RaceMotionData from a
# motion .msa path. Sound roots are patch-aware and searched in their setup
# order, just like `_find_sound`.
func load_mss_for_motion(motion_path: String) -> Array:
var p := motion_path.replace("\\", "/")
var lower := p.to_lower()
var marker := "/ymir work/"
var at := lower.find(marker)
if at < 0:
return []
var tail := p.substr(at + marker.length()).get_basename() + ".mss"
for root in _sound_roots:
var candidate := root.path_join(tail)
if FileAccess.file_exists(candidate):
return load_mss_instances(candidate)
return []
# Equivalent of CSoundManager::UpdateSoundInstance(fx, fy, fz, frame, ...).
# The caller owns the animation frame cursor and calls this once per newly
# reached frame, so looping animations naturally replay frame-zero records.
func update_sound_instances_3d(fx: float, fy: float, fz: float, frame: int,
instances: Array, check_frequency := false) -> int:
var played := 0
for row in instances:
if not row is Dictionary or int(row.get("frame", -1)) != frame:
continue
var id := play_character_sound_3d(fx, fy, fz, String(row.get("sound", "")), check_frequency)
if id >= 0:
played += 1
return played
func _mss_tokens(line: String) -> Array:
var out: Array = []
var word := ""
var quoted := false
for i in line.length():
var c := line.substr(i, 1)
if c == "\"":
quoted = not quoted
continue
if not quoted and (c == " " or c == "\t" or c == "\r"):
if word != "":
out.append(word)
word = ""
continue
word += c
if word != "":
out.append(word)
return out
# --- BGM ---------------------------------------------------------------
func play_bgm(name: String, fade := 1.2) -> void:
@@ -141,6 +330,30 @@ func set_music_volume(v: float) -> void:
if pl and pl.playing:
pl.volume_db = linear_to_db(master_bgm) if master_bgm > 0.0 else -80.0
static func ratio_to_apply_volume(ratio: float) -> float:
# CSoundManager::__ConvertRatioVolumeToApplyVolume.
ratio = clampf(ratio, 0.0, 1.0)
return ratio if ratio < 0.1 else pow(10.0, -1.0 + ratio)
static func grade_to_apply_volume(grade: int) -> float:
return ratio_to_apply_volume(float(grade) / 5.0)
func set_sound_volume(v: float) -> void:
master_sfx = clampf(v, 0.0, 1.0)
_apply_3d_volume()
func set_sound_volume_ratio(ratio: float) -> void:
set_sound_volume(ratio_to_apply_volume(ratio))
func set_sound_volume_grade(grade: int) -> void:
set_sound_volume(grade_to_apply_volume(grade))
func set_music_volume_ratio(ratio: float) -> void:
set_music_volume(ratio_to_apply_volume(ratio))
func set_music_volume_grade(grade: int) -> void:
set_music_volume(ratio_to_apply_volume(float(grade) / 5.0))
# §9.2:进入后台前保存音量(CSoundManager::SaveVolume)。
func save_volume() -> void:
_backup_music_volume = master_bgm
@@ -178,18 +391,178 @@ func play_ui(name: String) -> void:
pl.play()
func play_at(rel: String, world_pos: Vector3, parent: Node = null) -> void:
play_sound_3d(world_pos.x, world_pos.y, world_pos.z, rel, 1)
func play_sound_3d(fx: float, fy: float, fz: float, rel: String, play_count := 1) -> int:
return _play_3d(rel, Vector3(fx, fy, fz), sound_scale, play_count)
func play_ambience_sound_3d(fx: float, fy: float, fz: float, rel: String, play_count := 1) -> int:
return _play_3d(rel, Vector3(fx, fy, fz), ambience_sound_scale, play_count)
func update_ambience_sources(sources: Array) -> void:
# CArea::UpdateAroundAmbience: source positions/ranges are in centimetres
# on the reference side. The world adapter exposes metres, so convert the
# listener distance back to centimetres before applying the strict range.
var now := Time.get_ticks_msec() / 1000.0
var seen := {}
for i in sources.size():
var source: Dictionary = sources[i] if sources[i] is Dictionary else {}
var key := String(source.get("key", i))
seen[key] = true
var sounds: Array = source.get("sounds", [])
if sounds.is_empty():
continue
var pos: Vector3 = source.get("position", Vector3.ZERO)
var range_cm := maxf(0.0, float(source.get("range_cm", 0.0)))
var inside := (pos - _listener_world).length() * 100.0 < range_cm
var state: Dictionary = _ambience_states.get(key, {"id": -1, "inside": false, "next_time": 0.0})
var sound := String(sounds[0])
var play_type := String(source.get("play_type", "LOOP")).to_upper()
var id := int(state.get("id", -1))
if play_type == "LOOP":
if inside:
if id < 0 or not _active_3d.has(id):
id = play_ambience_sound_3d(pos.x, pos.y, pos.z, sound, 0)
if id >= 0:
var percentage := clampf(float(source.get("max_volume_area_percentage", 0.0)), 0.0, 1.0)
var full_volume_radius := range_cm * percentage
var distance_cm := (pos - _listener_world).length() * 100.0
var volume := 1.0 if full_volume_radius <= 0.0 or distance_cm <= full_volume_radius else \
1.0 - ((distance_cm - full_volume_radius) / maxf(range_cm - full_volume_radius, 0.001))
set_sound_volume_3d(id, clampf(volume, 0.0, 1.0))
else:
if id >= 0:
stop_sound_3d(id)
id = -1
elif play_type == "ONCE":
if inside and not bool(state.get("inside", false)):
id = play_ambience_sound_3d(pos.x, pos.y, pos.z, sound, 1)
elif not inside:
# ClientVS22 drops the bookkeeping id on exit; the one-shot itself
# is allowed to finish playing.
id = -1
elif play_type == "STEP":
if inside and now >= float(state.get("next_time", 0.0)):
id = play_ambience_sound_3d(pos.x, pos.y, pos.z, sound, 1)
var interval := maxf(0.0, float(source.get("play_interval", 0.0)))
var variation := maxf(0.0, float(source.get("play_interval_variation", 0.0)))
state["next_time"] = now + interval + randf_range(0.0, variation)
elif not inside:
id = -1
state["id"] = id
state["inside"] = inside
_ambience_states[key] = state
for key in _ambience_states.keys().duplicate():
if seen.has(key):
continue
var old: Dictionary = _ambience_states[key]
var old_id := int(old.get("id", -1))
if old_id >= 0:
stop_sound_3d(old_id)
_ambience_states.erase(key)
func can_play_character_sound(rel: String, world_pos: Vector3, now := -1.0) -> bool:
# PlayCharacterSound3D's optional bCheckFrequency path. Coordinates in the
# game scene are metres; convert to centimetres before applying the exact
# 5000*5000 ClientVS22 threshold.
var delta_cm := (world_pos - _listener_world) * 100.0
if delta_cm.x * delta_cm.x + delta_cm.y * delta_cm.y > CHARACTER_SOUND_LIMIT_DISTANCE_SQ:
return false
if now < 0.0:
now = Time.get_ticks_msec() / 1000.0
var key := rel.replace("\\", "/")
if _character_history.has(key) and now - float(_character_history[key]) < CHARACTER_SOUND_MIN_INTERVAL:
return false
_character_history[key] = now
return true
func play_character_sound_3d(fx: float, fy: float, fz: float, rel: String, check_frequency := false) -> int:
var p := Vector3(fx, fy, fz)
if check_frequency and not can_play_character_sound(rel, p):
return -1
return _play_3d(rel, p, sound_scale, 1)
func _play_3d(rel: String, world_pos: Vector3, scale: float, play_count: int) -> int:
if master_sfx <= 0.0:
return -1
var path := _find_sound(rel)
if path.is_empty():
return
return -1
var s := _load_stream(path)
if s == null:
return
return -1
_ensure_spatial_audio()
var pl := AudioStreamPlayer3D.new()
pl.stream = s
pl.unit_size = 6.0
pl.max_distance = 60.0
pl.volume_db = linear_to_db(master_sfx)
(parent if parent else self).add_child(pl)
pl.global_position = world_pos
pl.play()
pl.finished.connect(pl.queue_free)
pl.volume_db = _volume_db(master_sfx)
_spatial_root.add_child(pl)
pl.set_meta("miles_world", world_pos)
pl.set_meta("miles_scale", scale)
pl.position = (world_pos - _listener_world) * 100.0 / maxf(scale, 0.001)
var id := _next_3d_id
_next_3d_id += 1
_active_3d[id] = pl
pl.set_meta("miles_remaining", maxi(play_count, 1))
pl.set_meta("miles_loop", play_count == 0)
pl.finished.connect(func():
if not is_instance_valid(pl):
return
if bool(pl.get_meta("miles_loop", false)):
pl.play()
return
var remaining := int(pl.get_meta("miles_remaining", 1)) - 1
if remaining > 0:
pl.set_meta("miles_remaining", remaining)
pl.play()
else:
_active_3d.erase(id)
pl.queue_free())
if pl.is_inside_tree():
pl.play()
else:
pl.call_deferred("play")
return id
func _volume_db(value: float) -> float:
return linear_to_db(maxf(value, 0.0001)) if value > 0.0 else -80.0
func _apply_3d_volume() -> void:
for id in _active_3d:
var pl: AudioStreamPlayer3D = _active_3d[id]
if is_instance_valid(pl):
pl.volume_db = _volume_db(master_sfx)
func set_sound_volume_3d(id: int, value: float) -> void:
var pl: AudioStreamPlayer3D = _active_3d.get(id, null)
if pl and is_instance_valid(pl):
pl.volume_db = _volume_db(clampf(value, 0.0, 1.0))
func stop_sound_3d(id: int) -> void:
var pl: AudioStreamPlayer3D = _active_3d.get(id, null)
_active_3d.erase(id)
if pl and is_instance_valid(pl):
pl.stop()
pl.queue_free()
func stop_all_sound_3d() -> void:
for id in _active_3d.keys().duplicate():
stop_sound_3d(int(id))
_character_history.clear()
_ambience_states.clear()
func fade_limit_out_music(name: String, limit_volume: float, speed := FADE_SPEED) -> void:
if name != _bgm_name:
return
var pl: AudioStreamPlayer = _bgm[_bgm_cur]
if pl == null or not pl.playing:
return
var limit_db := _volume_db(ratio_to_apply_volume(limit_volume))
var duration := maxf(absf(pl.volume_db - limit_db) / maxf(speed * 60.0, 0.001), 0.01)
_fade(pl, limit_db, duration)
func fade_out_all_music(speed := 0.01) -> void:
var duration := maxf(1.0 / maxf(speed * 60.0, 0.001), 0.01)
_fade(_bgm[0], -80.0, duration, true)
_fade(_bgm[1], -80.0, duration, true)
+88
View File
@@ -0,0 +1,88 @@
# audio_miles_test —— MilesLib SoundManager 对齐回归。
# 验证 40250 的缩放、音量曲线和角色音效限频/距离规则。
extends SceneTree
const Audio = preload("res://audio.gd")
var _fail := 0
func _ck(condition: bool, message: String) -> void:
if not condition:
_fail += 1
printerr("FAIL: " + message)
func _init() -> void:
var audio := Audio.new()
get_root().add_child(audio)
_ck(is_equal_approx(Audio.SOUND_SCALE, 200.0), "sound scale is 200")
_ck(is_equal_approx(Audio.AMBIENCE_SOUND_SCALE, 1000.0), "ambience scale is 1000")
_ck(is_equal_approx(Audio.ratio_to_apply_volume(0.05), 0.05), "low ratio stays linear")
_ck(is_equal_approx(Audio.ratio_to_apply_volume(0.5), pow(10.0, -0.5)),
"ratio volume uses ClientVS22 logarithmic curve")
_ck(is_equal_approx(Audio.grade_to_apply_volume(5), 1.0), "grade 5 maps to full volume")
_ck(is_equal_approx(Audio.grade_to_apply_volume(0), 0.0), "grade 0 maps to silence")
audio.set_sound_scale(320.0)
audio.set_ambience_sound_scale(1200.0)
_ck(is_equal_approx(audio.get_sound_scale(), 320.0), "sound scale setter")
_ck(is_equal_approx(audio.get_ambience_sound_scale(), 1200.0), "ambience scale setter")
audio.set_listener(Vector3.ZERO, Vector3(0, 0, -1), Vector3.UP)
var assets := AssetRoot.path()
if DirAccess.dir_exists_absolute(assets):
audio.setup(assets)
var mss := assets.path_join("sound_m/sound/pc/warrior/general/run.mss")
if FileAccess.file_exists(mss):
var data: Array = audio.load_sound_information_piece(mss)
_ck(data.size() == 3, ".mss SoundDataCount and records parse")
_ck(is_equal_approx(float(data[0]["time"]), 0.759), ".mss preserves event time")
_ck(String(data[0]["sound"]) == "sound/common/walk_dirt_n.wav",
".mss preserves virtual sound path")
var instances: Array = audio.data_to_sound_instances(data)
_ck(int(instances[0]["frame"]) == 45 and int(instances[2]["frame"]) == 11,
".mss time converts to DWORD 60 FPS frames")
_ck(audio._find_sound(String(data[0]["sound"])) != "",
"virtual sound/ prefix resolves inside mounted sound root")
var motion := assets.path_join("PC/ymir work/pc/warrior/general/run.msa")
var motion_instances: Array = audio.load_mss_for_motion(motion)
_ck(motion_instances.size() == 3, "motion .msa resolves paired .mss")
var ambience := [{
"key": "waterfall-test",
"position": Vector3(20, 0, 0),
"range_cm": 3300,
"max_volume_area_percentage": 0.5,
"play_type": "LOOP",
"sounds": ["sound/ambience/warp_test.mp3"],
}]
audio.update_ambience_sources(ambience)
var ambience_state: Dictionary = audio._ambience_states.get("waterfall-test", {})
var ambience_id := int(ambience_state.get("id", -1))
_ck(ambience_id >= 0 and audio._active_3d.has(ambience_id),
"area ambience LOOP creates a Miles 3D instance")
if ambience_id >= 0 and audio._active_3d.has(ambience_id):
var ambience_player: AudioStreamPlayer3D = audio._active_3d[ambience_id]
_ck(abs(ambience_player.position.x - (20.0 * 100.0 / 1200.0)) < 0.001,
"ambience source uses cm -> ambience scale conversion")
audio.update_ambience_sources([])
_ck(not audio._ambience_states.has("waterfall-test") and not audio._active_3d.has(ambience_id),
"leaving / unloading area ambience stops its loop")
_ck(audio.can_play_character_sound("step.wav", Vector3(1, 0, 0), 10.0),
"near character sound is allowed")
_ck(not audio.can_play_character_sound("step.wav", Vector3(1, 0, 0), 10.2),
"same character sound is suppressed within 0.3 seconds")
_ck(audio.can_play_character_sound("step.wav", Vector3(1, 0, 0), 10.31),
"same character sound resumes after 0.3 seconds")
_ck(not audio.can_play_character_sound("far.wav", Vector3(51, 0, 0), 20.0),
"character sound beyond 5000cm is suppressed")
audio.set_sound_volume(0.0)
_ck(is_equal_approx(audio.master_sfx, 0.0), "sound volume setter accepts silence")
audio.stop_all_sound_3d()
audio.free()
if _fail == 0:
print("PASS: audio_miles_test (scale + volume curve + character sound guard)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
+7 -2
View File
@@ -54,8 +54,13 @@ func _init() -> void:
_client.connect("stage_changed", func(s): _set_status("stage: " + s))
_client.connect("login_failed", func(r): _set_status("login failed: " + r))
_client.connect("entered_game", func(): _set_status("entered game"))
var host := "192.168.21.203"
_client.call("connect_to_server", host, 11000, host, 13002, "admin", "123456789")
var host := OS.get_environment("MT_SERVER_HOST")
var account := OS.get_environment("MT_ACCOUNT")
var password := OS.get_environment("MT_PASSWORD")
if host.is_empty() or account.is_empty() or password.is_empty():
_set_status("请设置 MT_SERVER_HOST / MT_ACCOUNT / MT_PASSWORD,或使用 --mock")
return
_client.call("connect_to_server", host, 11000, host, 13002, account, password)
_set_status("connecting to " + host + " ...")
else:
_set_status("M2Client 未注册 —— 用 --mock")
+109
View File
@@ -0,0 +1,109 @@
# chat_tail —— §8.8 头顶「聊天 / 信息」尾标 1:1CPythonTextTail::RegisterChatTail /
# RegisterInfoTail 的纯静态镜像(与 name_color.gd / text_tail.gd 同套路)。
#
# REF 40250/ClientVS22/source/UserInterface/PythonTextTail.cpp
# :16 c_TextTail_Chat_Color = D3DXCOLOR(1,1,1,1) —— 普通聊天气泡=白
# :17 c_TextTail_Info_Color = D3DXCOLOR(1, 0.785f, 0.785f, 1) —— 信息气泡=淡红
# :26 gs_TextTail_LivingTime = 5000 —— TEXTTAIL_LIVINGTIME_CONTROL
# :28 TextTail_GetLivingTime() assert(gs_TextTail_LivingTime > 1000)
# :34 TextTail_SetLivingTime(livingTime)
# :581 RegisterChatTail(vid, szChat)m_ChatTailMap 已有该 vid → 改文本 + Color=Chat +
# LivingTime = now + GetLivingTime() + bNameFlag=TRUE,直接 return
# 否则新建(高度 = 角色高 + 10),LivingTime = now + GetLivingTime()bNameFlag=TRUE
# SetOutline(true)VERTICAL_ALIGN_BOTTOM,插入 m_ChatTailMap
# :624 RegisterInfoTail(vid, szChat):同上,但 Color=Info、bNameFlag=FALSE
# :328 UpdateAllTextTail:遍历 m_ChatTailMapLivingTime < now 即 DeleteTextTail + erase
# (每 vid 只有一条 chat/info 尾标,chat 与 info 共用同一张 m_ChatTailMap
# :74 UpdateAllTextTailchat 尾标若 bNameFlag → ShowCharacterTextTail(vid)(名字也一起顶出来)
# :105 UpdateShowingTextTailchat 尾标若 bNameFlag → 名字尾标 y = chatTail.y - 17(名字挪到气泡下方)
# :684 IsChatTextTail(vid)m_ChatTailMap.find(vid) != end
#
# REF 40250/ClientVS22/source/UserInterface/PythonNetworkStreamPhaseGame.cpp
# :1301 CHAT_TYPE_COMMAND → ServerCommand,提前 return(不进尾标)
# :1321 TALKING/PARTY/GUILD/SHOUT/WHISPERline = strchr(buf,':') ? p + 2 : buf(去掉 "名字: " 前缀)
# :1363 INFO/NOTICE/BIG_NOTICE/defaultline = buf(原样)
# :1372 if (CHAT_TYPE_SHOUT != kChat.type) RegisterChatTail(kChat.dwVID, line);
# → 带 VID 的聊天里,除 SHOUT / COMMAND 外都进「聊天尾标」;RegisterInfoTail 仅
# game.py:930 / :936 技能 / 射击错误尾标显式调。
#
# seam ⑨:参考端聊天尾标由 Render() 与名字尾标同屏排布(气泡在上、名字下移 17px,bNameFlag 决定
# 名字是否强制显示);本移植是 net_world 里名字 Label3D 与 "bubble" Label3D 各自 billboard 并存,
# 名字不强制顶出、不随气泡下移——差异记入 §8.8。
extends RefCounted
const CHAT_COLOR := Color(1.0, 1.0, 1.0)
const INFO_COLOR := Color(1.0, 0.785, 0.785)
const LIVING_TIME_DEFAULT := 5000 # msgs_TextTail_LivingTime
const LIVING_TIME_MIN := 1000 # assert 下界(TextTail_GetLivingTime
const HEIGHT_ADD := 10.0 # 角色高 + 10RegisterChatTail 新建分支)
# CHAT_TYPE_*Packet.h:1410 enum EChatType
const CT_TALKING := 0
const CT_INFO := 1
const CT_NOTICE := 2
const CT_PARTY := 3
const CT_GUILD := 4
const CT_COMMAND := 5
const CT_SHOUT := 6
const CT_WHISPER := 7
const CT_BIG_NOTICE := 8
static var _living_time := LIVING_TIME_DEFAULT
static func set_living_time(ms: int) -> void:
# TextTail_SetLivingTime —— 参考端不在此断言(断言在 GetLivingTime
_living_time = ms
static func living_time() -> int:
# TextTail_GetLivingTimeassert(gs_TextTail_LivingTime > 1000)
assert(_living_time > LIVING_TIME_MIN)
return _living_time
static func strip_name_prefix(raw: String) -> String:
# strchr(buf, ':') ? p + 2 : buf —— 有冒号则取 ": " 之后正文,否则整串原样
var i := raw.find(":")
return raw.substr(i + 2) if i != -1 else raw
static func line_for_tail(chat_type: int, raw: String) -> String:
# RecvChatPacket 的 switchTALKING/PARTY/GUILD/SHOUT/WHISPER 去前缀;其余原样
match chat_type:
CT_TALKING, CT_PARTY, CT_GUILD, CT_SHOUT, CT_WHISPER:
return strip_name_prefix(raw)
_:
return raw
static func wants_chat_tail(chat_type: int) -> bool:
# RecvChatPacketCOMMAND 提前 returnSHOUT 显式排除;其余带 VID 聊天都进尾标
return chat_type != CT_SHOUT and chat_type != CT_COMMAND
static func _register(tails: Dictionary, vid: int, text: String, now_ms: int, col: Color, name_flag: bool) -> Dictionary:
var e := {
"text": text,
"color": col,
"name_flag": name_flag,
"living_time": now_ms + living_time(),
}
tails[vid] = e
return e
static func register_chat(tails: Dictionary, vid: int, text: String, now_ms: int) -> Dictionary:
# CPythonTextTail::RegisterChatTail —— 白 + bNameFlag TRUE,已存在则原地替换
return _register(tails, vid, text, now_ms, CHAT_COLOR, true)
static func register_info(tails: Dictionary, vid: int, text: String, now_ms: int) -> Dictionary:
# CPythonTextTail::RegisterInfoTail —— 淡红 + bNameFlag FALSE,与 chat 共用同一张表
return _register(tails, vid, text, now_ms, INFO_COLOR, false)
static func is_chat_text_tail(tails: Dictionary, vid: int) -> bool:
# CPythonTextTail::IsChatTextTail
return tails.has(vid)
static func expire(tails: Dictionary, now_ms: int) -> Array:
# UpdateAllTextTailLivingTime < now 即删除;返回被清掉的 vid 列表
var dead: Array = []
for vid in tails.keys():
if int(tails[vid].get("living_time", 0)) < now_ms:
dead.append(vid)
for vid in dead:
tails.erase(vid)
return dead
+106
View File
@@ -0,0 +1,106 @@
# chat_tail_test —— §8.8 头顶「聊天 / 信息」尾标 1:1:
# CPythonTextTail::RegisterChatTail / RegisterInfoTail 的白 / 淡红色、bNameFlag、
# gs_TextTail_LivingTime5000assert >1000)、m_ChatTailMap 每 vid 一条 + chat/info 共表、
# UpdateAllTextTail 的 LivingTime < now 删除、IsChatTextTail,以及
# RecvChatPacket 的 "名字: " 前缀剥离 + SHOUT/COMMAND 不进尾标。
# godot --headless --path project --script chat_tail_test.gd
# 退出码 0 = 全过。
extends SceneTree
const ChatTail = preload("res://chat_tail.gd")
var _fail := 0
func _ck(c: bool, m: String) -> void:
if not c:
_fail += 1
printerr("FAIL: " + m)
func _init() -> void:
_run()
if _fail == 0:
print("PASS: chat_tail_test (§8.8 RegisterChatTail / RegisterInfoTail / LivingTime 1:1)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
func _run() -> void:
# --- 0. 常量(PythonTextTail.cpp / Packet.h---------------------------------
_ck(ChatTail.CHAT_COLOR.is_equal_approx(Color(1, 1, 1)), "c_TextTail_Chat_Color == white")
_ck(ChatTail.INFO_COLOR.is_equal_approx(Color(1.0, 0.785, 0.785)), "c_TextTail_Info_Color == (1,.785,.785)")
_ck(ChatTail.LIVING_TIME_DEFAULT == 5000, "gs_TextTail_LivingTime == 5000")
_ck(ChatTail.LIVING_TIME_MIN == 1000, "TextTail_GetLivingTime assert 下界 == 1000")
_ck(ChatTail.HEIGHT_ADD == 10.0, "新建分支高度 = 角色高 + 10")
# EChatType 枚举顺序(Packet.h:1410
_ck(ChatTail.CT_TALKING == 0 and ChatTail.CT_INFO == 1 and ChatTail.CT_NOTICE == 2, "EChatType 0/1/2")
_ck(ChatTail.CT_PARTY == 3 and ChatTail.CT_GUILD == 4 and ChatTail.CT_COMMAND == 5, "EChatType 3/4/5")
_ck(ChatTail.CT_SHOUT == 6 and ChatTail.CT_WHISPER == 7 and ChatTail.CT_BIG_NOTICE == 8, "EChatType 6/7/8")
# --- 1. TextTail_GetLivingTime / SetLivingTime ------------------------------
_ck(ChatTail.living_time() == 5000, "living_time() 默认 5000")
ChatTail.set_living_time(8000)
_ck(ChatTail.living_time() == 8000, "set_living_time(8000) 生效")
ChatTail.set_living_time(ChatTail.LIVING_TIME_DEFAULT) # 还原,避免污染同进程后续测试
_ck(ChatTail.living_time() == 5000, "restore: living_time 回 5000")
# --- 2. strip_name_prefixstrchr(buf,':') ? p+2 : buf --------------------
_ck(ChatTail.strip_name_prefix("Alice: hello world") == "hello world", "去掉 \"Alice: \" 前缀")
_ck(ChatTail.strip_name_prefix("nocolon here") == "nocolon here", "无冒号 -> 原样")
_ck(ChatTail.strip_name_prefix("A: x: y") == "x: y", "只吃第一个冒号")
_ck(ChatTail.strip_name_prefix("Bob: ") == "", "\"Bob: \" -> 空")
# --- 3. line_for_tailswitch 里 TALKING/PARTY/GUILD/SHOUT/WHISPER 去前缀 ----
_ck(ChatTail.line_for_tail(ChatTail.CT_TALKING, "Kane: hi") == "hi", "TALKING 去前缀")
_ck(ChatTail.line_for_tail(ChatTail.CT_PARTY, "Kane: hi") == "hi", "PARTY 去前缀")
_ck(ChatTail.line_for_tail(ChatTail.CT_GUILD, "Kane: hi") == "hi", "GUILD 去前缀")
_ck(ChatTail.line_for_tail(ChatTail.CT_WHISPER, "Kane: hi") == "hi", "WHISPER 去前缀")
_ck(ChatTail.line_for_tail(ChatTail.CT_SHOUT, "Kane: hi") == "hi", "SHOUT 也走去前缀分支")
_ck(ChatTail.line_for_tail(ChatTail.CT_INFO, "You got 10 gold") == "You got 10 gold", "INFO 原样")
_ck(ChatTail.line_for_tail(ChatTail.CT_NOTICE, "server: down soon") == "server: down soon", "NOTICE 原样")
_ck(ChatTail.line_for_tail(ChatTail.CT_BIG_NOTICE, "boss: alive") == "boss: alive", "BIG_NOTICE 原样")
# --- 4. wants_chat_tailRecvChatPacket 除 SHOUT / COMMAND 外都进尾标 --------
_ck(ChatTail.wants_chat_tail(ChatTail.CT_TALKING), "TALKING 进尾标")
_ck(ChatTail.wants_chat_tail(ChatTail.CT_PARTY), "PARTY 进尾标")
_ck(ChatTail.wants_chat_tail(ChatTail.CT_GUILD), "GUILD 进尾标")
_ck(ChatTail.wants_chat_tail(ChatTail.CT_WHISPER), "WHISPER 进尾标")
_ck(ChatTail.wants_chat_tail(ChatTail.CT_INFO), "INFO 进尾标")
_ck(ChatTail.wants_chat_tail(ChatTail.CT_NOTICE), "NOTICE 进尾标")
_ck(ChatTail.wants_chat_tail(ChatTail.CT_BIG_NOTICE), "BIG_NOTICE 进尾标")
_ck(not ChatTail.wants_chat_tail(ChatTail.CT_SHOUT), "SHOUT 不进尾标(:1372 显式排除)")
_ck(not ChatTail.wants_chat_tail(ChatTail.CT_COMMAND), "COMMAND 不进尾标(提前 return")
# --- 5. RegisterChatTail:新建 / 原地替换 / bNameFlag / LivingTime ----------
var tails := {}
var t0 := 100000
var c := ChatTail.register_chat(tails, 42, "hello", t0)
_ck(tails.size() == 1 and tails.has(42), "register_chat -> m_ChatTailMap 里一条 vid 42")
_ck(c["text"] == "hello", "chat tail 文本 == hello")
_ck(c["color"].is_equal_approx(Color(1, 1, 1)), "chat tail 色 == 白")
_ck(c["name_flag"] == true, "chat tail bNameFlag == TRUE")
_ck(int(c["living_time"]) == t0 + 5000, "chat tail LivingTime == now + GetLivingTime()")
# 同 vid 再来一条 —— 原地替换(find != end 分支),仍只有一条
var c2 := ChatTail.register_chat(tails, 42, "hello again", t0 + 1000)
_ck(tails.size() == 1, "同 vid 再 register_chat 不新增键")
_ck(c2["text"] == "hello again" and int(c2["living_time"]) == t0 + 6000, "原地改文本 + 续命")
_ck(ChatTail.is_chat_text_tail(tails, 42), "IsChatTextTail(42) == true")
_ck(not ChatTail.is_chat_text_tail(tails, 99), "IsChatTextTail(99) == false")
# --- 6. RegisterInfoTail:淡红 + bNameFlag FALSE,与 chat 共用 m_ChatTailMap --
var i := ChatTail.register_info(tails, 42, "used skill fail", t0 + 2000)
_ck(tails.size() == 1, "info tail 覆盖同 vid 的 chat tail(共表),仍一条")
_ck(i["color"].is_equal_approx(Color(1.0, 0.785, 0.785)), "info tail 色 == 淡红")
_ck(i["name_flag"] == false, "info tail bNameFlag == FALSE")
_ck(i["text"] == "used skill fail", "info tail 文本替换")
var i2 := ChatTail.register_info(tails, 7, "no arrows", t0)
_ck(tails.size() == 2 and i2["name_flag"] == false, "另一 vid 的 info tail 独立成键")
# --- 7. expireUpdateAllTextTail 的 LivingTime < now 删除 -----------------
# vid 42 LivingTime = t0+2000+5000 = t0+7000vid 7 = t0+5000
var dead := ChatTail.expire(tails, t0 + 6000)
_ck(dead == [7], "expire(now=t0+6000) 清掉 vid 7LivingTime t0+5000")
_ck(tails.size() == 1 and tails.has(42), "vid 42 未到点,保留")
var dead2 := ChatTail.expire(tails, t0 + 7001)
_ck(dead2 == [42], "expire(now=t0+7001) 清掉 vid 42")
_ck(tails.is_empty(), "全部到点后 m_ChatTailMap 空")
_ck(ChatTail.expire(tails, t0 + 99999) == [], "空表 expire 返回 []")
+20 -5
View File
@@ -27,6 +27,12 @@ class FakeClient extends Node:
func say(t, s) -> bool: said.append([t, s]); return true
func whisper(to, s) -> bool: whispered.append([to, s]); return true
class FakeProto extends Node:
func item(vnum: int) -> Dictionary:
if vnum == 30001:
return {"vnum": vnum, "type": 12, "locale_name": "Trout"}
return {}
var _fail := 0
func _ck(c: bool, m: String) -> void:
if not c:
@@ -52,7 +58,10 @@ func _run() -> void:
get_root().add_child(fc)
var chat: Node = ChatUI.new()
get_root().add_child(chat)
chat.setup(fc, canvas)
chat.setup(fc, canvas, AssetRoot.path())
var proto := FakeProto.new()
get_root().add_child(proto)
chat.set_proto(proto)
_ck(chat._log.size() == 4, "4 tabs built")
var tAll: RichTextLabel = chat._log[0]
@@ -81,11 +90,17 @@ func _run() -> void:
fc.item_picked_up.emit(27, 3, "")
_ck(_last(tSys).contains("拾取 #27 x3"), "pickup -> System")
_ck(_last(tBattle).contains("拾取 #27 x3"), "pickup -> Battle")
# 钓鱼服务端事件:成功/鱼获进入系统和战斗日志。
fc.fishing_event.emit(3, 1000, 0)
_ck(_last(tSys).contains("鱼钩命中"), "fishing success -> System")
# 钓鱼服务端事件:SUCCESS 只切动画;FISH 按 IsFishing() + item type
# 走 ClientVS22 的 notify/success locale 分支。
fc.fishing_event.emit(0, 1000, 0)
fc.fishing_event.emit(5, 30001, 0)
_ck(_last(tBattle).contains("捕获物品 #30001"), "fishing item -> Battle")
_ck(_last(tBattle).contains("Trout") and _last(tBattle).contains("hooked"),
"fishing notify fish -> Battle locale")
fc.fishing_event.emit(3, 1000, 0)
fc.fishing_event.emit(5, 30001, 0)
_ck(_last(tSys).contains("You captured Trout!"), "fishing success fish -> System locale")
chat.on_fishing_feedback("FISHING_WRONG_PLACE")
_ck(_last(tSys).contains("You cannot go fishing here."), "wrong place -> locale")
# --- 前缀解析 ---
chat._on_submit("hello world")
+273 -21
View File
@@ -22,17 +22,33 @@ class FakeClient extends Node:
var main := 1000
var ents := {1000: {"vid": 1000, "hp": 100, "dead": false}, 2000: {"vid": 2000, "hp": 50, "dead": false, "pos": Vector3(1, 0, 0)}}
var attacks := []
var moves := []
var synced := []
var said := []
var affects := []
var fly_targets := [] # §3.6CG_FLY_TARGETING
var shots := [] # §3.6CG_SHOOT
func is_in_game() -> bool: return true
func get_main_vid() -> int: return main
func get_entity(vid) -> Dictionary: return ents.get(vid, {})
func get_entities() -> Array: return ents.values()
func get_affects() -> Array: return affects
func attack(motion, vid) -> bool: attacks.append([motion, vid]); return true
func add_fly_targeting(vid, x, y) -> bool: fly_targets.append([vid, x, y]); return true
func shoot(skill) -> bool: shots.append(skill); return true
func set_target(v) -> bool: return true
func move(a, b, c, d, e) -> bool: return true
func move(a, b, c, d, e) -> bool: moves.append([a, b, c, d, e]); return true
func sync_positions(arr) -> bool: synced.append(arr); return true
func say(t, s) -> bool: said.append([t, s]); return true
class FakeNetWorld extends Node:
var nodes := {}
func node_for(vid: int) -> Node3D: return nodes.get(vid, null)
class FakeProto extends Node:
var items := {}
func item(vnum: int) -> Dictionary: return items.get(vnum, {})
class FakePC extends Node:
signal target_selected(node: Node3D)
signal moved(pos: Vector3)
@@ -40,8 +56,13 @@ class FakePC extends Node:
var player: Node3D
var frozen := false
class FakeAnim extends Node:
var md := {}
func get_motion_data() -> Dictionary: return md
class FakeView extends Node3D:
var states := []
var anim := FakeAnim.new()
func set_anim_state(s): states.append(s)
class FakeHud extends Node:
@@ -57,7 +78,7 @@ func _ck(c: bool, m: String) -> void:
func _init() -> void:
await _run()
if _fail == 0:
print("PASS: combat_fx_test (hitstun / combo / shake / affects / death)")
print("PASS: combat_fx_test (hit-gate / combo / shake / affects / death)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
@@ -81,23 +102,36 @@ func _run() -> void:
np.player_view = view
np._main_vid = 1000
# --- 受击硬直 + 抖屏 ---
# --- 受击:播击退动作 + 抖屏;「命中」不靠固定硬直秒数锁输入(§3.7)---
fc.damage.emit(1000, 20, 0) # 命中(非闪避)
_ck(np.is_stunned(), "hit -> stunned")
_ck(np._knock_down, "hit -> knock_down latch set")
_ck(np.is_stunned(), "hit (knock_down) -> cannot act")
_ck(cam._shake > 0.0, "hit -> camera shake queued")
_ck("damage" in view.states, "hit -> view set_anim_state('damage')")
np._process(0.016)
_ck(pc.frozen == true, "stun -> pc.frozen = true (got %s)" % pc.frozen)
_ck(pc.frozen == true, "knock_down -> pc.frozen = true (got %s)" % pc.frozen)
# 闪避不硬直
np._hitstun_until = 0.0
# 击退动作结束 -> 解除(动作时长驱动)
np._knock_down = false
_ck(not np.is_stunned(), "knockback motion ended -> can act")
# 服务端 stun 标志独立驱动闸门
fc.ents[1000]["stunned"] = true
_ck(np.is_stunned(), "server stunned flag -> cannot act")
_ck(not np._can_attack(), "server stunned -> _can_attack() false")
fc.ents[1000]["stunned"] = false
_ck(not np.is_stunned(), "server stun cleared -> can act")
# 闪避不播击退
np._knock_down = false
fc.damage.emit(1000, 0, 1 << 2) # DODGE
_ck(not np.is_stunned(), "dodge -> not stunned")
_ck(not np._knock_down, "dodge -> no knock_down")
_ck(not np.is_stunned(), "dodge -> can act")
# 别人受击不影响自己
np._hitstun_until = 0.0
np._knock_down = false
fc.damage.emit(2000, 30, 0)
_ck(not np.is_stunned(), "other's damage -> not stunned")
_ck(not np._knock_down, "other's damage -> no knock_down")
# Fishing motion subheaders drive the local player view; FISH item outcome is ignored.
np.player_view = view
@@ -110,19 +144,237 @@ func _run() -> void:
_ck(view.states.size() == before, "fishing fish item -> no local motion")
# --- combo:连续攻击 motion 递增 ---
np._hitstun_until = 0.0
np._knock_down = false
np._target_vid = 2000
np.attack_period = 0.0
np.ATTACK_RANGE # (const, no-op)
# 让目标在攻击距离内
# net_world 为 null -> _process 里 node_for 走不到;直接测 combo 计数逻辑:
np._last_attack_t = np._now()
np._combo = 0
# 模拟三次「窗口内攻击」
for i in 3:
np._combo = (np._combo + 1) % 3 if (np._now() - np._last_attack_t) < np.COMBO_WINDOW else 0
np._last_attack_t = np._now()
_ck(np._combo == 0, "combo cycles 1->2->0 over 3 hits, got %d" % np._combo)
# §3.3:普攻节奏来自动作数据 / 攻速系数,不再是硬编码常量
_ck(is_equal_approx(np._current_attack_period(), np.DEFAULT_ATTACK_PERIOD),
"no motion data -> attack period falls back to DEFAULT_ATTACK_PERIOD")
np._apply_attack_speed(150)
_ck(is_equal_approx(np._atk_speed_factor, 1.5), "attack_speed 150 -> factor 1.5")
_ck(is_equal_approx(np._current_attack_period(), np.DEFAULT_ATTACK_PERIOD / 1.5),
"attack speed scales the resolved period")
np._apply_attack_speed(100)
# --- §3.5 连击状态机:InputComboAttackCommand / __RunNextCombo / ComboProcess ---
# 合成一张 4 段的 1H type1 段表(段号 14..17),并给 player_view 一个可控 .msa。
var K: int = (np.MOTION_MODE_GENERAL << 16) | 0
np._combo_tables = {0: {K: PackedInt32Array([14, 15, 16, 17])}}
np.combo_class = 0
np.combo_motion_mode = np.MOTION_MODE_GENERAL
view.anim.md = {
"has_combo_input": true, "duration": 1.0,
"pre_input_time": 0.10, "direct_input_time": 0.20, "input_limit_time": 0.40,
"next_combo": 0.20,
}
var pcn := Node3D.new(); get_root().add_child(pcn)
np.pc.player = pcn
np._vid_reserved = 2000
fc.attacks.clear()
await process_frame # SceneTree 脚本:等一帧让 add_child 的节点真正入树(global_position 才有效)
# 第一次输入:m_dwcurComboIndex 0 -> __RunNextCombo -> 段 1(段号 14
np._do_attack_swing(pcn, {})
_ck(np._combo_index == 1, "first input -> combo index 1, got %d" % np._combo_index)
_ck(fc.attacks.size() == 1 and fc.attacks[0][0] == 0, "CG_ATTACK bType is skill 0, not combo seg")
_ck(fc.moves.size() >= 1 and fc.moves.back()[0] == np.FUNC_COMBO and fc.moves.back()[1] == 14,
"FUNC_COMBO carries seg no. 14 (NAME_COMBO_ATTACK_1)")
# 立刻再输入(elapsed ~0 < pre_input_time):既不推进也不置 pre-input
np._do_attack_swing(pcn, {})
_ck(np._combo_index == 1 and not np._is_pre_input, "input before InputStartTime -> ignored")
# elapsed 落在 [start, next):置 m_isPreInput,不推进
np._combo_started_t = np._now() - 0.15
np._do_attack_swing(pcn, {})
_ck(np._combo_index == 1 and np._is_pre_input, "input in [start,next) -> pre-input latched")
# ComboProcesselapsed 过了 NextComboTime -> 触发挂起的 pre-input -> 段 2
np._combo_started_t = np._now() - 0.25
np._combo_process()
_ck(np._combo_index == 2 and not np._is_pre_input, "ComboProcess fires pre-input -> combo index 2")
# elapsed 过 NextComboTime 直接输入 -> 立即推进(段 3、段 4)
np._combo_started_t = np._now() - 0.30
np._do_attack_swing(pcn, {})
_ck(np._combo_index == 3, "input past NextComboTime -> advance to 3")
np._combo_started_t = np._now() - 0.30
np._do_attack_swing(pcn, {})
_ck(np._combo_index == 4, "advance to last seg 4")
# 段 4 是最后一段:__OnEndCombo(非骑乘不复位),再输入越界不推进
np._combo_started_t = np._now() - 0.30
np._do_attack_swing(pcn, {})
_ck(np._combo_index == 4, "past last seg -> no further advance (non-horse)")
# 动作回到 Waitelapsed > duration-> __ClearCombo
np._combo_started_t = np._now() - 1.5
np._combo_process()
_ck(np._combo_index == 0 and not np._is_pre_input, "motion back to Wait -> __ClearCombo")
# 没有段表(缺资源)-> 退化为单段普攻(段号 13)
np._combo_tables = {}
fc.moves.clear()
np._do_attack_swing(pcn, {})
_ck(np._combo_index == 0 and fc.moves.back()[0] == np.FUNC_COMBO \
and fc.moves.back()[1] == np.NAME_NORMAL_ATTACK,
"no combo table -> single NORMAL_ATTACK swing")
# --- §3.5 修改 4:命中窗几何判定 -> OnHit + FlushVictimList ---
# SceneTree 脚本里 add_child 到 root 的节点要等一帧才真正 is_inside_tree()
# 几何判定读 global_position,所以这一小节先 await 一帧。
var nw := FakeNetWorld.new(); get_root().add_child(nw)
np.net_world = nw
var vnode := Node3D.new(); get_root().add_child(vnode)
nw.nodes[2000] = vnode
fc.ents[2000] = {"vid": 2000, "ch_type": 2, "hp": 50, "dead": false, "knock_down": true}
fc.ents[1000]["dead"] = false
await process_frame
vnode.global_position = Vector3(0, 0, 1.0) # 攻击者正前方 1 myaw 0 -> 前向 +Z
pcn.global_position = Vector3.ZERO
pcn.rotation.y = 0.0
view.anim.md = {
"hit_windows": [{
"start_time": 0.0, "end_time": 0.5, "bone": "equip_right_hand",
"weapon_length": 120.0, "samples": [],
}],
"motion_type": np.MOTION_TYPE_COMBO, "hit_limit_count": 0, "invisible_time": 0.1,
"next_combo": 0.2,
}
np._combo_tables = {}
fc.attacks.clear()
fc.synced.clear()
np._do_attack_swing(pcn, {}) # _emit_swing:有命中窗 -> CG_ATTACK 不在挥击时发
_ck(fc.attacks.size() == 0, "hit windows present -> CG_ATTACK deferred off the swing")
_ck(np._hit_windows.size() == 1, "swing cached the .msa hit window")
np._swing_start_t = np._now() - 0.1 # 推进到命中窗内
np._process(0.016)
_ck(fc.attacks.size() == 1 and fc.attacks[0] == [0, 2000],
"in-window + in front arc -> OnHit CG_ATTACK(skill 0, vid 2000)")
_ck(fc.synced.size() == 1 and fc.synced[0][0]["vid"] == 2000,
"pushed victim -> frame-end CG_SYNC_POSITION")
np._swing_start_t = np._now() - 0.2
np._process(0.016)
_ck(fc.attacks.size() == 1, "COMBO motion_type -> same window/victim hits once only")
vnode.global_position = Vector3(0, 0, -1.0) # 转到身后
np._hit_dedup.clear()
np._swing_start_t = np._now() - 0.2
np._process(0.016)
_ck(fc.attacks.size() == 1, "victim behind attacker -> outside front arc -> no hit")
vnode.global_position = Vector3(0, 0, 1.0)
np._swing_start_t = np._now() - 2.0 # 动作越过所有命中窗
np._process(0.016)
_ck(np._hit_windows.is_empty(), "motion past all windows -> hit windows cleared")
np.net_world = null
# --- §3.5 修改 6:武器种类 -> combo_motion_modeRefreshState 的 SetMotionMode 分支)---
# 纯映射表:CItemData 类型/子类型 + 骑乘/变身 -> CRaceMotionData::EMode
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_SWORD, false, false, false) \
== np.MOTION_MODE_ONEHAND_SWORD, "sword -> ONEHAND_SWORD")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_TWO_HANDED, false, false, false) \
== np.MOTION_MODE_TWOHAND_SWORD, "two-handed -> TWOHAND_SWORD")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_DAGGER, false, false, false) \
== np.MOTION_MODE_DUALHAND_SWORD, "dagger -> DUALHAND_SWORD")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_BOW, false, false, false) \
== np.MOTION_MODE_BOW, "bow -> BOW")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_FAN, false, false, false) \
== np.MOTION_MODE_FAN, "fan -> FAN")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_BELL, false, false, false) \
== np.MOTION_MODE_BELL, "bell -> BELL")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_ARROW, false, false, false) \
== np.MOTION_MODE_GENERAL, "arrow / bare hand -> GENERAL")
_ck(np.motion_mode_for(np.ITEM_TYPE_ROD, 0, false, false, false) \
== np.MOTION_MODE_FISHING, "fishing rod -> FISHING")
_ck(np.motion_mode_for(np.ITEM_TYPE_ROD, 0, true, false, false) \
== np.MOTION_MODE_HORSE, "rod while mounted -> HORSE")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_SWORD, true, false, false) \
== np.MOTION_MODE_HORSE_ONEHAND_SWORD, "mounted sword -> HORSE_ONEHAND_SWORD")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_BOW, true, false, false) \
== np.MOTION_MODE_HORSE_BOW, "mounted bow -> HORSE_BOW")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_ARROW, true, false, false) \
== np.MOTION_MODE_HORSE, "mounted arrow -> HORSE")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_SWORD, false, true, false) \
== np.MOTION_MODE_GENERAL, "poly overrides weapon -> GENERAL")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_SWORD, true, false, true) \
== np.MOTION_MODE_WEDDING_DRESS, "wedding dress overrides all -> WEDDING_DRESS")
# 端到端:proto 桩 + parts[WEAPON] -> _refresh_motion_mode() -> combo_motion_mode 变,且清连击
var fp := FakeProto.new()
fp.items[19] = {"type": np.ITEM_TYPE_WEAPON, "sub_type": np.WEAPON_SUB_DAGGER}
np.proto = fp
np._main_vid = 1000
np.combo_motion_mode = np.MOTION_MODE_GENERAL
np._combo_index = 3
fc.ents[1000]["parts"] = [0, 19, 0, 0]
np._refresh_motion_mode()
_ck(np.combo_motion_mode == np.MOTION_MODE_DUALHAND_SWORD, "parts[WEAPON]=dagger -> DUALHAND_SWORD")
_ck(np._combo_index == 0, "motion mode change clears the running combo")
fc.ents[1000]["mount_vnum"] = 20101
np._refresh_motion_mode()
_ck(np.combo_motion_mode == np.MOTION_MODE_HORSE_DUALHAND_SWORD, "mount up -> HORSE_DUALHAND_SWORD")
fc.ents[1000].erase("parts")
fc.ents[1000].erase("mount_vnum")
np.proto = null
# --- §3.5 增量 85:弓箭 __GetBowRange() → _clickable_distance ---
np._bow_distance_bonus = 0.0
np.combo_motion_mode = np.MOTION_MODE_GENERAL
_ck(is_equal_approx(np._clickable_distance({"ch_type": 0}), np.CLICK_DIST_DEFAULT_CM / np.CM),
"non-bow: 玩家 / 怪目标 reach = 150cm")
np.combo_motion_mode = np.MOTION_MODE_BOW
_ck(is_equal_approx(np._bow_range_cm(), 2500.0 - 100.0), "__GetBowRange 基础 = 2500-100")
_ck(is_equal_approx(np._clickable_distance({"ch_type": 0}), (2500.0 - 100.0) / np.CM),
"bow mode: 非 NPC / 非采集物目标 reach = __GetBowRange()")
_ck(is_equal_approx(np._clickable_distance({"ch_type": 1}), np.CLICK_DIST_NPC_CM / np.CM),
"NPC 覆盖弓箭距离 → 500cm")
_ck(is_equal_approx(np._clickable_distance({"ch_type": 3}), np.CLICK_DIST_RESOURCE_CM / np.CM),
"采集物覆盖弓箭距离 → 100cm")
np.combo_motion_mode = np.MOTION_MODE_HORSE_BOW
_ck(np._is_bow_mode(), "HORSE_BOW 也算 bow mode")
np._bow_distance_bonus = 300.0
_ck(is_equal_approx(np._clickable_distance({"ch_type": 0}), (2500.0 - 100.0 + 300.0) / np.CM),
"POINT_BOW_DISTANCE 加成叠加进 __GetBowRange()")
np._on_points({"attack_speed": 0, "bow_distance": 0})
_ck(is_equal_approx(np._bow_distance_bonus, 0.0), "_on_points 读取 bow_distance → _bow_distance_bonus")
np.combo_motion_mode = np.MOTION_MODE_GENERAL
# --- §3.6 增量 86:弓 FLY 事件(OnSetFlyTarget / OnShoot---
var nw2 := FakeNetWorld.new(); get_root().add_child(nw2)
var tnode := Node3D.new(); get_root().add_child(tnode)
await process_frame
tnode.global_position = Vector3(3.0, 0.0, 4.0)
nw2.nodes[2000] = tnode
np.net_world = nw2
np._target_vid = 2000
np._swing_skill = 0
np._combo_tables = {}
view.anim.md = {}
fc.fly_targets.clear()
fc.attacks.clear()
var got_shot := []
np.bow_shot_fired.connect(func(sk: int): got_shot.append(sk))
# 非弓模式:起手不发 CG_FLY_TARGETING,也不冒 bow_shot_fired
np.combo_motion_mode = np.MOTION_MODE_GENERAL
np._emit_swing(np.NAME_NORMAL_ATTACK, false)
_ck(fc.fly_targets.is_empty() and got_shot.is_empty(), "非弓模式挥击 → 无 FLY 事件")
# 弓模式:起手即发 CG_FLY_TARGETING(目标VID, 目标平面坐标) + 冒 bow_shot_fired(uSkill)
np.combo_motion_mode = np.MOTION_MODE_BOW
np._emit_swing(np.NAME_NORMAL_ATTACK, false)
_ck(fc.fly_targets.size() == 1 and fc.fly_targets[0][0] == 2000,
"弓挥击起手 → OnSetFlyTarget 发 CG_FLY_TARGETING(vid 2000)")
var exp_xy := MapCoord.to_server_cm(Vector3(3.0, 0.0, 4.0))
_ck(fc.fly_targets[0][1] == int(exp_xy.x) and fc.fly_targets[0][2] == int(exp_xy.y),
"CG_FLY_TARGETING 坐标 = 目标节点 server-XYOnGetFlyTargetPosition 平面分量)")
_ck(got_shot == [0], "弓挥击 → bow_shot_fired(uSkill=0)OnShoot 延后到 .msa FLY 帧)")
# 无目标:不发
np._target_vid = 0
np._vid_reserved = 0
fc.fly_targets.clear()
np._send_fly_target()
_ck(fc.fly_targets.is_empty(), "无目标 → 不发 CG_FLY_TARGETING")
np.net_world = null
np.combo_motion_mode = np.MOTION_MODE_GENERAL
np._swing_skill = 0
# --- 状态图标条 ---
fc.affects = [{"type": 7001, "point_idx": 19, "value": 25, "duration": 300}]
+150
View File
@@ -0,0 +1,150 @@
# complex_item_drop_test —— 仓库 / 商城 / 交易 / NPC 商店的物品态回归。
# 验证增量 54 的全局 MouseController 可以把物品送到各窗口的明确目标槽,
# 而不是只验证背包和快捷栏。
extends SceneTree
const MouseController = preload("res://ui/mouse_controller.gd")
const CursorManager = preload("res://ui/cursor_manager.gd")
const SafeboxUI = preload("res://ui/safebox_ui.gd")
const MallUI = preload("res://ui/mall_ui.gd")
const ShopUI = preload("res://ui/shop_ui.gd")
const ExchangeUI = preload("res://ui/exchange_ui.gd")
class FakeClient extends Node:
signal safebox_changed()
signal mall_opened(size: int)
signal mall_changed()
signal shop_opened(vid: int)
signal shop_closed()
signal shop_error(kind: String)
signal exchange_changed()
var calls: Array = []
var safe_open := true
var safe_size := 1
var safe_items: Array = []
var mall_open := true
var mall_size := 1
var mall_items: Array = []
var shop_open := true
var exchange := {"active": true, "self_items": [], "peer_items": []}
func is_safebox_open() -> bool: return safe_open
func get_safebox_size() -> int: return safe_size
func get_safebox_gold() -> int: return 0
func get_safebox_items() -> Array: return safe_items
func safebox_checkin(dst, window, cell) -> bool:
calls.append(["safe_checkin", dst, window, cell]); return true
func safebox_move(src, dst, count) -> bool:
calls.append(["safe_move", src, dst, count]); return true
func safebox_checkout(src, window, cell) -> bool:
calls.append(["safe_checkout", src, window, cell]); return true
func is_mall_open() -> bool: return mall_open
func get_mall_size() -> int: return mall_size
func get_mall_items() -> Array: return mall_items
func mall_checkout(src, window, cell) -> bool:
calls.append(["mall_checkout", src, window, cell]); return true
func is_shop_open() -> bool: return shop_open
func get_shop_items() -> Array: return []
func get_shop() -> Dictionary: return {"tabs": [{"name": "", "items": []}]}
func shop_sell(cell, count) -> bool:
calls.append(["shop_sell", cell, count]); return true
func get_exchange() -> Dictionary: return exchange
func exchange_add_item(window, cell, slot) -> bool:
calls.append(["exchange_add_item", window, cell, slot]); return true
var _fail := 0
func _ck(condition: bool, message: String) -> void:
if not condition:
_fail += 1
printerr("FAIL: " + message)
func _has_call(client: FakeClient, name: String, predicate: Callable) -> bool:
for call in client.calls:
if call[0] == name and predicate.call(call):
return true
return false
func _init() -> void:
var host := Control.new()
get_root().add_child(host)
var cursor := CursorManager.new()
get_root().add_child(cursor)
var mouse := MouseController.new()
get_root().add_child(mouse)
mouse.setup(host, cursor)
var client := FakeClient.new()
get_root().add_child(client)
var safe := SafeboxUI.new()
safe.item_mouse = mouse
get_root().add_child(safe)
safe.setup(client, host)
client.safe_items = [{"cell": 2, "vnum": 19, "count": 3}]
safe.refresh()
_ck(mouse._targets.size() >= 45, "safebox registers all 45 visible drop slots")
_ck(safe._drop_to_slot({"window": 1, "cell": 7, "vnum": 19, "count": 1}, 4),
"inventory item drops into empty safebox slot")
_ck(_has_call(client, "safe_checkin", func(c): return c[1] == 4 and c[2] == 1 and c[3] == 7),
"safebox drop uses explicit destination slot")
var safe_item := safe._cells[2] as Button
var safe_event := InputEventMouseButton.new()
safe_event.button_index = MOUSE_BUTTON_LEFT
safe_event.pressed = true
safe._on_grid_input(2, safe_event)
_ck(mouse.is_attached() and int(mouse.attached().get("window", -1)) == 3,
"safebox item enters global item state")
mouse.cancel()
var mall := MallUI.new()
mall.item_mouse = mouse
get_root().add_child(mall)
mall.setup(client, host)
client.mall_items = [{"cell": 5, "vnum": 27, "count": 2}]
mall.refresh()
var mall_name := mall._list.get_child(0).get_child(0) as Label
var mall_event := InputEventMouseButton.new()
mall_event.button_index = MOUSE_BUTTON_LEFT
mall_event.pressed = true
mall_name.gui_input.emit(mall_event)
_ck(mouse.is_attached() and int(mouse.attached().get("window", -1)) == 4
and int(mouse.attached().get("cell", -1)) == 5,
"mall item enters global item state")
mouse.cancel()
var exchange := ExchangeUI.new()
exchange.item_mouse = mouse
get_root().add_child(exchange)
exchange.setup(client, host)
_ck(exchange._drop_to_slot({"window": 1, "cell": 8, "vnum": 29, "count": 1}, 6),
"inventory item drops into empty exchange slot")
_ck(_has_call(client, "exchange_add_item", func(c): return c[1] == 1 and c[2] == 8 and c[3] == 6),
"exchange drop uses explicit display slot")
var shop := ShopUI.new()
shop.item_mouse = mouse
get_root().add_child(shop)
shop.setup(client, host)
shop.open()
_ck(shop._mode == 1 and not shop._sell_drop_target.visible,
"shop opens in buy mode with sell target hidden")
_ck(not shop._drop_to_sell({"window": 1, "cell": 9, "vnum": 31, "count": 4}),
"buy mode rejects item drop to sell target")
shop._set_mode(2)
_ck(shop._mode == 2 and shop._sell_drop_target.visible,
"sell mode shows sell target")
_ck(shop._drop_to_sell({"window": 1, "cell": 9, "vnum": 31, "count": 4}),
"inventory item drops into shop sell target")
_ck(_has_call(client, "shop_sell", func(c): return c[1] == 9 and c[2] == 1),
"shop drop uses selected sell quantity")
if _fail == 0:
print("PASS: complex_item_drop_test (safebox + mall + exchange + shop)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
+86
View File
@@ -0,0 +1,86 @@
# DungeonState —— GC_DUNGEON 目的地状态(40250/ClientVS22)。
#
# 参考端 PythonNetworkStreamPhaseGame.cpp:3875-3901 收到目的地后调用
# CPythonPlayer::SetDungeonDestinationPositionPythonPlayer.cpp:147-167、
# :1255-1285 保存全局像素/厘米坐标、立即提示,并在接近目标后清除,未到达时
# 每 20 秒再次提示。本工程把目标状态与 UI 解耦,UI 只消费信号。
extends Node
signal destination_changed(active: bool, world_pos: Vector3)
signal destination_reached(world_pos: Vector3)
signal alarm_requested(world_pos: Vector3)
const TIME_ATTACK_START := 0
const DESTINATION_POSITION := 1
const ARRIVAL_THRESHOLD_CM := 10000.0
const ALARM_INTERVAL_SEC := 20.0
var client: Node
var player_getter: Callable
var active := false
var destination_cm := Vector2.ZERO
var destination_world := Vector3.ZERO
var last_subheader := -1
var alarm_count := 0
var _last_alarm_ms := -1
func setup(m2client: Node, get_player: Callable) -> void:
client = m2client
player_getter = get_player
if client and client.has_signal("dungeon_event"):
client.dungeon_event.connect(_on_dungeon_event)
if client and client.has_signal("world_reset"):
client.world_reset.connect(clear)
func _on_dungeon_event(subheader: int, x: int, y: int, has_destination: bool) -> void:
last_subheader = subheader
# ClientVS22's TIME_ATTACK_START branch is intentionally empty. Consume the
# packet but do not invent a local timer that the reference client does not run.
if subheader != DESTINATION_POSITION or not has_destination:
return
destination_cm = Vector2(float(x), float(y))
# GC_DUNGEON carries TPixelPosition x/y in global server centimetres. The
# network frame uses -Y for Godot Z before MapCoord applies the map base.
destination_world = MapCoord.to_world(Vector3(float(x) * 0.01, 0.0, -float(y) * 0.01))
active = true
_last_alarm_ms = -1
destination_changed.emit(true, destination_world)
_alarm()
func clear() -> void:
var was_active := active
active = false
destination_cm = Vector2.ZERO
destination_world = Vector3.ZERO
_last_alarm_ms = -1
if was_active:
destination_changed.emit(false, Vector3.ZERO)
func _player() -> Node3D:
if not player_getter.is_valid():
return null
var p: Variant = player_getter.call()
return p as Node3D
func _alarm() -> void:
_last_alarm_ms = Time.get_ticks_msec()
alarm_count += 1
alarm_requested.emit(destination_world)
func _process(_dt: float) -> void:
if not active:
return
var p := _player()
if p:
# Match PythonPlayer.cpp:155: integer Manhattan distance, not Euclidean.
var p_cm := MapCoord.to_server_cm(p.global_position)
var distance_cm := absf(p_cm.x - destination_cm.x) + absf(p_cm.y - destination_cm.y)
if distance_cm < ARRIVAL_THRESHOLD_CM:
var reached := destination_world
active = false
destination_changed.emit(false, reached)
destination_reached.emit(reached)
return
var now := Time.get_ticks_msec()
if _last_alarm_ms < 0 or now - _last_alarm_ms > int(ALARM_INTERVAL_SEC * 1000.0):
_alarm()
+71
View File
@@ -0,0 +1,71 @@
# dungeon_state_test —— GC_DUNGEON 目的地状态回归。
# godot --headless --path project --script dungeon_state_test.gd
extends SceneTree
const DungeonState = preload("res://dungeon_state.gd")
class FakeClient extends Node:
signal dungeon_event(subheader: int, x: int, y: int, has_destination: bool)
signal world_reset
var _fail := 0
func _ck(condition: bool, message: String) -> void:
if not condition:
_fail += 1
printerr("FAIL: " + message)
func _init() -> void:
await _run()
if _fail == 0:
print("PASS: dungeon_state_test (GC_DUNGEON destination / arrival / reset)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
func _run() -> void:
MapCoord.set_base(Vector2.ZERO)
var client := FakeClient.new()
var player := Node3D.new()
get_root().add_child(client)
get_root().add_child(player)
var state := DungeonState.new()
get_root().add_child(state)
state.setup(client, func() -> Node3D: return player)
await process_frame
var changes: Array = []
var alarms: Array = []
state.destination_changed.connect(func(active: bool, pos: Vector3): changes.append([active, pos]))
state.alarm_requested.connect(func(pos: Vector3): alarms.append(pos))
client.dungeon_event.emit(state.TIME_ATTACK_START, 0, 0, false)
_ck(not state.active and state.last_subheader == state.TIME_ATTACK_START,
"TIME_ATTACK_START is consumed without inventing a timer")
client.dungeon_event.emit(state.DESTINATION_POSITION, 3210, 6540, true)
_ck(state.active, "destination packet activates route")
_ck(state.destination_cm == Vector2(3210, 6540), "destination keeps server cm")
_ck(state.destination_world.distance_to(Vector3(32.1, 0.0, 65.4)) < 0.01,
"destination converts global cm through MapCoord")
_ck(changes.size() == 1 and changes[0][0] == true, "destination_changed opens target")
_ck(alarms.size() == 1, "destination alarms immediately")
# Reference uses Manhattan < 10000 cm, so exactly 100m is not complete and
# 99m is complete. Keep the player in the same global coordinate frame.
player.global_position = Vector3(32.1, 0.0, 65.4 - 100.0)
state._process(0.0)
_ck(state.active, "Manhattan distance exactly 100m remains active")
player.global_position = Vector3(32.1, 0.0, 65.4 - 99.0)
state._process(0.0)
_ck(not state.active, "Manhattan distance below 100m completes route")
_ck(changes.size() == 2 and changes[1][0] == false, "arrival closes target")
client.dungeon_event.emit(state.DESTINATION_POSITION, 10000, 20000, true)
_ck(state.active, "second destination reopens route")
client.world_reset.emit()
_ck(not state.active and state.destination_cm == Vector2.ZERO and changes.size() == 4,
"world reset clears dungeon destination")
client.queue_free()
player.queue_free()
state.queue_free()
+255
View File
@@ -0,0 +1,255 @@
# entity_rules.gd —— CInstanceBase::IsAttackableInstance 及其判据的 1:1 迁移(§5.4)。
#
# 参考(REF/ = /Users/shenlei/Work/mt/40250/ClientVS22/source/):
# UserInterface/InstanceBase.cpp:2147 IsAttackableInstance —— 唯一判据,逐行照抄
# UserInterface/InstanceBase.cpp:2116 IsConflictAlignmentInstance
# UserInterface/InstanceBase.cpp:470 IsSameEmpire
# UserInterface/InstanceBase.cpp:502 GetAlignmentGrade
# UserInterface/InstanceBase.cpp:524 GetAlignmentType
# UserInterface/InstanceBase.cpp:2059 SetPKMode / :2073 SetKiller / :2086 SetStateFlags
# UserInterface/InstanceBaseEffect.cpp:495 __FindDUELKey
# UserInterface/InstanceBaseEffect.cpp:505 IsPVPInstance
# UserInterface/PythonPlayer.cpp:1445 IsSamePartyMember
# UserInterface/InstanceBase.cpp:2390 IsWoodenDoorvnum 13000 / 30111..30119
#
# 纯静态谓词。调用方(net_play.gd)负责把网络快照拼成 self_e / victim_e / ctx
# 部分依赖在本 40250 客户端的发布源码里没有 setter(同 §3.4/§3.6/§3.8 的处理),
# 在下方逐条标注为 seam。
extends RefCounted
# EPKModesREF/UserInterface/Packet.h:1273
const PK_MODE_PEACE := 0
const PK_MODE_REVENGE := 1
const PK_MODE_FREE := 2
const PK_MODE_PROTECT := 3
const PK_MODE_GUILD := 4
# alignment typeREF/UserInterface/InstanceBase.h:249
const ALIGNMENT_TYPE_WHITE := 0
const ALIGNMENT_TYPE_NORMAL := 1
const ALIGNMENT_TYPE_DARK := 2
# duel modeREF/UserInterface/InstanceBase.h:378GetDuelMode 返回 m_dwDuelMode
const DUEL_NONE := 0
const DUEL_CANNOTATTACK := 1
const DUEL_START := 2
# ADD_CHARACTER_STATE_*REF/UserInterface/Packet.h:1264)—— SetStateFlags 从 state_flags
# 位里取 killer / partyInstanceBase.cpp:2086)。
const STATE_KILLER := 1 << 3
const STATE_PARTY := 1 << 4
# _entity_kind 语义(net_play._entity_kind):0 PC / 1 NPC / 2 MONSTER / 3 STONE / 4 WARP
const KIND_PC := 0
const KIND_NPC := 1
const KIND_MONSTER := 2
const KIND_STONE := 3
const KIND_WARP := 4
# --- alignment(善恶)---------------------------------------------------------
# CInstanceBase::GetAlignmentGradeInstanceBase.cpp:502
static func alignment_grade(a: int) -> int:
if a >= 12000: return 0
elif a >= 8000: return 1
elif a >= 4000: return 2
elif a >= 1000: return 3
elif a >= 0: return 4
elif a > -4000: return 5
elif a > -8000: return 6
elif a > -12000: return 7
return 8
# CInstanceBase::GetAlignmentTypeInstanceBase.cpp:524
static func alignment_type(a: int) -> int:
match alignment_grade(a):
0, 1, 2, 3:
return ALIGNMENT_TYPE_WHITE
5, 6, 7, 8:
return ALIGNMENT_TYPE_DARK
return ALIGNMENT_TYPE_NORMAL
# --- 类别谓词(GraphicThingInstance 的 type 位,见 net_play._entity_kind---------
static func _kind(e: Dictionary) -> int:
return int(e.get("kind", e.get("ch_type", 0)))
static func _is_poly(e: Dictionary) -> bool:
# m_GraphicThingInstance.IsPoly()(变身:模型换成怪,thing type 变 ENEMY
return bool(e.get("poly", false)) \
or int(e.get("polymorph", 0)) != 0 \
or int(e.get("poly_vnum", 0)) != 0
static func _is_pc(e: Dictionary) -> bool:
# CActorInstance::IsPC = (TYPE_PC == m_eActorType)ActorInstance.cpp:282)。
# 变身中的玩家仍是 TYPE_PC —— IsPoly() 另经 race >= MAIN_RACE_MAX_NUM 判真,
# 但 IsPC() 不看 race,所以这里**不**排除 poly(否则 IsAttackableInstance 的
# `else if (IsPC())` 会漏掉变身玩家,与参考端不一致)。
return _kind(e) == KIND_PC
static func _is_stone(e: Dictionary) -> bool:
return _kind(e) == KIND_STONE
static func _is_enemy(e: Dictionary) -> bool:
# CActorInstance::IsEnemy = (TYPE_ENEMY == m_eActorType)ActorInstance.cpp:298)——
# 仅普通怪。变身玩家是 TYPE_PC 不是 ENEMY;独立的 TYPE_POLY 实例归 _is_poly。
return _kind(e) == KIND_MONSTER
static func _is_wooden_door(e: Dictionary) -> bool:
# CInstanceBase::IsWoodenDoorInstanceBase.cpp:2390
var vnum := int(e.get("race", e.get("vnum", 0)))
return vnum == 13000 or (vnum >= 30111 and vnum <= 30119)
static func _is_building(e: Dictionary) -> bool:
# CInstanceBase::IsBuilding() = m_GraphicThingInstance.IsBuilding()。
# 建筑 / 城墙的 thing type 在本 POC 未单独下发(IsWall 的 race 白名单未随源码发布)→ seam。
return bool(e.get("building", false))
# --- killer / party / empire / duel / pvp -----------------------------------
static func _is_killer(e: Dictionary) -> bool:
# m_isKiller,由 SetStateFlags(state_flags & ADD_CHARACTER_STATE_KILLER) 置位。
return (int(e.get("state_flags", 0)) & STATE_KILLER) != 0
static func _is_same_party(a_vid: int, b_vid: int, ctx: Dictionary) -> bool:
# CPythonPlayer::IsSamePartyMember = IsPartyMemberByVID(a) && IsPartyMemberByVID(b)
var vids: Array = ctx.get("party_vids", [])
return a_vid in vids and b_vid in vids
static func _is_same_empire(self_e: Dictionary, victim_e: Dictionary) -> bool:
# CInstanceBase::IsSameEmpireInstanceBase.cpp:470
if int(victim_e.get("empire", 0)) == 0:
return true
# IsGameMaster() —— 本 POC 无 GM 标记 → seam(恒 false)。
if bool(self_e.get("game_master", false)) or bool(victim_e.get("game_master", false)):
return true
return int(victim_e.get("empire", 0)) == int(self_e.get("empire", 0))
static func _find_duel_key(self_vid: int, victim_vid: int, ctx: Dictionary) -> bool:
# CInstanceBase::__FindDUELKeyInstanceBaseEffect.cpp:495)—— g_kSet_dwDUELKey 里
# 有 MAKE_PVPKEY(min,max) 才算。本 POC 的 get_duel().opponents 是主角的对手 VID 列表。
var opp: Array = ctx.get("duel_opponents", [])
return victim_vid in opp or self_vid in opp
static func _pair_in(pairs: Array, a: int, b: int) -> bool:
for p in pairs:
if (int(p[0]) == a and int(p[1]) == b) or (int(p[0]) == b and int(p[1]) == a):
return true
return false
static func _is_pvp_instance(self_e: Dictionary, victim_e: Dictionary, ctx: Dictionary) -> bool:
# CInstanceBase::IsPVPInstanceInstanceBaseEffect.cpp:505):
# if (GetDuelMode()) return true;
# return __FindPVPKey(vidSrc, vidDst) || __FindGVGKey(guildSrc, guildDst);
if int(ctx.get("duel_mode", 0)) != 0:
return true
var sv := int(self_e.get("vid", 0))
var vv := int(victim_e.get("vid", 0))
if _pair_in(ctx.get("pvp_pairs", []), sv, vv):
return true
var sg := int(self_e.get("guild", 0))
var vg := int(victim_e.get("guild", 0))
if sg != 0 and vg != 0 and _pair_in(ctx.get("gvg_pairs", []), sg, vg):
return true
return false
static func is_conflict_alignment_instance(self_e: Dictionary, victim_e: Dictionary) -> bool:
# CInstanceBase::IsConflictAlignmentInstanceInstanceBase.cpp:2116
if _pk_mode_of(victim_e) == PK_MODE_PROTECT:
return false
var mine := alignment_type(int(self_e.get("alignment", 0)))
var theirs := alignment_type(int(victim_e.get("alignment", 0)))
match mine:
ALIGNMENT_TYPE_NORMAL, ALIGNMENT_TYPE_WHITE:
if theirs == ALIGNMENT_TYPE_DARK:
return true
ALIGNMENT_TYPE_DARK:
if mine != theirs:
return true
return false
static func _pk_mode_of(e: Dictionary) -> int:
return int(e.get("pk_mode", 0))
# --- CInstanceBase::IsAttackableInstanceInstanceBase.cpp:2147)—— 逐行 -----------
static func is_attackable_instance(self_e: Dictionary, victim_e: Dictionary, ctx: Dictionary) -> bool:
var sv := int(self_e.get("vid", 0))
var vv := int(victim_e.get("vid", -1))
# if (__IsMainInstance()) { if (rkPlayer.IsObserverMode()) return false; }
if sv == int(ctx.get("main_vid", 0)) and bool(ctx.get("observer", false)):
return false
# if (GetVirtualID() == rkInstVictim.GetVirtualID()) return false;
if sv == vv:
return false
var self_pk := int(ctx.get("pk_mode", 0))
if _is_stone(self_e):
if _is_pc(victim_e):
return true
elif _is_pc(self_e):
if _is_stone(victim_e):
return true
if _is_pc(victim_e):
var duel := int(ctx.get("duel_mode", 0))
if duel != 0:
match duel:
DUEL_CANNOTATTACK:
return false
DUEL_START:
return _find_duel_key(sv, vv, ctx)
# if (PK_MODE_GUILD == GetPKMode()) if (GetGuildID() == victim.GetGuildID()) return false;
if self_pk == PK_MODE_GUILD:
if int(self_e.get("guild", 0)) == int(victim_e.get("guild", 0)):
return false
# if (victim.IsKiller()) if (!IsSamePartyMember(...)) return true;
if _is_killer(victim_e):
if not _is_same_party(sv, vv, ctx):
return true
# if (PK_MODE_PROTECT != GetPKMode()) { ... }
if self_pk != PK_MODE_PROTECT:
if self_pk == PK_MODE_FREE:
if _pk_mode_of(victim_e) != PK_MODE_PROTECT:
if not _is_same_party(sv, vv, ctx):
return true
if self_pk == PK_MODE_GUILD:
if _pk_mode_of(victim_e) != PK_MODE_PROTECT:
if not _is_same_party(sv, vv, ctx):
if int(self_e.get("guild", 0)) != int(victim_e.get("guild", 0)):
return true
# if (IsSameEmpire(victim)) { ... } else { return true; }
if _is_same_empire(self_e, victim_e):
if _is_pvp_instance(self_e, victim_e, ctx):
return true
if self_pk == PK_MODE_REVENGE:
if not _is_same_party(sv, vv, ctx):
if is_conflict_alignment_instance(self_e, victim_e):
return true
else:
return true
if _is_enemy(victim_e):
return true
if _is_wooden_door(victim_e):
return true
elif _is_enemy(self_e):
if _is_pc(victim_e):
return true
if _is_building(victim_e):
return true
elif _is_poly(self_e):
if _is_pc(victim_e):
return true
if _is_enemy(victim_e):
return true
return false
+166
View File
@@ -0,0 +1,166 @@
# entity_rules_test —— §5.4 CInstanceBase::IsAttackableInstance 1:1 迁移自检。
# godot --headless --path project --script entity_rules_test.gd
# 覆盖:alignment grade/type 边界、观战门、自打自、PC/怪/NPC/石头/木门、
# 同帝国 peace、异帝国、PK_FREE/GUILD/REVENGE 分支、killer、决斗三态、
# PVP 关系表、公会战对表、conflict alignment、变身自身。
extends SceneTree
const EntityRules = preload("res://entity_rules.gd")
var _fail := 0
func _ck(c: bool, m: String) -> void:
if not c:
_fail += 1
printerr("FAIL: " + m)
func _e(kind: int, vid: int, extra: Dictionary = {}) -> Dictionary:
var d := {"kind": kind, "vid": vid, "empire": 1, "alignment": 0, "pk_mode": 0, "guild": 0, "state_flags": 0}
for k in extra:
d[k] = extra[k]
return d
func _ctx(extra: Dictionary = {}) -> Dictionary:
var d := {
"observer": false, "main_vid": 1, "pk_mode": 0, "duel_mode": 0,
"duel_opponents": [], "pvp_pairs": [], "gvg_pairs": [], "party_vids": [],
}
for k in extra:
d[k] = extra[k]
return d
func _init() -> void:
_run()
if _fail == 0:
print("PASS: entity_rules_test (§5.4 IsAttackableInstance 1:1)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
func _run() -> void:
var R = EntityRules
# 1) alignment grade / type 边界(InstanceBase.cpp:502 / :524
_ck(R.alignment_grade(12000) == 0 and R.alignment_grade(11999) == 1, "grade 12000/11999")
_ck(R.alignment_grade(0) == 4 and R.alignment_grade(-1) == 5, "grade 0/-1")
_ck(R.alignment_grade(-12000) == 8 and R.alignment_grade(-11999) == 7, "grade -12000/-11999")
_ck(R.alignment_type(5000) == R.ALIGNMENT_TYPE_WHITE, "type white (grade<=3)")
_ck(R.alignment_type(0) == R.ALIGNMENT_TYPE_NORMAL, "type normal (grade 4)")
_ck(R.alignment_type(-5000) == R.ALIGNMENT_TYPE_DARK, "type dark (grade>=5)")
var me := _e(R.KIND_PC, 1)
# 2) 观战模式:主角一律不可攻击
_ck(not R.is_attackable_instance(me, _e(R.KIND_MONSTER, 2),
_ctx({"observer": true})), "observer -> not attackable")
# 3) 自己打自己
_ck(not R.is_attackable_instance(me, _e(R.KIND_PC, 1), _ctx()), "self == victim -> false")
# 4) PC vs 怪(IsEnemy
_ck(R.is_attackable_instance(me, _e(R.KIND_MONSTER, 9), _ctx()), "PC vs monster -> true")
# 5) PC vs NPC —— 无分支命中
_ck(not R.is_attackable_instance(me, _e(R.KIND_NPC, 9), _ctx()), "PC vs NPC -> false")
# 6) PC vs 同帝国 peace PC
_ck(not R.is_attackable_instance(me, _e(R.KIND_PC, 2), _ctx()),
"PC vs same-empire peace PC -> false")
# 7) PC vs 异帝国 PCelse 分支)
_ck(R.is_attackable_instance(me, _e(R.KIND_PC, 2, {"empire": 2}), _ctx()),
"PC vs different-empire PC -> true")
# empire==0 的 victim 视为同帝国(IsSameEmpire 首行)
_ck(not R.is_attackable_instance(me, _e(R.KIND_PC, 2, {"empire": 0}), _ctx()),
"victim empire 0 -> treated same empire -> false")
# 8) PK_FREEvictim 非 protect 且不同队 -> true
_ck(R.is_attackable_instance(me, _e(R.KIND_PC, 2),
_ctx({"pk_mode": R.PK_MODE_FREE})), "PK_FREE vs normal PC -> true")
# 9) PK_FREEvictim 是 protect -> false
_ck(not R.is_attackable_instance(me, _e(R.KIND_PC, 2, {"pk_mode": R.PK_MODE_PROTECT}),
_ctx({"pk_mode": R.PK_MODE_FREE})), "PK_FREE vs protect victim -> false")
# 10) PK_FREE:同队 -> false
_ck(not R.is_attackable_instance(me, _e(R.KIND_PC, 2),
_ctx({"pk_mode": R.PK_MODE_FREE, "party_vids": [1, 2]})),
"PK_FREE same party -> false")
# 11) PK_GUILD:同公会 -> 提前 false
var me_g := _e(R.KIND_PC, 1, {"guild": 7})
_ck(not R.is_attackable_instance(me_g, _e(R.KIND_PC, 2, {"guild": 7}),
_ctx({"pk_mode": R.PK_MODE_GUILD})), "PK_GUILD same guild -> false")
# 12) PK_GUILD:不同公会、victim 非 protect、不同队 -> true
_ck(R.is_attackable_instance(me_g, _e(R.KIND_PC, 2, {"guild": 9}),
_ctx({"pk_mode": R.PK_MODE_GUILD})), "PK_GUILD diff guild -> true")
# 13) killer victimstate_flags bit3):不同队即可打,即便自己 peace
_ck(R.is_attackable_instance(me, _e(R.KIND_PC, 2, {"state_flags": R.STATE_KILLER}), _ctx()),
"killer victim, not party -> true")
# 14) killer victim 但同队 -> false
_ck(not R.is_attackable_instance(me, _e(R.KIND_PC, 2, {"state_flags": R.STATE_KILLER}),
_ctx({"party_vids": [1, 2]})), "killer victim, same party -> false")
# 15) 决斗 DUEL_CANNOTATTACK -> false
_ck(not R.is_attackable_instance(me, _e(R.KIND_PC, 2),
_ctx({"duel_mode": R.DUEL_CANNOTATTACK})), "DUEL_CANNOTATTACK -> false")
# 16) 决斗 DUEL_START:对手在名单 -> true;不在 -> false
_ck(R.is_attackable_instance(me, _e(R.KIND_PC, 2),
_ctx({"duel_mode": R.DUEL_START, "duel_opponents": [2]})),
"DUEL_START vs opponent -> true")
_ck(not R.is_attackable_instance(me, _e(R.KIND_PC, 3),
_ctx({"duel_mode": R.DUEL_START, "duel_opponents": [2]})),
"DUEL_START vs non-opponent -> false")
# 17) PVP 关系表(__FindPVPKey):同帝国 peace 也能打
_ck(R.is_attackable_instance(me, _e(R.KIND_PC, 2),
_ctx({"pvp_pairs": [[2, 1]]})), "PVP pair present -> true")
# 18) 公会战对表(__FindGVGKey):双方都有公会且对表命中
_ck(R.is_attackable_instance(me_g, _e(R.KIND_PC, 2, {"guild": 9}),
_ctx({"gvg_pairs": [[7, 9]]})), "GVG pair present -> true")
_ck(not R.is_attackable_instance(me_g, _e(R.KIND_PC, 2, {"guild": 0}),
_ctx({"gvg_pairs": [[7, 0]]})), "GVG needs both guilds non-zero -> false")
# 19) PK_REVENGE + conflict alignmentself white, victim dark)不同队 -> true
var me_w := _e(R.KIND_PC, 1, {"alignment": 10000})
_ck(R.is_attackable_instance(me_w, _e(R.KIND_PC, 2, {"alignment": -10000}),
_ctx({"pk_mode": R.PK_MODE_REVENGE})), "REVENGE + conflict alignment -> true")
# 20) PK_REVENGE 但 alignment 不冲突(都 white-> false
_ck(not R.is_attackable_instance(me_w, _e(R.KIND_PC, 2, {"alignment": 10000}),
_ctx({"pk_mode": R.PK_MODE_REVENGE})), "REVENGE + non-conflict -> false")
# 21) 石头自身 vs PC victim -> true
_ck(R.is_attackable_instance(_e(R.KIND_STONE, 5), _e(R.KIND_PC, 2), _ctx({"main_vid": 5})),
"stone self vs PC -> true")
# 22) PC self vs 石头 victim -> true
_ck(R.is_attackable_instance(me, _e(R.KIND_STONE, 5), _ctx()), "PC vs stone -> true")
# 23) PC vs 木门(race 13000 / 30111..30119
_ck(R.is_attackable_instance(me, {"kind": 9, "vid": 8, "race": 13000}, _ctx()),
"PC vs wooden door 13000 -> true")
_ck(R.is_attackable_instance(me, {"kind": 9, "vid": 8, "race": 30115}, _ctx()),
"PC vs wooden door 30115 -> true")
_ck(not R.is_attackable_instance(me, {"kind": 9, "vid": 8, "race": 13001}, _ctx()),
"PC vs stone door 13001 -> false")
# 24) is_conflict_alignment_instancevictim PK_PROTECT 一律 false
_ck(not R.is_conflict_alignment_instance(me_w,
_e(R.KIND_PC, 2, {"alignment": -10000, "pk_mode": R.PK_MODE_PROTECT})),
"conflict alignment: protect victim -> false")
# dark self vs 非 dark victim -> conflict
var me_d := _e(R.KIND_PC, 1, {"alignment": -10000})
_ck(R.is_conflict_alignment_instance(me_d, _e(R.KIND_PC, 2, {"alignment": 0})),
"conflict alignment: dark vs normal -> true")
_ck(not R.is_conflict_alignment_instance(me_d, _e(R.KIND_PC, 2, {"alignment": -20000})),
"conflict alignment: dark vs dark -> false")
# 25a) 变身玩家(TYPE_PC + 越界 race):IsPC() 仍为真 → 完全走 PC 分支。
var me_polypc := _e(R.KIND_PC, 1, {"poly": true})
_ck(not R.is_attackable_instance(me_polypc, _e(R.KIND_PC, 2), _ctx()),
"polymorphed PC vs peaceful same-empire PC -> false (still a PC)")
_ck(R.is_attackable_instance(me_polypc, _e(R.KIND_MONSTER, 3), _ctx()),
"polymorphed PC vs monster -> true")
# 25b) 真正的 TYPE_POLY 实例(非 PC 类别)走 else-if IsPoly() 分支:vs PC / vs 怪 均可打。
var poly_self := _e(R.KIND_NPC, 1, {"poly": true})
_ck(R.is_attackable_instance(poly_self, _e(R.KIND_PC, 2), _ctx()), "TYPE_POLY self vs PC -> true")
_ck(R.is_attackable_instance(poly_self, _e(R.KIND_MONSTER, 3), _ctx()), "TYPE_POLY self vs enemy -> true")
+61
View File
@@ -0,0 +1,61 @@
# environment_test —— 真实 .msenv -> Godot 运行时断言。
# 验证参考端的 Filter / Character light / cloud fields are parsed and that
# an existing cloud texture is attached to the appropriate sky material.
extends SceneTree
var _fail := 0
func _ck(ok: bool, message: String) -> void:
if not ok:
_fail += 1
printerr("FAIL: " + message)
func _init() -> void:
if not ClassDB.class_exists("Metin2World"):
printerr("FAIL: Metin2World extension is not registered")
quit(1)
return
var world: Node = ClassDB.instantiate("Metin2World")
world.set("auto_load", false)
world.set("assets_root", AssetRoot.path())
world.set("map_path", "OutdoorA1/metin2_map_a1")
world.set("focus_tile", Vector2i(0, 0))
world.set("load_radius_tiles", 0)
world.set("splat_enabled", false)
world.set("water_enabled", false)
get_root().add_child(world)
_ck(bool(world.call("load_map")), "A1 map loads with .msenv")
var rep: Dictionary = world.call("get_load_report")
_ck(bool(rep.get("env_ok", false)), ".msenv env_ok")
var env_node := world.get_node_or_null("WorldEnv") as WorldEnvironment
_ck(env_node != null and env_node.environment != null, "WorldEnv has Environment")
var background_light := world.get_node_or_null("Sun") as DirectionalLight3D
var character_light := world.get_node_or_null("CharacterLight") as DirectionalLight3D
_ck(background_light != null and character_light != null, "background and character lights exist")
if background_light != null and character_light != null:
_ck(background_light.light_cull_mask == 1, "background light only sees layer 1")
_ck(character_light.light_cull_mask == 2, "character light only sees layer 2")
if env_node != null:
_ck(bool(env_node.get_meta("msenv_filter_enabled", false)) == false,
"A1 filter flag preserved")
_ck(is_equal_approx(float(env_node.get_meta("msenv_wind_strength", -1.0)), 0.2),
"reference wind strength default preserved")
_ck(is_equal_approx(float(env_node.get_meta("msenv_wind_random", -1.0)), 0.0),
"reference wind random default preserved")
_ck(env_node.has_meta("msenv_cloud_scale") and env_node.has_meta("msenv_cloud_speed"),
"cloud scale/speed metadata preserved")
var sky := env_node.environment.sky
_ck(sky != null, "WorldEnv has sky")
if sky != null:
var mat := sky.get_material() as ProceduralSkyMaterial
_ck(mat != null, "sky uses ProceduralSkyMaterial")
if mat != null:
_ck(mat.sky_cover != null, "real A1 cloud texture attached as sky cover")
world.queue_free()
await process_frame
if _fail == 0:
print("PASS: environment_test (.msenv parser + runtime sky cover)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
+43
View File
@@ -163,3 +163,46 @@ func _run() -> void:
_ck(em4._eff([9, 0, 0, 0], 0, [{"vnum": 5}], 0) == 9, "_eff: parts 非 0 优先")
_ck(em4._eff([0, 0, 0, 0], 0, [{"vnum": 5}], 0) == 5, "_eff: parts 0 -> 装备槽")
_ck(em4._eff([], 1, [{"vnum": 1}, {"vnum": 2}], 1) == 2, "_eff: 无 parts -> 装备槽")
# §4.1 修改 4:动物 / 怪物时装(shape 100-103)遮挡武器 + 头发
var em5: Node = EquipModel.new()
get_root().add_child(em5)
em5.setup(fc, il, func() -> Node: return model, assets)
em5.main_getter = func() -> int: return 1000
em5.armor_shape_of = func(v: int) -> int: return 101 if v == 88888 else v
fc.equip[4] = {"vnum": 19, "count": 1, "wear": 4} # 有武器
fc.equip[10] = {"vnum": 19, "count": 1, "wear": 10} # 有盾
fc.main_parts = [88888, 0, 0, 5] # parts[ARMOR] -> shape 101
model.weapon_gr2 = "x"; model.shield_gr2 = "x"; model.hair_gr2 = "x"
em5._last_weapon_vnum = -1; em5._last_shield_vnum = -1
em5._last_body_vnum = -1; em5._last_head_vnum = -2
em5.refresh()
_ck(model.weapon_gr2 == "", "动物时装 -> weapon_gr2 清空 (%s)" % model.weapon_gr2)
_ck(model.shield_gr2 == "", "动物时装 -> shield_gr2 清空 (%s)" % model.shield_gr2)
_ck(model.hair_gr2 == "", "动物时装 -> hair_gr2 清空 (%s)" % model.hair_gr2)
# 脱下动物时装 -> 武器恢复解析(资产里 00010.gr2 可能不存在,至少不再是遮挡态)
fc.main_parts = [0, 0, 0, 5]
em5._last_weapon_vnum = -999; em5._last_shield_vnum = -999
em5._last_body_vnum = -1; em5._last_head_vnum = -2
em5.refresh()
_ck(model.weapon_gr2 == "" or model.weapon_gr2.ends_with("00010.gr2"),
"脱时装 -> weapon 重新解析 (%s)" % model.weapon_gr2)
# 婚纱(shape 201)遮挡武器,但头发保留;捧花例外
var em6: Node = EquipModel.new()
get_root().add_child(em6)
em6.setup(fc, il, func() -> Node: return model, assets)
em6.main_getter = func() -> int: return 1000
em6.armor_shape_of = func(v: int) -> int: return 201 if v == 99999 else v
fc.equip[4] = {"vnum": 19, "count": 1, "wear": 4}
fc.main_parts = [99999, 0, 0, 5]
model.weapon_gr2 = "x"; model.hair_gr2 = "x"
em6._last_weapon_vnum = -1; em6._last_body_vnum = -1; em6._last_head_vnum = -2
em6.refresh()
_ck(model.weapon_gr2 == "", "婚纱 + 剑 -> weapon_gr2 清空")
_ck(model.hair_gr2 != "", "婚纱 -> 头发保留 (%s)" % model.hair_gr2)
# 换捧花(vnum 50201-> 武器恢复
fc.main_parts = [99999, 50201, 0, 5]
em6._last_weapon_vnum = -999; em6._last_body_vnum = -1; em6._last_head_vnum = -2
em6.refresh()
_ck(em6._last_weapon_vnum == 50201, "婚纱 + 捧花 -> 武器 vnum 保留 (%d)" % em6._last_weapon_vnum)
+234
View File
@@ -0,0 +1,234 @@
# equip_rules.gd —— 装备穿戴合法性校验(纯函数校验器)。
#
# 语义顺序对齐服务器 `char_item.cpp::CanEquipNow`
# antiflag(性别 / 职业 / 帝国) → wearable_flag 与目标 wear 槽匹配 → limit(等级 / 属性)。
#
# 参考枚举 1:1 自 `REF/GameLib/ItemData.h``EItemAntiFlag` / `EItemWearableFlag` /
# `EWearPositions` / `ELimitTypes` / `EItemType` / `EArmorSubTypes` / `ECostumeSubTypes`)。
# 40250 客户端本身**不做** `CanEquipNow` 前置门(`PythonItemModule` 只暴露 `IsWearableFlag` /
# `GetLimit`,真正的仲裁在服务器)。这里按 §3.9 `__CanShot` 的方式在客户端防御式重建一道前置
# 门——精确的 tie-break 顺序、PCBANG / REAL_TIME 类 limit、已穿同槽替换等留待接服务器比对(seam)。
extends RefCounted
# --- EItemAntiFlagItemData.hclassic 排列:帝国位在 1<<9..1<<11---
const ANTIFLAG_FEMALE := 1 << 0
const ANTIFLAG_MALE := 1 << 1
const ANTIFLAG_WARRIOR := 1 << 2
const ANTIFLAG_ASSASSIN := 1 << 3
const ANTIFLAG_SURA := 1 << 4
const ANTIFLAG_SHAMAN := 1 << 5
const ANTIFLAG_EMPIRE_A := 1 << 9
const ANTIFLAG_EMPIRE_B := 1 << 10
const ANTIFLAG_EMPIRE_R := 1 << 11
# --- EItemWearableFlag ---
const WEARABLE_BODY := 1 << 0
const WEARABLE_HEAD := 1 << 1
const WEARABLE_FOOTS := 1 << 2
const WEARABLE_WRIST := 1 << 3
const WEARABLE_WEAPON := 1 << 4
const WEARABLE_NECK := 1 << 5
const WEARABLE_EAR := 1 << 6
const WEARABLE_UNIQUE := 1 << 7
const WEARABLE_SHIELD := 1 << 8
const WEARABLE_ARROW := 1 << 9
# --- EWearPositions0..10+ 新装备系统槽(GameType.h19..23---
const WEAR_BODY := 0
const WEAR_HEAD := 1
const WEAR_FOOTS := 2
const WEAR_WRIST := 3
const WEAR_WEAPON := 4
const WEAR_NECK := 5
const WEAR_EAR := 6
const WEAR_UNIQUE1 := 7
const WEAR_UNIQUE2 := 8
const WEAR_ARROW := 9
const WEAR_SHIELD := 10
const WEAR_MAX_NUM := 11
const WEAR_COSTUME_BODY := 19
const WEAR_COSTUME_HAIR := 20
const WEAR_RING1 := 21
const WEAR_RING2 := 22
const WEAR_BELT := 23
# --- ELimitTypes ---
const LIMIT_NONE := 0
const LIMIT_LEVEL := 1
const LIMIT_STR := 2
const LIMIT_DEX := 3
const LIMIT_INT := 4
const LIMIT_CON := 5
const LIMIT_PCBANG := 6
const LIMIT_REAL_TIME := 7
# --- EItemType(相关项)+ 子类 ---
const ITEM_TYPE_WEAPON := 1
const ITEM_TYPE_ARMOR := 2
const ITEM_TYPE_COSTUME := 28
const ITEM_TYPE_RING := 33
const ITEM_TYPE_BELT := 34
const ARMOR_BODY := 0
const ARMOR_HEAD := 1
const ARMOR_SHIELD := 2
const ARMOR_WRIST := 3
const ARMOR_FOOTS := 4
const ARMOR_NECK := 5
const ARMOR_EAR := 6
const COSTUME_BODY := 0
const COSTUME_HAIR := 1
# --- 职业 / 性别 <- racecommon length.h `MAIN_RACE_*`job = race % 4;性别见下表)---
const JOB_WARRIOR := 0
const JOB_ASSASSIN := 1
const JOB_SURA := 2
const JOB_SHAMAN := 3
const SEX_MALE := 0
const SEX_FEMALE := 1
# race 0..7 = WARRIOR_M / ASSASSIN_W / SURA_M / SHAMAN_W / WARRIOR_W / ASSASSIN_M / SURA_W / SHAMAN_M
const SEX_BY_RACE := [SEX_MALE, SEX_FEMALE, SEX_MALE, SEX_FEMALE, SEX_FEMALE, SEX_MALE, SEX_FEMALE, SEX_MALE]
const JOB_ANTIFLAG := [ANTIFLAG_WARRIOR, ANTIFLAG_ASSASSIN, ANTIFLAG_SURA, ANTIFLAG_SHAMAN]
# 帝国 1/2/3GC_EMPIRE)→ 对应 anti 位
const EMPIRE_ANTIFLAG := {1: ANTIFLAG_EMPIRE_A, 2: ANTIFLAG_EMPIRE_B, 3: ANTIFLAG_EMPIRE_R}
static func sex_of_race(race: int) -> int:
return int(SEX_BY_RACE[race]) if race >= 0 and race < SEX_BY_RACE.size() else SEX_MALE
static func job_of_race(race: int) -> int:
return (race % 4) if race >= 0 else JOB_WARRIOR
# 物品默认落在哪个 wear 槽(type / sub_type 决定)。不可穿戴 → -1。
static func default_wear_for(item: Dictionary) -> int:
var itype := int(item.get("type", -1))
var sub := int(item.get("sub_type", -1))
match itype:
ITEM_TYPE_WEAPON:
return WEAR_WEAPON
ITEM_TYPE_COSTUME:
return WEAR_COSTUME_HAIR if sub == COSTUME_HAIR else WEAR_COSTUME_BODY
ITEM_TYPE_RING:
return WEAR_RING1
ITEM_TYPE_BELT:
return WEAR_BELT
ITEM_TYPE_ARMOR:
match sub:
ARMOR_HEAD: return WEAR_HEAD
ARMOR_SHIELD: return WEAR_SHIELD
ARMOR_WRIST: return WEAR_WRIST
ARMOR_FOOTS: return WEAR_FOOTS
ARMOR_NECK: return WEAR_NECK
ARMOR_EAR: return WEAR_EAR
_: return WEAR_BODY
return -1
static func is_equippable(item: Dictionary) -> bool:
return default_wear_for(item) >= 0
# wear 槽 → 需要的 wearable_flag 位(新装备槽 19..23 无 wearable_flag → 0)。
static func wear_to_wearable(wear: int) -> int:
match wear:
WEAR_BODY: return WEARABLE_BODY
WEAR_HEAD: return WEARABLE_HEAD
WEAR_FOOTS: return WEARABLE_FOOTS
WEAR_WRIST: return WEARABLE_WRIST
WEAR_WEAPON: return WEARABLE_WEAPON
WEAR_NECK: return WEARABLE_NECK
WEAR_EAR: return WEARABLE_EAR
WEAR_UNIQUE1, WEAR_UNIQUE2: return WEARABLE_UNIQUE
WEAR_ARROW: return WEARABLE_ARROW
WEAR_SHIELD: return WEARABLE_SHIELD
return 0
# 目标槽能否接受这件物品:新装备槽按 item type,旧 11 槽按 wearable_flag 位。
static func slot_accepts(item: Dictionary, wear: int) -> bool:
var itype := int(item.get("type", -1))
match wear:
WEAR_COSTUME_BODY, WEAR_COSTUME_HAIR:
return itype == ITEM_TYPE_COSTUME
WEAR_RING1, WEAR_RING2:
return itype == ITEM_TYPE_RING
WEAR_BELT:
return itype == ITEM_TYPE_BELT
var need := wear_to_wearable(wear)
if need == 0:
return false
return (int(item.get("wear_flags", 0)) & need) != 0
static func _fail(code: String, need := 0) -> Dictionary:
return {"ok": false, "code": code, "need": need}
# 主校验。
# item : proto 字典 —— {type, sub_type, anti_flags, wear_flags, limits:[{type,value}]}
# wear : 目标 EWearPositions(或新装备槽 19..23);传 -1 用 default_wear_for(item)
# ctx : {race, sex, job, empire, level, st, dx, ht, iq}
# —— sex/job 缺省时由 race 推;属性 st/dx/ht/iq 缺省或 < 0 = 不判(seam
# 返回 {ok: bool, code: String, need: int}。code 见下方常量注释;need = 失败 limit 的要求值。
static func can_equip(item: Dictionary, wear: int, ctx: Dictionary) -> Dictionary:
if item.is_empty():
return _fail("NO_ITEM")
var target := wear if wear >= 0 else default_wear_for(item)
if target < 0:
return _fail("NOT_EQUIPPABLE")
var anti := int(item.get("anti_flags", 0))
var race := int(ctx.get("race", 0))
var sex := int(ctx.get("sex", sex_of_race(race)))
var job := int(ctx.get("job", job_of_race(race)))
var empire := int(ctx.get("empire", 0))
# 1) anti-flag:性别
if sex == SEX_FEMALE and (anti & ANTIFLAG_FEMALE):
return _fail("ANTI_SEX")
if sex == SEX_MALE and (anti & ANTIFLAG_MALE):
return _fail("ANTI_SEX")
# 2) anti-flag:职业
if job >= 0 and job < JOB_ANTIFLAG.size() and (anti & int(JOB_ANTIFLAG[job])):
return _fail("ANTI_JOB")
# 3) anti-flag:帝国
var emp_bit := int(EMPIRE_ANTIFLAG.get(empire, 0))
if emp_bit != 0 and (anti & emp_bit):
return _fail("ANTI_EMPIRE")
# 4) wearable_flag 与目标槽匹配
if not slot_accepts(item, target):
return _fail("NOT_WEARABLE_HERE")
# 5) limit(等级 / 属性)—— 逐条 GetLimit
for lim in item.get("limits", []):
var t := int(lim.get("type", 0))
var v := int(lim.get("value", 0))
if v <= 0:
continue
match t:
LIMIT_LEVEL:
if int(ctx.get("level", 0)) < v:
return _fail("LIMIT_LEVEL", v)
LIMIT_STR:
if _stat(ctx, "st") >= 0 and _stat(ctx, "st") < v:
return _fail("LIMIT_STR", v)
LIMIT_DEX:
if _stat(ctx, "dx") >= 0 and _stat(ctx, "dx") < v:
return _fail("LIMIT_DEX", v)
LIMIT_INT:
if _stat(ctx, "iq") >= 0 and _stat(ctx, "iq") < v:
return _fail("LIMIT_INT", v)
LIMIT_CON:
if _stat(ctx, "ht") >= 0 and _stat(ctx, "ht") < v:
return _fail("LIMIT_CON", v)
_:
pass # PCBANG / REAL_TIME* → 服务器判(seam
return {"ok": true, "code": "", "need": 0}
static func _stat(ctx: Dictionary, key: String) -> int:
return int(ctx.get(key, -1))
+131
View File
@@ -0,0 +1,131 @@
# equip_rules_test —— §4.1 修改 2:装备穿戴合法性校验(EquipRules)1:1 自检。
# canonical #17。 godot --headless --path project --script equip_rules_test.gd
extends SceneTree
const EquipRules = preload("res://equip_rules.gd")
var failed := 0
func _ck(ok: bool, message: String) -> void:
if not ok:
failed += 1
printerr("FAIL: " + message)
func _weapon(extra := {}) -> Dictionary:
var d := {"type": 1, "sub_type": 0, "anti_flags": 0, "wear_flags": 1 << 4, "limits": []}
d.merge(extra, true)
return d
func _init() -> void:
_test_race_tables()
_test_default_wear()
_test_slot_accepts()
_test_antiflag()
_test_wearable()
_test_limits()
_test_order_and_newslots()
if failed == 0:
print("PASS: equip_rules_test (§4.1 CanEquipNow antiflag→wearable→limit 1:1)")
quit(0)
else:
quit(1)
func _test_race_tables() -> void:
# race 0..7 = WARRIOR_M / ASSASSIN_W / SURA_M / SHAMAN_W / WARRIOR_W / ASSASSIN_M / SURA_W / SHAMAN_M
_ck(EquipRules.sex_of_race(0) == EquipRules.SEX_MALE, "race 0 male")
_ck(EquipRules.sex_of_race(1) == EquipRules.SEX_FEMALE, "race 1 female")
_ck(EquipRules.sex_of_race(4) == EquipRules.SEX_FEMALE, "race 4 female (warrior W)")
_ck(EquipRules.sex_of_race(5) == EquipRules.SEX_MALE, "race 5 male (assassin M)")
_ck(EquipRules.job_of_race(0) == EquipRules.JOB_WARRIOR, "race 0 warrior")
_ck(EquipRules.job_of_race(1) == EquipRules.JOB_ASSASSIN, "race 1 assassin")
_ck(EquipRules.job_of_race(6) == EquipRules.JOB_SURA, "race 6 sura")
_ck(EquipRules.job_of_race(7) == EquipRules.JOB_SHAMAN, "race 7 shaman")
func _test_default_wear() -> void:
_ck(EquipRules.default_wear_for(_weapon()) == EquipRules.WEAR_WEAPON, "weapon -> WEAR_WEAPON")
_ck(EquipRules.default_wear_for({"type": 2, "sub_type": 0}) == EquipRules.WEAR_BODY, "armor body -> WEAR_BODY")
_ck(EquipRules.default_wear_for({"type": 2, "sub_type": 1}) == EquipRules.WEAR_HEAD, "armor head -> WEAR_HEAD")
_ck(EquipRules.default_wear_for({"type": 2, "sub_type": 2}) == EquipRules.WEAR_SHIELD, "armor shield -> WEAR_SHIELD")
_ck(EquipRules.default_wear_for({"type": 2, "sub_type": 5}) == EquipRules.WEAR_NECK, "armor neck -> WEAR_NECK")
_ck(EquipRules.default_wear_for({"type": 28, "sub_type": 0}) == EquipRules.WEAR_COSTUME_BODY, "costume body -> 19")
_ck(EquipRules.default_wear_for({"type": 28, "sub_type": 1}) == EquipRules.WEAR_COSTUME_HAIR, "costume hair -> 20")
_ck(EquipRules.default_wear_for({"type": 33}) == EquipRules.WEAR_RING1, "ring -> WEAR_RING1")
_ck(EquipRules.default_wear_for({"type": 34}) == EquipRules.WEAR_BELT, "belt -> WEAR_BELT")
_ck(EquipRules.default_wear_for({"type": 3}) == -1, "potion -> not equippable")
_ck(not EquipRules.is_equippable({"type": 3}), "is_equippable(potion) false")
_ck(EquipRules.is_equippable(_weapon()), "is_equippable(weapon) true")
func _test_slot_accepts() -> void:
_ck(EquipRules.slot_accepts(_weapon(), EquipRules.WEAR_WEAPON), "weapon accepted at WEAR_WEAPON")
_ck(not EquipRules.slot_accepts(_weapon(), EquipRules.WEAR_BODY), "weapon rejected at WEAR_BODY")
_ck(EquipRules.slot_accepts({"type": 2, "wear_flags": 1 << 0}, EquipRules.WEAR_BODY), "body armor at WEAR_BODY")
_ck(EquipRules.slot_accepts({"type": 2, "wear_flags": 1 << 7}, EquipRules.WEAR_UNIQUE2), "unique flag at WEAR_UNIQUE2")
_ck(EquipRules.slot_accepts({"type": 33}, EquipRules.WEAR_RING1), "ring type at WEAR_RING1 (no wear_flags)")
_ck(not EquipRules.slot_accepts(_weapon(), EquipRules.WEAR_RING1), "weapon rejected at WEAR_RING1")
_ck(EquipRules.slot_accepts({"type": 34}, EquipRules.WEAR_BELT), "belt type at WEAR_BELT")
func _test_antiflag() -> void:
var female_only := _weapon({"anti_flags": EquipRules.ANTIFLAG_FEMALE})
_ck(not EquipRules.can_equip(female_only, -1, {"race": 1})["ok"], "ANTI_FEMALE blocks female (race 1)")
_ck(EquipRules.can_equip(female_only, -1, {"race": 1})["code"] == "ANTI_SEX", "code ANTI_SEX")
_ck(EquipRules.can_equip(female_only, -1, {"race": 0})["ok"], "ANTI_FEMALE ok for male (race 0)")
var male_only := _weapon({"anti_flags": EquipRules.ANTIFLAG_MALE})
_ck(not EquipRules.can_equip(male_only, -1, {"race": 0})["ok"], "ANTI_MALE blocks male")
_ck(EquipRules.can_equip(male_only, -1, {"sex": EquipRules.SEX_FEMALE})["ok"], "ANTI_MALE ok when sex override female")
var no_warrior := _weapon({"anti_flags": EquipRules.ANTIFLAG_WARRIOR})
_ck(EquipRules.can_equip(no_warrior, -1, {"race": 0})["code"] == "ANTI_JOB", "ANTI_WARRIOR blocks warrior (race 0)")
_ck(EquipRules.can_equip(no_warrior, -1, {"race": 1})["ok"], "ANTI_WARRIOR ok for assassin (race 1)")
var no_emp_a := _weapon({"anti_flags": EquipRules.ANTIFLAG_EMPIRE_A})
_ck(EquipRules.can_equip(no_emp_a, -1, {"empire": 1})["code"] == "ANTI_EMPIRE", "ANTI_EMPIRE_A blocks empire 1")
_ck(EquipRules.can_equip(no_emp_a, -1, {"empire": 2})["ok"], "ANTI_EMPIRE_A ok for empire 2")
_ck(EquipRules.can_equip(no_emp_a, -1, {"empire": 0})["ok"], "no empire -> anti empire skipped")
func _test_wearable() -> void:
var res := EquipRules.can_equip(_weapon(), EquipRules.WEAR_BODY, {"race": 0})
_ck(not res["ok"] and res["code"] == "NOT_WEARABLE_HERE", "weapon at WEAR_BODY -> NOT_WEARABLE_HERE")
_ck(EquipRules.can_equip(_weapon(), EquipRules.WEAR_WEAPON, {"race": 0})["ok"], "weapon at WEAR_WEAPON ok")
func _test_limits() -> void:
var lv30 := _weapon({"limits": [{"type": EquipRules.LIMIT_LEVEL, "value": 30}]})
var r := EquipRules.can_equip(lv30, -1, {"race": 0, "level": 20})
_ck(not r["ok"] and r["code"] == "LIMIT_LEVEL" and int(r["need"]) == 30, "level 20 < 30 -> LIMIT_LEVEL need 30")
_ck(EquipRules.can_equip(lv30, -1, {"race": 0, "level": 30})["ok"], "level 30 == 30 ok")
_ck(EquipRules.can_equip(lv30, -1, {"race": 0, "level": 99})["ok"], "level 99 ok")
var str50 := _weapon({"limits": [{"type": EquipRules.LIMIT_STR, "value": 50}]})
_ck(EquipRules.can_equip(str50, -1, {"race": 0})["ok"], "STR limit skipped when ctx has no st (-1)")
_ck(not EquipRules.can_equip(str50, -1, {"race": 0, "st": 40})["ok"], "st 40 < 50 -> fail")
_ck(EquipRules.can_equip(str50, -1, {"race": 0, "st": 50})["ok"], "st 50 ok")
_ck(EquipRules.can_equip(str50, -1, {"race": 0, "st": 40})["code"] == "LIMIT_STR", "code LIMIT_STR")
var con20 := _weapon({"limits": [{"type": EquipRules.LIMIT_CON, "value": 20}]})
_ck(not EquipRules.can_equip(con20, -1, {"race": 0, "ht": 10})["ok"], "ht 10 < 20 -> LIMIT_CON")
# value <= 0 的 limit 项被忽略
var zero := _weapon({"limits": [{"type": EquipRules.LIMIT_LEVEL, "value": 0}]})
_ck(EquipRules.can_equip(zero, -1, {"race": 0, "level": 1})["ok"], "limit value 0 ignored")
func _test_order_and_newslots() -> void:
# anti-flag 先于 limit:两个都失败 → 返回 ANTI_SEX
var both := _weapon({"anti_flags": EquipRules.ANTIFLAG_FEMALE,
"limits": [{"type": EquipRules.LIMIT_LEVEL, "value": 99}]})
_ck(EquipRules.can_equip(both, -1, {"race": 1, "level": 1})["code"] == "ANTI_SEX",
"antiflag checked before limit")
# wearable 先于 limit
var wl := _weapon({"limits": [{"type": EquipRules.LIMIT_LEVEL, "value": 99}]})
_ck(EquipRules.can_equip(wl, EquipRules.WEAR_BODY, {"race": 0, "level": 1})["code"] == "NOT_WEARABLE_HERE",
"wearable checked before limit")
# 新装备槽:ring / belt / costume 按 type 判定,无 wear_flags 也能穿
_ck(EquipRules.can_equip({"type": 33}, EquipRules.WEAR_RING1, {"race": 0})["ok"], "ring at RING1 ok")
_ck(EquipRules.can_equip({"type": 34}, EquipRules.WEAR_BELT, {"race": 0})["ok"], "belt at BELT ok")
_ck(EquipRules.can_equip({"type": 28, "sub_type": 0}, -1, {"race": 0})["ok"], "costume body default slot ok")
_ck(EquipRules.can_equip({"type": 28}, EquipRules.WEAR_BELT, {"race": 0})["code"] == "NOT_WEARABLE_HERE",
"costume at BELT rejected")
# 空物品
_ck(EquipRules.can_equip({}, -1, {})["code"] == "NO_ITEM", "empty item -> NO_ITEM")
+6 -3
View File
@@ -126,11 +126,14 @@ func shoot(skill): shots.append(skill); return true
await process_frame
_ck(gs.ui.get_node_or_null("ColorRect") != null or gs._flash_rect != null,
"motion_event: type 3 → 屏闪 ColorRect")
gs._pending_shoot_skill = 77
gs._queue_shot(77)
gs._queue_shot(78)
gs._on_local_motion_event(6, "", "", Vector3.ZERO)
_ck(fake_client.shots == [77], "motion_event: FLY → sends pending CG_SHOOT once")
_ck(fake_client.shots == [77], "motion_event: FLY → FIFO pop 一发 CG_SHOOT")
gs._on_local_motion_event(6, "", "", Vector3.ZERO)
_ck(fake_client.shots == [77], "motion_event: FLY clears pending shoot")
_ck(fake_client.shots == [77, 78], "motion_event: FLY 连射按序 popAutoClear")
gs._on_local_motion_event(6, "", "", Vector3.ZERO)
_ck(fake_client.shots == [77, 78], "motion_event: FLY 队列空 → 不再发")
gs.queue_free()
+60
View File
@@ -0,0 +1,60 @@
# fishing_water_test —— ClientVS22 GetFishingRot / ATTRIBUTE_WATER 回归。
# godot --headless --path project --script fishing_water_test.gd
extends SceneTree
var _fail := 0
func _ck(ok: bool, message: String) -> void:
if not ok:
_fail += 1
printerr("FAIL: " + message)
func _init() -> void:
if not ClassDB.class_exists("Metin2World"):
printerr("FAIL: Metin2World extension is not registered")
quit(1)
return
var world: Node = ClassDB.instantiate("Metin2World")
world.set("auto_load", false)
world.set("assets_root", AssetRoot.path())
world.set("map_path", "OutdoorA1/metin2_map_a1")
world.set("focus_tile", Vector2i(0, 0))
# A1(4x5) 的水面不保证位于 focus_tile=(0,0),加载完整地图后
# 自动寻找 ATTRIBUTE_WATER 样本,测试不绑定某个无水区块。
world.set("load_radius_tiles", -1)
world.set("splat_enabled", false)
world.set("objects_enabled", false)
world.set("env_enabled", false)
world.set("water_enabled", true)
get_root().add_child(world)
_ck(bool(world.call("load_map")), "A1 map loads for water probe")
var water := Vector3(-1.0, 0.0, -1.0)
for z in range(5 * 256):
for x in range(4 * 256):
if (int(world.call("sample_attribute", float(x) + 0.5, float(z) + 0.5)) & 2) != 0:
water = Vector3(float(x) + 0.5, 0.0, float(z) + 0.5)
break
if water.x >= 0.0:
break
_ck(water.x >= 0.0, "A1 has an ATTRIBUTE_WATER sample in loaded chunk")
if water.x >= 0.0:
# The POC MapCoord frame maps heading 270 to world +Z. Put the
# player 600cm behind the sample and require the zero-offset probe
# to resolve a valid fishing heading.
var player := Vector3(water.x, 0.0, water.z - 6.0)
var resolved := float(world.call("get_fishing_rotation", player.x, player.z, 270.0))
_ck(resolved >= 0.0 and resolved < 360.0,
"GetFishingRot resolves a heading over water, got %f" % resolved)
var can := bool(world.call("can_fishing_position", player.x, player.z, 270.0))
_ck(can, "can_fishing_position agrees with GetFishingRot")
var outside := float(world.call("get_fishing_rotation", -50.0, -50.0, 0.0))
_ck(outside < 0.0, "map-outside water probe rejects")
world.queue_free()
await process_frame
if _fail == 0:
print("PASS: fishing_water_test (GetFishingRot + ATTRIBUTE_WATER)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
+314
View File
@@ -0,0 +1,314 @@
# fly_object.gd —— 1:1 复刻 GameLib 的飞行物实体系统(CFlyingManager / CFlyingInstance /
# CFlyingData),把原客户端「.msa FLY 帧 → OnShoot → CG_SHOOT」之后、由服务端 GC_CREATE_FLY
# 驱动的那颗真实弹道 + 命中判定搬到 POC。net_world.gd 收到 client.fly_cue 后调
# FlyManager.spawn(),每帧 step() 推进运动学并做「线段到点」的爆炸判定,命中 / 超程 / 触地
# 时回调 handler(对齐 IFlyEventHandler)。§3.6
#
# 参考:
# REF/GameLib/FlyingObjectManager.cpp CFlyingManager::CreateFlyingInstanceFlyTarget /
# Update()(遍历实例,Update() 返 false 即 Delete + erase/ CreateIndexedFlyNORMAL /
# FIRE_CRACKER / AUTO_FIRE 三种服务端索引飞行)
# REF/GameLib/FlyingInstance.cpp CFlyingInstance::Create → __SetDataPointerm_v3Position=起点、
# m_bAlive、m_fStartTime、由 m_fRollAngle-90 yaw + m_fConeAngle roll 建四元数、可选
# m_bSpreading 随机扩散、m_v3Velocity=m_v3LocalVelocity=(0,-m_fInitVel,0)、m_v3Accel=
# pData->m_v3Accel、m_fRemainRange=m_fRange、m_iPierceCount+ __SetTargetDirection
# v3TargetPos=target.GetFlyTargetPosition()m_bMaintainParallel 时 z+=50;方向=
# normalize(target-pos),把局部速度 / 加速度旋到该方向)
# REF/GameLib/FlyingInstance.cpp CFlyingInstance::Update()homing → AdjustDirectionForHoming
# v3LastPosition=m_v3Positionm_v3Velocity+=m_v3Accel*dtm_v3Velocity.z+=m_fGravity*dt
# v3Movement=m_v3Velocity*dt_fMoveDistance=|v3Movement|m_fRemainRange-=_fMoveDistance
# m_v3Position+=v3Movementm_fRemainRange<0 → OnExplodingOutOfRange + __Explode(false)
# 对象目标且未命中 && square_distance_between_linesegment_and_point(pos,last,targetPos) <
# m_fBombRange² → m_bTargetHitted=TRUEm_canAttack 时 pVictim->OnShootDamage()
# handler->OnExplodingAtTarget(skill)m_iPierceCount>0 → --、__Bomb();否则 __Explode()
# 位置目标同样线段判定 → __Explode()m_bHitOnBackground → GetTerrainHeight(x,-y)>z → 触地
# REF/GameLib/FlyingInstance.cpp __Explode(bBomb=true)!m_bAlive returnm_bAlive=false
# bBomb → __Bomb()(在 m_v3Position 生成 m_dwBombEffectID 特效)
# REF/EterLib/GrpMath.h:80 square_distance_between_linesegment_and_point(点到线段的平方距离,
# d<=0 取端点 p1、d>=l 取端点 p2、否则叉积模方 / l)
# REF/GameLib/FlyingData.cpp CFlyingData::__Initialize()(本文件 FlyData 默认值逐项对齐)
# REF/GameLib/FlyHandler.h IFlyEventHandlerOnExplodingOutOfRange / OnExplodingAtBackground /
# OnExplodingAtAnotherTarget(skill,vid) / OnExplodingAtTarget(skill)
extends Node
# CFlyingManager::EIndexFlyTypeREF/GameLib/FlyingObjectManager.h
const INDEX_FLY_TYPE_NORMAL := 0
const INDEX_FLY_TYPE_FIRE_CRACKER := 1
const INDEX_FLY_TYPE_AUTO_FIRE := 2
# CFlyingData —— .fly 脚本字段。POC 无 .fly 加载器,默认值逐项对齐 CFlyingData::__Initialize()
# (长度单位:原版是 pixel==cm,这里换算成米后传进来,故 init_vel / range / bomb_range 都按米)。
class FlyData extends RefCounted:
var init_vel := 2.0 # m_fInitVel=200cm/s)→ 2 m/s(占位;net_world 用弓箭值覆盖)
var cone_angle := 0.0 # m_fConeAngle
var roll_angle := 0.0 # m_fRollAngle
var gravity := 0.0 # m_fGravity(原版加到速度 .z(=up),本实现映射到 -Y = 向下)
var accel := Vector3.ZERO # m_v3Accel
var flat_range := 5.0 # m_fRange=500cm)→ 5 m
var bomb_range := 0.1 # m_fBombRange=10cm)→ 0.1 m
var pierce_count := 0 # m_iPierceCount
var spreading := false # m_bSpreading
var maintain_parallel := false # m_bMaintainParallel(对象目标点 y 抬 0.5 m ≈ 原版 +50cm
var is_homing := false # m_bIsHoming
var homing_start_time := 0.0 # m_fHomingStartTime
var homing_max_angle := 0.0 # m_fHomingMaxAngle(度 / 每次修正)
var hit_on_background := false # m_bHitOnBackground
# 单颗飞行物 —— 对齐 CFlyingInstance。target 可为 Node3D(对象目标,跟随移动)、
# Vector3(位置目标)、或 { "vid": int, "pos": Vector3 }。
class FlyInstance extends RefCounted:
var id := 0
var data: FlyData
var pos := Vector3.ZERO
var last_pos := Vector3.ZERO
var vel := Vector3.ZERO
var accel := Vector3.ZERO
var remain_range := 0.0
var pierce := 0
var alive := false
var can_attack := false
var skill_index := 0
var target_vid := 0
var target_hitted := false
var _target_obj: Node3D
var _target_pos := Vector3.ZERO
var _is_object := false
var _elapsed := 0.0
var _world: Object # 可空:有 sample_height 时做触地
# handler.call(event: String, world_pos: Vector3, vid: int) —— 对齐 IFlyEventHandler
var handler := Callable()
func target_position() -> Vector3:
if _is_object and is_instance_valid(_target_obj):
_target_pos = _target_obj.global_position
var tp := _target_pos
if data and data.maintain_parallel:
tp.y += 0.5
return tp
# CFlyingInstance::Create → __SetDataPointer + __SetTargetDirection。
func create(d: FlyData, start_world: Vector3, target, attack: bool) -> void:
data = d
can_attack = attack
pos = start_world
last_pos = start_world
alive = true
_elapsed = 0.0
target_hitted = false
pierce = d.pierce_count
remain_range = d.flat_range
if target is Node3D:
_is_object = true
_target_obj = target
_target_pos = (target as Node3D).global_position
elif target is Vector3:
_is_object = false
_target_pos = target
elif target is Dictionary:
target_vid = int(target.get("vid", 0))
var tp: Variant = target.get("pos", null)
if tp is Vector3:
_target_pos = tp
var tn: Variant = target.get("node", null)
if tn is Node3D:
_is_object = true
_target_obj = tn
_target_pos = (tn as Node3D).global_position
# __SetTargetDirection:方向 = normalize(target - pos),局部速度 (0,-init_vel,0)
# 旋到该方向即 dir*init_vel;加速度同样旋到该方向。
var dir := (target_position() - pos)
if dir.length() < 0.0001:
dir = Vector3(0, 0, -1)
dir = dir.normalized()
vel = dir * d.init_vel
accel = d.accel
if accel.length_squared() > 0.0:
# 局部帧 -Y → dir 的最小弧旋转(对齐 __SetTargetNormalizedDirection)。
accel = Quaternion(Vector3(0, -1, 0), dir).normalized() * accel
# CFlyingInstance::AdjustDirectionForHoming —— 把速度朝目标方向转,单次夹在
# homing_max_angle 度以内。
func _adjust_homing() -> void:
var tdir := (target_position() - pos)
if tdir.length() < 0.0001:
return
tdir = tdir.normalized()
var vdir := vel.normalized()
var ang := vdir.angle_to(tdir)
if ang < 0.0001:
return
var maxr := deg_to_rad(data.homing_max_angle) if data.homing_max_angle < 180.0 else ang
var t := clampf(maxr / ang, 0.0, 1.0)
var speed := vel.length()
vel = vdir.slerp(tdir, t) * speed
# CFlyingInstance::Update() —— 返 false 表示该销毁(超程 / 命中且不穿透 / 触地)。
func update(dt: float) -> bool:
if not alive:
return false
_elapsed += dt
if data.is_homing and _elapsed > data.homing_start_time and _is_object:
_adjust_homing()
last_pos = pos
vel += accel * dt
vel.y += data.gravity * dt
var movement := vel * dt
var move_dist := movement.length()
remain_range -= move_dist
pos += movement
if remain_range < 0.0:
_emit("out_of_range")
_explode(false)
return false
var br2 := data.bomb_range * data.bomb_range
if _is_object:
if not target_hitted:
if _seg_point_sq_dist(pos, last_pos, target_position()) < br2:
target_hitted = true
if can_attack:
_emit("shoot_damage")
_emit("at_target")
if pierce > 0:
pierce -= 1
_emit("bomb")
else:
_explode()
return false
return true
else:
if _seg_point_sq_dist(pos, last_pos, target_position()) < br2:
_emit("at_position")
_explode()
return false
if data.hit_on_background and _world and _world.has_method("sample_height"):
var gh := float(_world.call("sample_height", pos.x, pos.z))
if gh > pos.y:
_emit("at_background")
_explode()
return false
return true
func _explode(bomb := true) -> void:
if not alive:
return
alive = false
if bomb:
_emit("bomb")
func _emit(ev: String) -> void:
if handler.is_valid():
handler.call(ev, pos, target_vid)
# REF/EterLib/GrpMath.h:80 square_distance_between_linesegment_and_point
static func _seg_point_sq_dist(p1: Vector3, p2: Vector3, x: Vector3) -> float:
var v := p2 - p1
var l := v.length_squared()
var w := x - p1
var d := w.dot(v)
if d <= 0.0:
return w.length_squared()
if d >= l:
return (x - p2).length_squared()
return w.cross(v).length_squared() / l
var parent: Node3D # 弹道 / 爆点可视节点挂这下面(可空 = 纯逻辑,仅回调)
var world: Object # Metin2World(可空,用于 hit_on_background 贴地)
var manual_step := false # true 时 _process 不自动推进(headless 测试用 step() 手动驱动)
var _instances: Array = [] # Array[FlyInstance]
var _visuals := {} # FlyInstance -> MeshInstance3D
var _id_counter := 1
signal shoot_damage(target_vid: int)
signal exploded(world_pos: Vector3, cause: String)
func setup(mount: Node3D, w: Object = null) -> void:
parent = mount
world = w
# CFlyingManager::CreateFlyingInstanceFlyTarget —— 建一颗飞行物并入表。
func spawn(start_world: Vector3, target, can_attack: bool, data: FlyData = null,
skill_index := 0) -> FlyInstance:
var d := data if data != null else FlyData.new()
var inst := FlyInstance.new()
inst.id = _id_counter
_id_counter += 1
inst.skill_index = skill_index
inst._world = world
inst.handler = func(ev: String, wp: Vector3, vid: int) -> void:
_on_instance_event(inst, ev, wp, vid)
inst.create(d, start_world, target, can_attack)
_instances.append(inst)
if parent != null:
var m := MeshInstance3D.new()
var sm := SphereMesh.new()
sm.radius = 0.08
sm.height = 0.16
m.mesh = sm
var mat := StandardMaterial3D.new()
mat.albedo_color = Color(1.0, 0.9, 0.4)
mat.emission_enabled = true
mat.emission = Color(0.9, 0.7, 0.2)
m.material_override = mat
parent.add_child(m)
m.global_position = inst.pos
_visuals[inst] = m
return inst
func active_count() -> int:
return _instances.size()
# CFlyingManager::Update() —— 遍历实例,Update() 返 false 即删除。
func step(dt: float) -> void:
var i := 0
while i < _instances.size():
var inst: FlyInstance = _instances[i]
var keep := inst.update(dt)
var vis: MeshInstance3D = _visuals.get(inst, null)
if is_instance_valid(vis):
vis.global_position = inst.pos
if not keep:
if is_instance_valid(vis):
vis.queue_free()
_visuals.erase(inst)
_instances.remove_at(i)
else:
i += 1
func _process(dt: float) -> void:
if not manual_step:
step(dt)
func _on_instance_event(inst: FlyInstance, ev: String, wp: Vector3, vid: int) -> void:
match ev:
"shoot_damage":
shoot_damage.emit(vid)
"out_of_range", "at_target", "at_position", "at_background", "at_another":
exploded.emit(wp, ev)
if parent != null and ev != "out_of_range":
_spawn_flash(wp)
"bomb":
if parent != null:
_spawn_flash(wp)
func _spawn_flash(wp: Vector3) -> void:
var f := MeshInstance3D.new()
var sm := SphereMesh.new()
sm.radius = 0.05
sm.height = 0.1
f.mesh = sm
var mat := StandardMaterial3D.new()
mat.albedo_color = Color(1.0, 0.75, 0.3, 0.9)
mat.emission_enabled = true
mat.emission = Color(1.0, 0.6, 0.2)
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
f.material_override = mat
parent.add_child(f)
f.global_position = wp
var tw := create_tween()
tw.set_parallel(true)
tw.tween_property(f, "scale", Vector3.ONE * 6.0, 0.25)
tw.tween_property(mat, "albedo_color:a", 0.0, 0.25)
tw.chain().tween_callback(f.queue_free)
+241
View File
@@ -0,0 +1,241 @@
# fly_test —— §3.6 飞行物实体(fly_object.gd / FlyManager / FlyInstance+ net_play._can_shot()
# 的 headless 自检。
# godot --headless --path project --script fly_test.gd
# 退出码 0 = 全过。
extends SceneTree
const FlyObject = preload("res://fly_object.gd")
const NetPlay = preload("res://net_play.gd")
var _fail := 0
func _ck(c: bool, m: String) -> void:
if not c:
_fail += 1
printerr("FAIL: " + m)
func _init() -> void:
await _run()
if _fail == 0:
print("PASS: fly_test (§3.6 CFlyingManager/CFlyingInstance + __CanShot)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
func _mk_mgr() -> Node:
var m := FlyObject.new()
m.manual_step = true
root.add_child(m)
return m
func _node_at(p: Vector3) -> Node3D:
var n := Node3D.new()
root.add_child(n)
n.global_position = p
return n
func _drain(mgr: Node, steps: int, dt: float) -> void:
for i in steps:
if mgr.active_count() == 0:
return
mgr.step(dt)
func _run() -> void:
# SceneTree._init() 跑得比 root 就绪早;等一帧再建 Node3D,避免 !is_inside_tree()。
await process_frame
_test_hit_object()
_test_out_of_range()
_test_can_attack_damage()
_test_pierce()
_test_position_target()
_test_hit_background()
_test_can_shot()
_test_seg_point_dist()
# 直线箭命中对象目标 —— CFlyingInstance::Update 的线段-点判定 < m_fBombRange²。
func _test_hit_object() -> void:
var mgr := _mk_mgr()
var tgt := _node_at(Vector3(10, 1, 0))
var events: Array = []
mgr.exploded.connect(func(wp: Vector3, cause: String): events.append([cause, wp]))
var d = FlyObject.FlyData.new()
d.init_vel = 45.0
d.flat_range = 30.0
d.bomb_range = 0.6
mgr.spawn(Vector3(0, 1, 0), tgt, false, d)
_ck(mgr.active_count() == 1, "hit_object: instance registered")
_drain(mgr, 60, 0.05)
_ck(events.size() == 1 and events[0][0] == "at_target", "hit_object: exploded at_target once")
if events.size() == 1:
_ck((events[0][1] as Vector3).distance_to(Vector3(10, 1, 0)) < 2.0,
"hit_object: explosion near the target")
_ck(mgr.active_count() == 0, "hit_object: instance removed after non-pierce hit")
mgr.free()
tgt.free()
# 超程 —— m_fRemainRange < 0 → OnExplodingOutOfRange + __Explode(false)(不 __Bomb)。
func _test_out_of_range() -> void:
var mgr := _mk_mgr()
var tgt := _node_at(Vector3(100, 1, 0))
var causes: Array = []
var dmg := [0]
mgr.exploded.connect(func(_wp: Vector3, cause: String): causes.append(cause))
mgr.shoot_damage.connect(func(_v: int): dmg[0] += 1)
var d = FlyObject.FlyData.new()
d.init_vel = 45.0
d.flat_range = 5.0
d.bomb_range = 0.6
mgr.spawn(Vector3(0, 1, 0), tgt, true, d)
_drain(mgr, 40, 0.05)
_ck(causes == ["out_of_range"], "out_of_range: exploded out_of_range, no target hit")
_ck(dmg[0] == 0, "out_of_range: no shoot_damage")
_ck(mgr.active_count() == 0, "out_of_range: instance removed")
mgr.free()
tgt.free()
# canAttack=true 且对象目标 → OnShootDamage(本 POC 转成 shoot_damage(vid) 信号)。
func _test_can_attack_damage() -> void:
var mgr := _mk_mgr()
var tgt := _node_at(Vector3(5, 1, 0))
var hits: Array = []
mgr.shoot_damage.connect(func(v: int): hits.append(v))
var d = FlyObject.FlyData.new()
d.init_vel = 45.0
d.flat_range = 20.0
d.bomb_range = 0.6
mgr.spawn(Vector3(0, 1, 0), {"vid": 4242, "node": tgt}, true, d)
_drain(mgr, 40, 0.05)
_ck(hits == [4242], "can_attack: shoot_damage(4242) exactly once")
mgr.free()
tgt.free()
# 穿透 —— m_iPierceCount>0 时命中后 --、__Bomb()、继续飞(Update 返 true),最终超程。
func _test_pierce() -> void:
var mgr := _mk_mgr()
var tgt := _node_at(Vector3(6, 1, 0))
var causes: Array = []
mgr.exploded.connect(func(_wp: Vector3, cause: String): causes.append(cause))
var d = FlyObject.FlyData.new()
d.init_vel = 45.0
d.flat_range = 15.0
d.bomb_range = 0.6
d.pierce_count = 1
mgr.spawn(Vector3(0, 1, 0), tgt, false, d)
# 命中前几步:命中那一步实例仍存活(pierce 消耗),不立即移除
var seen_alive_after_hit := false
for i in 60:
mgr.step(0.05)
if causes.size() == 1 and causes[0] == "at_target" and mgr.active_count() == 1:
seen_alive_after_hit = true
if mgr.active_count() == 0:
break
_ck(seen_alive_after_hit, "pierce: instance survives the first hit")
_ck(causes == ["at_target", "out_of_range"], "pierce: at_target then out_of_range")
mgr.free()
tgt.free()
# 位置目标 —— IsPosition 分支同样线段判定 → __Explode()。
func _test_position_target() -> void:
var mgr := _mk_mgr()
var causes: Array = []
mgr.exploded.connect(func(_wp: Vector3, cause: String): causes.append(cause))
var d = FlyObject.FlyData.new()
d.init_vel = 45.0
d.flat_range = 30.0
d.bomb_range = 0.6
mgr.spawn(Vector3(0, 1, 0), Vector3(8, 1, 0), false, d)
_drain(mgr, 40, 0.05)
_ck(causes == ["at_position"], "position_target: exploded at_position")
mgr.free()
# hit_on_background —— GetTerrainHeight(x,-y) > z(本 POCworld.sample_height(x,z) > pos.y)。
func _test_hit_background() -> void:
var mgr := _mk_mgr()
var world := _FakeWorld.new()
world.h = 0.9
mgr.world = world
var causes: Array = []
mgr.exploded.connect(func(_wp: Vector3, cause: String): causes.append(cause))
var d = FlyObject.FlyData.new()
d.init_vel = 45.0
d.flat_range = 60.0
d.bomb_range = 0.05
d.gravity = -20.0 # 向下拉,几步内 y 跌破地面高度
d.hit_on_background = true
mgr.spawn(Vector3(0, 1, 0), Vector3(40, 1, 0), false, d)
_drain(mgr, 40, 0.05)
_ck(causes.size() == 1 and causes[0] == "at_background", "hit_background: exploded at_background")
_ck(mgr.active_count() == 0, "hit_background: instance removed")
mgr.free()
# net_play._can_shot() —— __CanShot 防御式重建(弓模式 && 有箭 && 有可攻击目标 && 在弓程内)。
func _test_can_shot() -> void:
var fc := _FakeClient.new()
get_root().add_child(fc)
var np: Node = NetPlay.new()
get_root().add_child(np)
np.client = fc
np.net_world = null
np._main_vid = 1
fc.ents[1] = {"vid": 1, "pos": Vector3.ZERO, "hp": 100, "max_hp": 100}
np.combo_motion_mode = np.MOTION_MODE_GENERAL
_ck(np._can_shot() == "NOT_BOW_MODE", "can_shot: non-bow -> NOT_BOW_MODE")
np.combo_motion_mode = np.MOTION_MODE_BOW
np._arrow_count = 0
_ck(np._can_shot() == "EMPTY_ARROW", "can_shot: bow + 0 arrows -> EMPTY_ARROW")
np._arrow_count = 5
np._target_vid = 0
np._vid_reserved = 0
_ck(np._can_shot() == "NO_TARGET", "can_shot: bow + arrows + no target -> NO_TARGET")
np._arrow_count = -1 # 未跟踪 = 无限
fc.ents[900] = {"vid": 900, "ch_type": 2, "pos": Vector3(5, 0, 0), "hp": 30, "max_hp": 30, "dead": false}
np._target_vid = 900
_ck(np._can_shot() == "", "can_shot: bow + target in range -> OK")
fc.ents[901] = {"vid": 901, "ch_type": 2, "pos": Vector3(400, 0, 0), "hp": 30, "max_hp": 30, "dead": false}
np._target_vid = 901
_ck(np._can_shot() == "OUT_OF_RANGE", "can_shot: target beyond __GetBowRange -> OUT_OF_RANGE")
fc.ents[902] = {"vid": 902, "ch_type": 2, "pos": Vector3(3, 0, 0), "hp": 0, "max_hp": 30, "dead": true}
np._target_vid = 902
_ck(np._can_shot() == "NO_TARGET", "can_shot: dead target -> NO_TARGET")
np.free()
fc.free()
func _test_seg_point_dist() -> void:
var f = FlyObject.FlyInstance
# 垂足在段内:点到直线距离
_ck(is_equal_approx(f._seg_point_sq_dist(Vector3(0, 0, 0), Vector3(10, 0, 0), Vector3(5, 3, 0)), 9.0),
"seg_dist: foot inside segment -> perpendicular²")
# 垂足在 p1 外:取端点 p1
_ck(is_equal_approx(f._seg_point_sq_dist(Vector3(0, 0, 0), Vector3(10, 0, 0), Vector3(-4, 0, 0)), 16.0),
"seg_dist: past p1 -> |x-p1|²")
# 垂足在 p2 外:取端点 p2
_ck(is_equal_approx(f._seg_point_sq_dist(Vector3(0, 0, 0), Vector3(10, 0, 0), Vector3(13, 4, 0)), 25.0),
"seg_dist: past p2 -> |x-p2|²")
class _FakeWorld extends RefCounted:
var h := 0.0
func sample_height(_x: float, _z: float) -> float:
return h
class _FakeClient extends Node:
signal entity_spawned(entity: Dictionary)
signal entity_despawned(vid: int)
signal entity_moved(vid: int)
signal entity_main_set(vid: int)
signal entity_dead(vid: int)
signal vitals_changed(vid: int)
signal points_changed(points: Dictionary)
signal chat(type: int, vid: int, text: String)
signal damage(vid: int, amount: int, flag: int)
var ents := {}
func is_in_game() -> bool: return true
func get_main_vid() -> int: return 1
func get_entity(vid: int) -> Dictionary: return ents.get(vid, {})
func get_entities() -> Array: return ents.values()
+543 -35
View File
@@ -6,15 +6,23 @@
# fx.play() # 或 fx.play(true) 一次性
#
# 覆盖 Particle 组:发射形状 / 速率 / 寿命 / 方向 / 重力 / 缩放曲线 / 颜色渐变 /
# billboard / 加法混合 / 旋转。纹理(.dds)暂用程序化径向渐变代替(GDScript 不解 DDS
# Mesh 组:占位(MeshFileName 是 .mde,另需解码器)。SimpleLight:本资产集未用
# billboard / 加法混合 / 旋转;粒子 TextureFiles 通过 UiAssets + C++ DDS 解码加载
# Mesh 组:读取 ClientVS22 的 .mde v1/v2、展开 TriangleList、播放几何帧与 IFL 贴图
# SimpleLight:按参考的范围曲线 / 生命周期创建 OmniLight3D;现有资产集仍缺少逐项视觉样本。
extends Node3D
const UiAssets = preload("res://ui/ui_assets.gd")
const Mde = preload("res://fx/mde.gd")
var spec := {}
var assets_root := ""
var one_shot := false
var _emitters: Array[GPUParticles3D] = []
var _particle_states: Array[Dictionary] = []
var _mesh_nodes: Array[Node3D] = []
var _mesh_states: Array[Dictionary] = []
var _lights: Array[OmniLight3D] = []
var _light_states: Array[Dictionary] = []
var _bsphere_r := 0.0
static var _glow_tex: Texture2D
@@ -33,14 +41,32 @@ func build(mse_spec: Dictionary, assets := "") -> void:
if mn:
add_child(mn)
_mesh_nodes.append(mn)
for l in spec.get("lights", []):
var light := _build_light(l)
if light:
add_child(light)
_lights.append(light)
func _process(delta: float) -> void:
for state in _particle_states:
_advance_particle(state, delta)
for state in _mesh_states:
_advance_mesh(state, delta)
for state in _light_states:
_advance_light(state, delta)
func play(force_one_shot := false) -> void:
# EffectPlayer can be reused by the registry. Reset the CPU-side clocks as
# well as the GPU emitters so cycle gates do not inherit the previous play.
for state in _particle_states:
state["clock"] = 0.0
state["emission_stopped"] = false
for i in _emitters.size():
var e := _emitters[i]
var st := float(_emitters[i].get_meta("start_time", 0.0))
if force_one_shot:
e.one_shot = true
var tree := get_tree()
var tree: SceneTree = get_tree() if is_inside_tree() else null
if st <= 0.0 or tree == null:
e.restart()
e.emitting = true
@@ -49,7 +75,7 @@ func play(force_one_shot := false) -> void:
if is_instance_valid(e):
e.restart()
e.emitting = true)
var tr := get_tree()
var tr: SceneTree = get_tree() if is_inside_tree() else null
if (force_one_shot or one_shot) and tr:
var total := _longest_life() + 0.5
tr.create_timer(maxf(total, 1.5)).timeout.connect(queue_free)
@@ -57,11 +83,24 @@ func play(force_one_shot := false) -> void:
func stop() -> void:
for e in _emitters:
e.emitting = false
for light in _lights:
light.visible = false
func _longest_life() -> float:
var m := 1.0
for e in _emitters:
m = maxf(m, e.lifetime + float(e.get_meta("start_time", 0.0)))
var start := float(e.get_meta("start_time", 0.0))
var cycle := float(e.get_meta("cycle_length", 0.0))
var loop := bool(e.get_meta("cycle_loop", false))
var loop_count := int(e.get_meta("loop_count", 0))
var emission_window := 0.0
if cycle > 0.0 and (not loop or loop_count > 0):
emission_window = cycle * (loop_count if loop else 1)
m = maxf(m, start + emission_window + e.lifetime)
for state in _mesh_states:
m = maxf(m, float(state.get("duration", 0.0)))
for state in _light_states:
m = maxf(m, float(state.get("duration", 0.0)))
return m
# --- particle ---------------------------------------------------------
@@ -71,31 +110,68 @@ func _build_particle(p: Dictionary) -> GPUParticles3D:
var prop: Dictionary = p.get("particle", {})
var g := GPUParticles3D.new()
g.set_meta("start_time", float(p.get("start_time", 0.0)))
g.set_meta("emitter_advanced_type", int(_n(emit.get("EmitterAdvancedType", 0))))
g.set_meta("emitter_emit_from_edge", int(_n(emit.get("EmitterEmitFromEdgeFlag", 0))) != 0)
var positions: Array = p.get("position", [])
if not positions.is_empty() and positions[0] is Array and positions[0].size() >= 5:
# EffectLib stores effect coordinates in centimetres; the Godot scene is
# metres. The position curve is evaluated again as the effect advances.
g.position = Vector3(float(positions[0][2]), float(positions[0][3]),
float(positions[0][4])) * 0.01
g.amount = maxi(1, int(_n(emit.get("MaxEmissionCount", 16))))
g.explosiveness = 0.0
var life := _last_val(emit.get("TimeEventLifeTime", []), 1.0)
g.lifetime = clampf(life, 0.05, 12.0)
var cycle := _n(emit.get("CycleLength", 0.0))
# ParticleSystemData.cpp supplies 0.05 when CycleLength is absent. Keep an
# explicit zero distinct: it means the source does not request a cycle gate.
var cycle := _n(emit.get("CycleLength", 0.0)) if emit.has("CycleLength") else 0.05
var loop := int(_n(emit.get("CycleLoopEnable", 0)))
var loop_count := int(_n(emit.get("LoopCount", 0)))
g.one_shot = (loop == 0)
if cycle > 0.0 and loop != 0:
g.lifetime = maxf(g.lifetime, cycle)
g.set_meta("cycle_length", maxf(cycle, 0.0))
g.set_meta("cycle_loop", loop != 0)
g.set_meta("loop_count", maxi(loop_count, 0))
g.set_meta("particle_lifetime", g.lifetime)
g.set_meta("emission_stopped", false)
var pm := ParticleProcessMaterial.new()
# 发射形状
var shape := int(_n(emit.get("EmitterShape", 0)))
if shape == 3:
pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE
pm.emission_sphere_radius = maxf(0.01, _n(emit.get("EmittingRadius", 10.0)) * 0.01)
else:
pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_POINT
var emit_from_edge := int(_n(emit.get("EmitterEmitFromEdgeFlag", 0))) != 0
var radius := maxf(0.0, _n(emit.get("EmittingRadius", 10.0))) * 0.01
var emitting_size := _vec3(emit.get("EmittingSize", [0.0, 0.0, 0.0])) * 0.01
match shape:
1: # CEmitterProperty::EMITTER_SHAPE_ELLIPSE (flat box approximation)
pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX
pm.emission_box_extents = Vector3(maxf(radius, 0.01), maxf(radius, 0.01), 0.005)
2: # CEmitterProperty::EMITTER_SHAPE_SQUARE
pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX
pm.emission_box_extents = Vector3(maxf(absf(emitting_size.x) * 0.5, 0.005),
maxf(absf(emitting_size.y) * 0.5, 0.005), maxf(absf(emitting_size.z) * 0.5, 0.005))
3: # CEmitterProperty::EMITTER_SHAPE_SPHERE
pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE_SURFACE if emit_from_edge else ParticleProcessMaterial.EMISSION_SHAPE_SPHERE
pm.emission_sphere_radius = maxf(0.01, radius)
_:
pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_POINT
# 方向 / 速度
var vel := _last_val(emit.get("TimeEventEmittingVelocity", []), 0.0) * 0.01
pm.direction = Vector3(0, 1, 0)
pm.spread = 25.0
var direction := _vec3(emit.get("EmittingDirection", [0.0, 0.0, 0.0]))
direction += Vector3(_last_val(emit.get("TimeEventEmittingDirectionX", []), 0.0),
_last_val(emit.get("TimeEventEmittingDirectionY", []), 0.0),
_last_val(emit.get("TimeEventEmittingDirectionZ", []), 0.0))
pm.direction = direction.normalized() if direction.length_squared() > 0.000001 else Vector3(0, 1, 0)
pm.spread = 25.0 if direction.length_squared() <= 0.000001 else 0.0
# The reference computes OUTER/INNER velocity from each particle's radial
# position. ParticleProcessMaterial has no radial-velocity primitive, so keep
# the source mode visible and use a broad directional fallback until the
# particle-shader path is implemented. This avoids silently treating those
# modes as FREE while preserving the normal velocity range and gravity.
var advanced_type := int(g.get_meta("emitter_advanced_type", 0))
if advanced_type == 1 or advanced_type == 2:
pm.spread = 180.0
pm.initial_velocity_min = vel * 0.6
pm.initial_velocity_max = maxf(vel, 0.05)
pm.initial_velocity_max = vel
# 重力
var grav := _last_val(p.get("particle", {}).get("TimeEventGravity", []), 0.0)
pm.gravity = Vector3(0, -grav * 0.01, 0)
@@ -109,9 +185,16 @@ func _build_particle(p: Dictionary) -> GPUParticles3D:
var ramp := _alpha_ramp(prop.get("TimeEventAlpha", []), pm.color)
if ramp:
pm.color_ramp = ramp
# 旋转
# 旋转ParticleProperty 的 0=NONE、1=TIME_EVENT、2=CW、3=CCW、
# 4=RANDOM_DIRECTION。Godot 没有完全相同的每粒子时间表,先保留方向和
# 时间表末值,避免把 NONE 误当成恒速旋转。
var rotation_type := int(_n(prop.get("RotationType", 0)))
var rspeed := _n(prop.get("RotationSpeed", 0.0))
if rspeed != 0.0:
if rotation_type == 1:
rspeed = _last_val(prop.get("TimeEventRotation", []), rspeed)
if rotation_type == 3:
rspeed = -rspeed
if rotation_type != 0 and rspeed != 0.0:
pm.angular_velocity_min = deg_to_rad(rspeed) * 0.3
pm.angular_velocity_max = deg_to_rad(rspeed)
g.process_material = pm
@@ -126,26 +209,407 @@ func _build_particle(p: Dictionary) -> GPUParticles3D:
mat.billboard_keep_scale = true
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.blend_mode = _blend(int(_n(prop.get("SrcBlendType", 5))), int(_n(prop.get("DestBlendType", 2))))
mat.albedo_texture = _glow()
var tex := _particle_texture(prop)
mat.albedo_texture = tex if tex else _glow()
mat.vertex_color_use_as_albedo = true
g.material_override = mat
_particle_states.append({
"node": g,
"rows": positions,
"clock": 0.0,
"start_time": float(p.get("start_time", 0.0)),
"cycle_length": maxf(cycle, 0.0),
"cycle_loop": loop != 0,
"loop_count": maxi(loop_count, 0),
"emission_stopped": false,
})
return g
func _advance_particle(state: Dictionary, delta: float) -> void:
var g: GPUParticles3D = state.get("node")
if g == null or not is_instance_valid(g):
return
var rows: Array = state.get("rows", [])
if rows.is_empty():
return
var clock := float(state.get("clock", 0.0)) + maxf(delta, 0.0)
state["clock"] = clock
var start := float(state.get("start_time", 0.0))
if clock < start:
g.emitting = false
return
var local := clock - start
var cycle := float(state.get("cycle_length", 0.0))
var loop := bool(state.get("cycle_loop", false))
var loop_count := int(state.get("loop_count", 0))
var emission_finished := cycle > 0.0 and ((not loop and local >= cycle) or
(loop and loop_count > 0 and local >= cycle * loop_count))
if emission_finished:
g.emitting = false
state["emission_stopped"] = true
return
if not bool(state.get("emission_stopped", false)):
g.position = _position_at(rows, local)
func _build_mesh(m: Dictionary) -> Node3D:
# .mde 需要专门解码器;先占位一个小 box,位置对
var mde_path := _resolve_mde(String(m.get("mesh_file", "")))
if mde_path == "":
return null
var mesh_data := Mde.new().parse_file(mde_path)
if mesh_data.is_empty():
return null
var n := Node3D.new()
n.name = "mesh_" + String(m.get("mesh_file", "?")).get_basename()
var mi := MeshInstance3D.new()
var bm := BoxMesh.new()
bm.size = Vector3(0.1, 0.1, 0.4)
mi.mesh = bm
var mat := StandardMaterial3D.new()
mat.albedo_color = Color(1, 0.9, 0.5, 0.6)
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mi.material_override = mat
n.add_child(mi)
var positions: Array = m.get("position", [])
if not positions.is_empty() and positions[0] is Array and positions[0].size() >= 5:
n.position = Vector3(float(positions[0][2]), float(positions[0][3]), float(positions[0][4])) * 0.01
var geometries: Array = mesh_data.get("geometries", [])
var elements: Array = m.get("elements", [])
var states: Array[Dictionary] = []
for geometry_index in geometries.size():
var geometry: Dictionary = geometries[geometry_index]
var frames: Array = geometry.get("frames", [])
if frames.is_empty():
continue
var meshes: Array = []
for frame in frames:
var array_mesh := _array_mesh(frame)
meshes.append(array_mesh)
var first_mesh: ArrayMesh = null
for candidate in meshes:
if candidate is ArrayMesh:
first_mesh = candidate
break
if first_mesh == null:
continue
var mi := MeshInstance3D.new()
mi.name = String(geometry.get("name", "geometry_%d" % geometry_index))
mi.mesh = first_mesh
var element: Dictionary = elements[geometry_index] if geometry_index < elements.size() else {}
var textures := _load_mesh_textures(mde_path, String(geometry.get("diffuse", "")))
mi.material_override = _mesh_material(element, textures)
n.add_child(mi)
states.append({
"node": mi,
"frames": frames,
"meshes": meshes,
"element": element,
"textures": textures,
"frame": -1,
"texture_frame": -1,
})
var frame_delay := maxf(float(m.get("frame_delay", 0.02)), 0.0001)
var frame_count := int(mesh_data.get("frame_count", 0))
var loop_count := int(m.get("loop_count", 0))
var loops := loop_count if loop_count > 0 else 1
var duration := float(m.get("start_time", 0.0)) + frame_count * frame_delay * loops
var state := {
"node": n,
"children": states,
"position": positions,
"clock": 0.0,
"start_time": float(m.get("start_time", 0.0)),
"frame_delay": frame_delay,
"loop": int(m.get("loop", 0)) != 0,
"loop_count": loop_count,
"duration": maxf(duration, frame_delay),
}
_mesh_states.append(state)
n.set_meta("mde_path", mde_path)
n.set_meta("mde_version", int(mesh_data.get("version", 0)))
n.set_meta("mesh_states", states)
n.visible = false
return n
func _build_light(l: Dictionary) -> OmniLight3D:
var light := OmniLight3D.new()
light.name = "simple_light"
var positions: Array = l.get("position", [])
if not positions.is_empty() and positions[0] is Array and positions[0].size() >= 5:
light.position = Vector3(float(positions[0][2]), float(positions[0][3]), float(positions[0][4])) * 0.01
var diffuse: Array = l.get("diffuse", [0.0, 0.0, 0.0, 1.0])
if diffuse.size() >= 4:
light.light_color = Color(float(diffuse[0]), float(diffuse[1]), float(diffuse[2]), float(diffuse[3]))
var attenuation := float(l.get("attenuation1", 0.1)) + float(l.get("attenuation2", 0.0))
light.omni_attenuation = clampf(1.0 / maxf(1.0, 1.0 + attenuation * 10.0), 0.01, 1.0)
light.visible = false
var duration := maxf(float(l.get("duration", 1.0)), 0.0001)
var loop_count := int(l.get("loop_count", 0))
var loops := loop_count if loop_count > 0 else 1
var state := {
"node": light,
"data": l,
"clock": 0.0,
"start_time": float(l.get("start_time", 0.0)),
"duration": float(l.get("start_time", 0.0)) + duration * loops,
"loop": int(l.get("loop", 0)) != 0,
"loop_count": loop_count,
}
_light_states.append(state)
light.set_meta("simple_light", true)
light.set_meta("ambient", l.get("ambient", []))
light.set_meta("max_range_cm", float(l.get("max_range", 300.0)))
return light
func _advance_light(state: Dictionary, delta: float) -> void:
var light: OmniLight3D = state.get("node")
if light == null or not is_instance_valid(light):
return
var clock := float(state.get("clock", 0.0)) + maxf(delta, 0.0)
state["clock"] = clock
var start := float(state.get("start_time", 0.0))
if clock < start:
light.visible = false
return
var data: Dictionary = state.get("data", {})
var duration := maxf(float(data.get("duration", 1.0)), 0.0001)
var local := clock - start
var loop := bool(state.get("loop", false))
var loop_count := int(state.get("loop_count", 0))
if loop:
if loop_count > 0 and local >= duration * loop_count:
light.visible = false
return
local = fmod(local, duration)
elif local >= duration:
light.visible = false
return
light.visible = true
light.position = _position_at(data.get("position", []), local)
var range_ratio := clampf(_event_value(data.get("range", []), local, 1.0), 0.0, 1.0)
light.omni_range = maxf(0.01, float(data.get("max_range", 300.0)) * range_ratio * 0.01)
var diffuse: Array = data.get("diffuse", [0.0, 0.0, 0.0, 1.0])
if diffuse.size() >= 4:
var color := light.light_color
color.a = float(diffuse[3])
light.light_color = color
func _advance_mesh(state: Dictionary, delta: float) -> void:
var n: Node3D = state.get("node")
if n == null or not is_instance_valid(n):
return
var clock := float(state.get("clock", 0.0)) + maxf(delta, 0.0)
state["clock"] = clock
var start := float(state.get("start_time", 0.0))
if clock < start:
n.visible = false
return
var local := clock - start
n.position = _position_at(state.get("position", []), local)
var children: Array = state.get("children", [])
if children.is_empty():
return
var frame_delay := maxf(float(state.get("frame_delay", 0.02)), 0.0001)
var frame_count := 0
for child in children:
frame_count = maxi(frame_count, Array(child.get("frames", [])).size())
if frame_count < 1:
return
var loop := bool(state.get("loop", false))
var loop_count := int(state.get("loop_count", 0))
var frame_number := int(floor(local / frame_delay))
if loop:
var total_frames := frame_count * (loop_count if loop_count > 0 else 1)
if loop_count > 0 and frame_number >= total_frames:
n.visible = false
return
frame_number = frame_number % frame_count
else:
if frame_number >= frame_count:
n.visible = false
return
n.visible = true
for child in children:
_set_mesh_child_frame(child, frame_number, local)
func _set_mesh_child_frame(state: Dictionary, frame_number: int, local: float) -> void:
var frames: Array = state.get("frames", [])
var meshes: Array = state.get("meshes", [])
var mi: MeshInstance3D = state.get("node")
if mi == null or frame_number < 0 or frame_number >= frames.size() or frame_number >= meshes.size():
return
if int(state.get("frame", -1)) != frame_number:
if meshes[frame_number] is ArrayMesh:
mi.mesh = meshes[frame_number]
state["frame"] = frame_number
var frame: Dictionary = frames[frame_number]
var visibility := clampf(float(frame.get("visibility", 1.0)), 0.0, 1.0)
var element: Dictionary = state.get("element", {})
var alpha := _event_value(element.get("TimeEventAlpha", []), local, 1.0)
var mat := mi.material_override as StandardMaterial3D
if mat:
var color := mat.albedo_color
color.a = clampf(float(element.get("_base_alpha", color.a)) * visibility * alpha, 0.0, 1.0)
mat.albedo_color = color
var textures: Array = state.get("textures", [])
if textures.size() > 0 and mat:
var texture_delay := maxf(float(element.get("TextureAnimationFrameDelay", 0.02)), 0.0001)
var texture_frame := int(element.get("TextureAnimationStartFrame", 0)) + int(floor(local / texture_delay))
if int(element.get("TextureAnimationLoopEnable", 1)) != 0:
texture_frame = texture_frame % textures.size()
else:
texture_frame = mini(texture_frame, textures.size() - 1)
if int(state.get("texture_frame", -1)) != texture_frame:
mat.albedo_texture = textures[texture_frame]
state["texture_frame"] = texture_frame
func _array_mesh(frame: Dictionary) -> ArrayMesh:
var vertices: PackedVector3Array = frame.get("vertices", PackedVector3Array())
var uvs: PackedVector2Array = frame.get("uvs", PackedVector2Array())
var count := mini(vertices.size(), uvs.size())
count -= count % 3
if count < 3:
return null
if count != vertices.size():
vertices = vertices.slice(0, count)
uvs = uvs.slice(0, count)
var arrays := []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = vertices
arrays[Mesh.ARRAY_TEX_UV] = uvs
var mesh := ArrayMesh.new()
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
return mesh
func _mesh_material(element: Dictionary, textures: Array) -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.billboard_mode = _billboard(int(_n(element.get("BillboardType", 0))))
mat.blend_mode = _blend(int(_n(element.get("BlendingSrcType", 5))), int(_n(element.get("BlendingDestType", 2))))
var cf = element.get("ColorFactor", [1.0, 1.0, 1.0, 1.0])
var color := Color(1, 1, 1, 1)
if cf is Array and cf.size() >= 4:
color = Color(float(cf[0]), float(cf[1]), float(cf[2]), float(cf[3]))
element["_base_alpha"] = color.a
mat.albedo_color = color
mat.albedo_texture = textures[0] if textures.size() > 0 else _glow()
return mat
func _load_mesh_textures(mde_path: String, diffuse: String) -> Array:
var out: Array = []
if diffuse == "":
return out
var mde_dir := mde_path.get_base_dir()
var diffuse_name := diffuse.replace("\\", "/").get_file()
var diffuse_path := mde_dir.path_join(diffuse_name)
if not FileAccess.file_exists(diffuse_path):
diffuse_path = _resolve_asset_file(diffuse)
if diffuse_path != "" and diffuse_path.get_extension().to_lower() == "ifl":
for line in FileAccess.get_file_as_string(diffuse_path).split("\n"):
var name := line.strip_edges().trim_prefix("\"").trim_suffix("\"")
if name == "" or name.begins_with("#"):
continue
var tex := UiAssets.load_tex(diffuse_path.get_base_dir(), name)
if tex:
out.append(tex)
else:
var tex := UiAssets.load_tex(mde_dir, diffuse_name)
if tex == null:
tex = UiAssets.load_tex(assets_root, diffuse)
if tex:
out.append(tex)
return out
func _resolve_mde(file_name: String) -> String:
if file_name == "":
return ""
var rel := file_name.replace("\\", "/")
var mse_dir := String(spec.get("dir", ""))
var sibling := mse_dir.path_join(rel.get_file()) if mse_dir != "" else ""
if sibling != "" and FileAccess.file_exists(sibling):
return sibling
var direct := assets_root.path_join(rel.lstrip("/")) if assets_root != "" else ""
if direct != "" and FileAccess.file_exists(direct):
return direct
return _scan_for_mde(assets_root, rel.get_file(), 8) if assets_root != "" else ""
func _resolve_asset_file(file_name: String) -> String:
var base := file_name.replace("\\", "/").get_file()
var mse_dir := String(spec.get("dir", ""))
var sibling := mse_dir.path_join(base) if mse_dir != "" else ""
if sibling != "" and FileAccess.file_exists(sibling):
return sibling
return _scan_for_mde(assets_root, base, 8) if assets_root != "" else ""
func _scan_for_mde(dir: String, basename: String, depth: int) -> String:
if dir == "" or depth < 0:
return ""
var da := DirAccess.open(dir)
if da == null:
return ""
for file in da.get_files():
if file.to_lower() == basename.to_lower():
return dir.path_join(file)
for sub in da.get_directories():
if sub.begins_with("."):
continue
var result := _scan_for_mde(dir.path_join(sub), basename, depth - 1)
if result != "":
return result
return ""
func _position_at(rows, time: float) -> Vector3:
# CEffectElementBase::GetPosition: each row's moving type describes the
# segment starting at that row. Bezier uses P0 + controlPoint as its middle
# control vertex, then keeps the result in the effect's centimetre space.
if not rows is Array or rows.is_empty():
return Vector3.ZERO
var first = rows[0]
if not first is Array or first.size() < 5:
return Vector3.ZERO
if time <= float(first[0]):
return _position_vector(first) * 0.01
for i in range(1, rows.size()):
var current = rows[i]
if not current is Array or current.size() < 5:
continue
var previous = rows[i - 1]
if not previous is Array or previous.size() < 5:
return _position_vector(current) * 0.01
var t0 := float(previous[0])
var t1 := float(current[0])
if time > t1:
continue
var t := 1.0 if is_zero_approx(t1 - t0) else clampf((time - t0) / (t1 - t0), 0.0, 1.0)
var p0 := _position_vector(previous)
var p1 := _position_vector(current)
if String(previous[1]) == "MOVING_TYPE_BEZIER_CURVE" and previous.size() >= 8:
var control := Vector3(float(previous[5]), float(previous[6]), float(previous[7]))
var middle := p0 + control
var inv := 1.0 - t
return (p0 * inv * inv + middle * 2.0 * inv * t + p1 * t * t) * 0.01
return p0.lerp(p1, t) * 0.01
var last = rows[-1]
return _position_vector(last) * 0.01 if last is Array and last.size() >= 5 else Vector3.ZERO
func _position_vector(row) -> Vector3:
if row is Array and row.size() >= 5:
return Vector3(float(row[2]), float(row[3]), float(row[4]))
return Vector3.ZERO
func _event_value(rows, time: float, fallback: float) -> float:
if not rows is Array or rows.is_empty():
return fallback
var previous = rows[0]
if previous is Array and previous.size() >= 2 and time <= float(previous[0]):
return float(previous[1])
for row in rows:
if not row is Array or row.size() < 2:
continue
var x := float(row[0])
if time <= x:
var x0 := float(previous[0])
var y0 := float(previous[1])
var y1 := float(row[1])
var t := 1.0 if is_zero_approx(x - x0) else clampf((time - x0) / (x - x0), 0.0, 1.0)
return lerpf(y0, y1, t)
previous = row
return float(previous[1]) if previous is Array and previous.size() >= 2 else fallback
# --- helpers ---------------------------------------------------------
func _n(v) -> float:
@@ -155,6 +619,46 @@ func _n(v) -> float:
return float(v)
return 0.0
func _vec3(v) -> Vector3:
if v is Array and v.size() >= 3:
return Vector3(float(v[0]), float(v[1]), float(v[2]))
return Vector3.ZERO
func _particle_texture(prop: Dictionary) -> Texture2D:
var rows: Variant = prop.get("TextureFiles", [])
if not rows is Array or rows.is_empty():
return null
# ParticleSystemData resolves relative texture names against the .mse
# directory. UiAssets keeps the same behavior and uses the native DDS path.
var mse_dir := String(spec.get("dir", ""))
var textures: Array[Texture2D] = []
for row in rows:
var file := String(row[0]) if row is Array and not row.is_empty() else String(row)
if file == "":
continue
var tex: Texture2D = UiAssets.load_tex(mse_dir, file) if mse_dir != "" else null
if tex == null:
tex = UiAssets.load_tex(assets_root, file)
if tex:
textures.append(tex)
if textures.is_empty():
return null
var animation_type := int(_n(prop.get("TexAniType", 0)))
if textures.size() == 1 or animation_type == 0:
return textures[0]
# AnimatedTexture is shared by the draw pass, unlike the original per-particle
# frame index. This preserves the real frame order and delay for the common
# multi-file case; random-start variants remain deterministic until the GPU
# particle custom-data path is added.
if animation_type == 2: # TEXTURE_ANIMATION_TYPE_CCW
textures.reverse()
var animated := AnimatedTexture.new()
animated.fps = 1.0 / maxf(_n(prop.get("TexAniDelay", 0.05)), 0.001)
animated.frames = mini(textures.size(), 256)
for i in animated.frames:
animated.set_frame_texture(i, textures[i])
return animated
# List 表末行的值(列 idx,默认 col 1 = 时间后第一个数)
func _last_val(rows, def: float, col := 1) -> float:
if rows is Array and rows.size() > 0:
@@ -190,8 +694,6 @@ func _alpha_ramp(rows, base: Color) -> GradientTexture1D:
if not (rows is Array) or rows.size() < 1:
return null
var grad := Gradient.new()
grad.offsets = PackedFloat32Array()
grad.colors = PackedColorArray()
var pts := []
for r in rows:
if r is Array and r.size() >= 2:
@@ -203,10 +705,16 @@ func _alpha_ramp(rows, base: Color) -> GradientTexture1D:
pts.push_front([0.0, pts[0][1]])
if pts[-1][0] < 1.0:
pts.append([1.0, pts[-1][1]])
for pt in pts:
grad.add_point(pt[0], Color(base.r, base.g, base.b, clampf(pt[1], 0, 1)))
grad.remove_point(0)
grad.remove_point(0)
if pts.size() == 1:
pts.append([minf(1.0, pts[0][0] + 0.001), pts[0][1]])
grad.offsets = PackedFloat32Array([float(pts[0][0]), float(pts[-1][0])])
grad.colors = PackedColorArray([
Color(base.r, base.g, base.b, clampf(float(pts[0][1]), 0, 1)),
Color(base.r, base.g, base.b, clampf(float(pts[-1][1]), 0, 1)),
])
for i in range(1, pts.size() - 1):
var pt = pts[i]
grad.add_point(float(pt[0]), Color(base.r, base.g, base.b, clampf(float(pt[1]), 0, 1)))
var gt := GradientTexture1D.new()
gt.gradient = grad
return gt
+197
View File
@@ -0,0 +1,197 @@
# Mde (P5) —— ClientVS22 EffectMesh 的 .mde 二进制读取器。
#
# 40250/ClientVS22/source/EffectLib/EffectMesh.cpp 支持两种布局:
# EffectData —— v1:每个几何体固定顶点/索引/UV 数量,每帧只有 visibility
# MDEData002 —— v2:每帧带 changed/visibility 以及三组数量。
# 参考端把索引顶点展开成 TPTVertex,并把 UV.y 取反;这里同样展开,
# 同时把客户端厘米坐标换成 Godot 的米坐标。
extends RefCounted
const HEADER_SIZE := 11
const GEOMETRY_HEADER_SIZE := 160 # object name 32 + diffuse path 128
const MAX_GEOMETRIES := 256
const MAX_FRAMES := 4096
const MAX_ELEMENTS := 1_000_000
var last_error := ""
func parse_file(path: String) -> Dictionary:
last_error = ""
if not FileAccess.file_exists(path):
last_error = "no such file: " + path
return {}
return parse_bytes(FileAccess.get_file_as_bytes(path))
func parse_bytes(data: PackedByteArray) -> Dictionary:
last_error = ""
if data.size() < HEADER_SIZE + 8:
last_error = "truncated header"
return {}
var header := _fixed_string(data, 0, HEADER_SIZE)
var version := 0
if header == "EffectData":
version = 1
elif header == "MDEData002":
version = 2
else:
last_error = "unknown header: " + header
return {}
var offset := HEADER_SIZE
var geometry_count := int(data.decode_s32(offset))
offset += 4
var frame_count := int(data.decode_s32(offset))
offset += 4
if geometry_count < 1 or geometry_count > MAX_GEOMETRIES:
last_error = "invalid geometry count: %d" % geometry_count
return {}
if frame_count < 1 or frame_count > MAX_FRAMES:
last_error = "invalid frame count: %d" % frame_count
return {}
var geometries: Array = []
for geometry_index in geometry_count:
if not _has(data, offset, GEOMETRY_HEADER_SIZE):
last_error = "truncated geometry header %d" % geometry_index
return {}
var name := _fixed_string(data, offset, 32)
offset += 32
var diffuse := _fixed_string(data, offset, 128)
offset += 128
var fixed_vertex_count := 0
var fixed_index_count := 0
var fixed_uv_count := 0
if version == 1:
if not _has(data, offset, 12):
last_error = "truncated v1 geometry counts %d" % geometry_index
return {}
fixed_vertex_count = int(data.decode_u32(offset))
fixed_index_count = int(data.decode_u32(offset + 4))
fixed_uv_count = int(data.decode_u32(offset + 8))
offset += 12
if not _valid_counts(fixed_vertex_count, fixed_index_count, fixed_uv_count):
last_error = "invalid v1 geometry counts %d" % geometry_index
return {}
var frames: Array = []
for frame_index in frame_count:
var changed := 0
var visibility := 1.0
var vertex_count := fixed_vertex_count
var index_count := fixed_index_count
var uv_count := fixed_uv_count
if version == 2:
if not _has(data, offset, 17): # byte + float + 3 x DWORD
last_error = "truncated v2 frame header %d/%d" % [geometry_index, frame_index]
return {}
changed = int(data.decode_u8(offset))
offset += 1
visibility = float(data.decode_float(offset))
offset += 4
vertex_count = int(data.decode_u32(offset))
offset += 4
index_count = int(data.decode_u32(offset))
offset += 4
uv_count = int(data.decode_u32(offset))
offset += 4
else:
if not _has(data, offset, 4):
last_error = "truncated v1 frame header %d/%d" % [geometry_index, frame_index]
return {}
visibility = float(data.decode_float(offset))
offset += 4
if not _valid_counts(vertex_count, index_count, uv_count):
last_error = "invalid frame counts %d/%d" % [geometry_index, frame_index]
return {}
var bytes_needed := vertex_count * 12 + index_count * 4 + uv_count * 8 + index_count * 4
if not _has(data, offset, bytes_needed):
last_error = "truncated frame data %d/%d" % [geometry_index, frame_index]
return {}
var positions := PackedVector3Array()
positions.resize(vertex_count)
for i in vertex_count:
var base := offset + i * 12
positions[i] = Vector3(
float(data.decode_float(base)),
float(data.decode_float(base + 4)),
float(data.decode_float(base + 8))) * 0.01
offset += vertex_count * 12
var indices := PackedInt32Array()
indices.resize(index_count)
for i in index_count:
indices[i] = int(data.decode_s32(offset + i * 4))
offset += index_count * 4
var uvs := PackedVector2Array()
uvs.resize(uv_count)
for i in uv_count:
var base := offset + i * 8
uvs[i] = Vector2(
float(data.decode_float(base)),
-float(data.decode_float(base + 4)))
offset += uv_count * 8
var texture_indices := PackedInt32Array()
texture_indices.resize(index_count)
for i in index_count:
texture_indices[i] = int(data.decode_s32(offset + i * 4))
offset += index_count * 4
# EffectMesh.cpp renders a non-indexed TPTVertex array. Keep the
# same order so a frame is directly usable as a TriangleList.
var render_vertices := PackedVector3Array()
var render_uvs := PackedVector2Array()
render_vertices.resize(index_count)
render_uvs.resize(index_count)
for i in index_count:
var vi := indices[i]
var ti := texture_indices[i]
if vi < 0 or vi >= positions.size() or ti < 0 or ti >= uvs.size():
last_error = "index out of range %d/%d" % [geometry_index, frame_index]
return {}
render_vertices[i] = positions[vi]
render_uvs[i] = uvs[ti]
frames.append({
"changed": changed,
"visibility": visibility,
"vertex_count": vertex_count,
"index_count": index_count,
"uv_count": uv_count,
"vertices": render_vertices,
"uvs": render_uvs,
})
geometries.append({
"name": name,
"diffuse": diffuse,
"frames": frames,
})
return {
"version": version,
"geometry_count": geometry_count,
"frame_count": frame_count,
"geometries": geometries,
}
func _valid_counts(vertex_count: int, index_count: int, uv_count: int) -> bool:
return vertex_count >= 0 and index_count >= 0 and uv_count >= 0 \
and vertex_count <= MAX_ELEMENTS and index_count <= MAX_ELEMENTS \
and uv_count <= MAX_ELEMENTS
func _has(data: PackedByteArray, offset: int, length: int) -> bool:
return offset >= 0 and length >= 0 and offset <= data.size() - length
func _fixed_string(data: PackedByteArray, offset: int, length: int) -> String:
var out := ""
for i in length:
var value := int(data[offset + i])
if value == 0:
break
out += String.chr(value)
return out
+21
View File
@@ -42,12 +42,15 @@ func parse_text(src: String) -> Dictionary:
"bsphere_pos": _vec3(top.get("BoundingSpherePosition", [0, 0, 0])),
"particles": [],
"meshes": [],
"lights": [],
"dir": "",
}
for g in top.get("__group_Particle", []):
out.particles.append(_norm_particle(g))
for g in top.get("__group_Mesh", []):
out.meshes.append(_norm_mesh(g))
for g in top.get("__group_Light", []):
out.lights.append(_norm_light(g))
return out
# --- 归一化 -----------------------------------------------------------
@@ -70,10 +73,28 @@ func _norm_mesh(g: Dictionary) -> Dictionary:
"start_time": float(_scalar(g.get("StartTime", 0.0))),
"position": g.get("TimeEventPosition", []),
"mesh_file": String(_scalar(g.get("MeshFileName", ""))),
"loop": int(_scalar(g.get("MeshAnimationLoopEnable", 0))),
"loop_count": int(_scalar(g.get("MeshAnimationLoopCount", 0))),
"frame_delay": float(_scalar(g.get("MeshAnimationFrameDelay", 0.0))),
"elements": elems,
}
func _norm_light(g: Dictionary) -> Dictionary:
return {
"start_time": float(_scalar(g.get("StartTime", 0.0))),
"position": g.get("TimeEventPosition", []),
"duration": float(_scalar(g.get("Duration", 1.0))),
"loop": int(_scalar(g.get("LoopFlag", 0))),
"loop_count": int(_scalar(g.get("LoopCount", 0))),
"max_range": float(_scalar(g.get("MaxRange", 300.0))),
"ambient": g.get("AmbientColor", [0.5, 0.5, 0.5, 1.0]),
"diffuse": g.get("DiffuseColor", [0.0, 0.0, 0.0, 1.0]),
"attenuation0": float(_scalar(g.get("Attenuation0", 0.0))),
"attenuation1": float(_scalar(g.get("Attenuation1", 0.1))),
"attenuation2": float(_scalar(g.get("Attenuation2", 0.0))),
"range": g.get("TimeEventRange", []),
}
func _first(a) -> Dictionary:
return a[0] if a is Array and a.size() > 0 else {}
+92 -4
View File
@@ -3,6 +3,7 @@
extends SceneTree
const Mse = preload("res://fx/mse.gd")
const Mde = preload("res://fx/mde.gd")
const EffectPlayer = preload("res://fx/effect_player.gd")
const EffectRegistry = preload("res://fx/effect_registry.gd")
@@ -22,6 +23,7 @@ Group Particle
MaxEmissionCount 5
CycleLength 0.400000
CycleLoopEnable 1
LoopCount 2
EmitterShape 3
EmittingRadius 10.000000
List TimeEventLifeTime
@@ -58,8 +60,38 @@ Group Particle
Group Particle
{
StartTime 0.200000
Group EmitterProperty { MaxEmissionCount 10 EmitterShape 0 }
Group ParticleProperty { SrcBlendType 5 DestBlendType 4 BillboardType 4 }
Group EmitterProperty
{
MaxEmissionCount 10
EmitterShape 3
EmittingRadius 20.0
EmitterAdvancedType 1
EmitterEmitFromEdgeFlag 1
}
Group ParticleProperty
{
SrcBlendType 5
DestBlendType 4
BillboardType 4
}
}
Group Light
{
StartTime 0.100000
Duration 0.500000
LoopFlag 0
LoopCount 0
MaxRange 300.000000
AmbientColor 0.100000 0.200000 0.300000 1.000000
DiffuseColor 0.800000 0.400000 0.100000 1.000000
Attenuation0 0.000000
Attenuation1 0.100000
Attenuation2 0.000000
List TimeEventRange
{
0.000000 0.000000
0.500000 1.000000
}
}
"""
@@ -72,6 +104,7 @@ func _ck(c: bool, m: String) -> void:
func _init() -> void:
_test_parser()
_test_builder()
_test_mde()
_test_registry()
if _fail == 0:
print("PASS: fx_test (.mse parser + EffectPlayer + registry)")
@@ -97,6 +130,8 @@ func _test_parser() -> void:
var tex = p0.particle.get("TextureFiles")
_ck(tex is Array and tex.size() == 1 and String(tex[0][0]) == "bottom_under.dds", "p0 TextureFiles")
_ck(abs(float(s.particles[1].start_time) - 0.2) < 0.01, "p1 start_time 0.2")
_ck(s.lights.size() == 1, "1 SimpleLight group")
_ck(abs(float(s.lights[0].duration) - 0.5) < 0.01 and s.lights[0].range.size() == 2, "light duration and range curve")
func _test_builder() -> void:
var m := Mse.new()
@@ -107,14 +142,41 @@ func _test_builder() -> void:
_ck(fx._emitters.size() == 2, "EffectPlayer built 2 GPUParticles3D")
var g0: GPUParticles3D = fx._emitters[0]
_ck(g0.amount == 5, "emitter 0 amount = 5")
_ck(abs(float(g0.get_meta("cycle_length", 0.0)) - 0.4) < 0.001, "emitter 0 cycle length = 0.4")
_ck(bool(g0.get_meta("cycle_loop", false)), "emitter 0 cycle loop enabled")
_ck(int(g0.get_meta("loop_count", 0)) == 2, "emitter 0 loop count = 2")
_ck(abs(g0.lifetime - 0.07732) < 0.01, "particle lifetime is not replaced by cycle length")
_ck(g0.process_material is ParticleProcessMaterial, "emitter 0 has process material")
var pm: ParticleProcessMaterial = g0.process_material
_ck(pm.emission_shape == ParticleProcessMaterial.EMISSION_SHAPE_SPHERE, "emitter 0 sphere emission (shape 3)")
_ck(g0.material_override is StandardMaterial3D, "emitter 0 draw material")
_ck((g0.material_override as StandardMaterial3D).blend_mode == BaseMaterial3D.BLEND_MODE_ADD, "src5/dst2 -> additive blend")
_ck(float(fx._emitters[1].get_meta("start_time")) == 0.2, "emitter 1 start_time meta = 0.2")
_ck(bool(fx._emitters[1].get_meta("emitter_emit_from_edge", false)), "emitter 1 edge emission metadata")
_ck(int(fx._emitters[1].get_meta("emitter_advanced_type", -1)) == 1, "emitter 1 advanced type metadata")
var pm1: ParticleProcessMaterial = fx._emitters[1].process_material
_ck(pm1.emission_shape == ParticleProcessMaterial.EMISSION_SHAPE_SPHERE_SURFACE,
"emitter 1 edge sphere uses surface emission")
_ck(abs(pm1.spread - 180.0) < 0.01, "OUTER emitter uses radial-velocity fallback spread")
_ck(fx._lights.size() == 1, "EffectPlayer built 1 OmniLight3D")
fx._process(0.11)
_ck(fx._lights[0].visible, "SimpleLight becomes visible after start time")
_ck(abs(fx._lights[0].omni_range - 0.06) < 0.02, "SimpleLight range follows TimeEventRange")
var moving_rows: Array = [
[0.0, "MOVING_TYPE_DIRECT", 0.0, 0.0, 0.0],
[1.0, "MOVING_TYPE_BEZIER_CURVE", 100.0, 0.0, 0.0, 0.0, 100.0, 0.0],
[2.0, "MOVING_TYPE_DIRECT", 200.0, 0.0, 0.0],
]
var direct_mid: Vector3 = fx._position_at(moving_rows, 0.5)
var bezier_mid: Vector3 = fx._position_at(moving_rows, 1.5)
_ck(abs(direct_mid.x - 0.5) < 0.001 and abs(direct_mid.y) < 0.001,
"MOVING_TYPE_DIRECT interpolates in metres")
_ck(abs(bezier_mid.x - 1.25) < 0.001 and abs(bezier_mid.y - 0.5) < 0.001,
"MOVING_TYPE_BEZIER_CURVE uses previous control point")
fx.play()
_ck(g0.emitting, "emitter 0 emitting after play() (start_time 0)")
fx._process(0.81)
_ck(not g0.emitting, "finite particle cycle stops after LoopCount cycles")
fx.queue_free()
func _test_registry() -> void:
@@ -130,9 +192,35 @@ func _test_registry() -> void:
p = fxr.resolve("geompung_3_sword")
_ck(p != "" and FileAccess.file_exists(p), "registry resolved a real .mse (%s)" % p)
if p != "":
var s := fxr.spec_for("PC/ymir work/pc/warrior/effect/geompung_3_sword.mse")
var s := fxr.spec_for("PC/ymir work/pc/warrior/effect/geompung.mse")
_ck(s.get("particles", []).size() > 0, "real .mse parsed to >=1 particle (%d)" % s.get("particles", []).size())
var parent := Node3D.new()
get_root().add_child(parent)
var fx := fxr.spawn("PC/ymir work/pc/warrior/effect/geompung_3_sword.mse", parent, true)
var fx := fxr.spawn("PC/ymir work/pc/warrior/effect/geompung.mse", parent, true)
_ck(fx != null and fx.get_parent() == parent, "spawn attached EffectPlayer")
_ck(fx._mesh_nodes.size() > 0, "spawn built real .mde mesh instead of placeholder")
if fx._mesh_nodes.size() > 0:
var mn: Node3D = fx._mesh_nodes[0]
_ck(int(mn.get_meta("mde_version", 0)) == 1, "real geompung.mde uses EffectData v1")
_ck(mn.get_child_count() > 0 and mn.get_child(0) is MeshInstance3D, "mde geometry is MeshInstance3D")
func _test_mde() -> void:
var assets := AssetRoot.path()
if not DirAccess.dir_exists_absolute(assets):
print(" (skip mde: no assets)")
return
var path := assets.path_join("PC/ymir work/pc/warrior/effect/geompung.mde")
if not FileAccess.file_exists(path):
print(" (skip mde: fixture missing)")
return
var data := Mde.new()
var mesh := data.parse_file(path)
_ck(data.last_error == "", "mde parses EffectData v1 (%s)" % data.last_error)
_ck(int(mesh.get("version", 0)) == 1, "mde version 1")
_ck(int(mesh.get("geometry_count", 0)) == 3, "mde geometry count = 3")
_ck(int(mesh.get("frame_count", 0)) == 2, "mde frame count = 2")
var geometries: Array = mesh.get("geometries", [])
_ck(geometries.size() == 3 and geometries[0].frames.size() == 2, "mde keeps every geometry frame")
if geometries.size() > 0 and geometries[0].frames.size() > 0:
var frame: Dictionary = geometries[0].frames[0]
_ck(frame.vertices.size() == frame.uvs.size() and frame.vertices.size() > 0, "mde expands indexed vertices with UVs")
+22
View File
@@ -34,6 +34,13 @@ var _orbit_active := false # 已越过 deadzone
var _orbit_press := Vector2.ZERO
var _pinch_last := -1.0 # 上一帧两指间距,<0 = 未在捏合
# ClientVS22 game.py drives these while Q/E/R/F/T/G are held. Keeping the
# state here (instead of applying one large step per key event) gives the
# same continuous desktop-camera motion on macOS and Windows.
var _key_orbit := 0
var _key_zoom := 0
var _key_pitch := 0
const OCCLUDER_MASK := 1 << 1 # 静态遮挡物层(Metin2World 给建筑的盒碰撞)
const FADE_ALPHA := 0.72 # 挡住玩家时的透明度
var _faded: Array[Node] = [] # 上一帧被淡出的 GeometryInstance3D
@@ -108,6 +115,15 @@ func heading() -> float:
# 相机看向的水平方向(供角色移动「相对相机」用)
return yaw
func set_key_orbit(direction: int) -> void:
_key_orbit = clampi(direction, -1, 1)
func set_key_zoom(direction: int) -> void:
_key_zoom = clampi(direction, -1, 1)
func set_key_pitch(direction: int) -> void:
_key_pitch = clampi(direction, -1, 1)
func _desired_pos(snap := false) -> Vector3:
var head := target.global_position + head_offset
var off := Vector3(
@@ -146,6 +162,12 @@ func shake(strength: float, decay := 8.0) -> void:
func _process(dt: float) -> void:
if target == null:
return
if _key_orbit != 0:
yaw += float(_key_orbit) * dt * 1.8
if _key_zoom != 0:
dist = clampf(dist + float(_key_zoom) * dt * 5.0, min_dist, max_dist)
if _key_pitch != 0:
pitch = clampf(pitch + float(_key_pitch) * dt * 1.2, min_pitch, max_pitch)
var want := _desired_pos()
if _pos_ready:
global_position = global_position.lerp(want, clampf(follow_lerp * dt, 0.0, 1.0))
+345 -63
View File
@@ -2,7 +2,7 @@
#
# 把已有的件拼成一个能玩的场景:
# Metin2World + 本地玩家 + GameCamera + PlayerController + NetWorld + HUD
# + NetPlay(胶水)+ Audio + AppLifecycle
# + NetPlay(胶水)+ Audio
#
# 由 login.gd 在 entered_game 时实例化:
# var gs := preload("res://game_scene.gd").new()
@@ -17,14 +17,16 @@ const PlayerCtl = preload("res://player_controller.gd")
const NetWorld = preload("res://net_world.gd")
const NetPlay = preload("res://net_play.gd")
const Hud = preload("res://hud.gd")
const AppLifecycle = preload("res://app_lifecycle.gd")
const Audio = preload("res://audio.gd")
const BgmDirector = preload("res://bgm_director.gd")
const UiManager = preload("res://ui/ui_manager.gd")
const CursorManager = preload("res://ui/cursor_manager.gd")
const MouseController = preload("res://ui/mouse_controller.gd")
const InventoryUI = preload("res://ui/inventory_ui.gd")
const ItemListDB = preload("res://ui/item_list.gd")
const EquipModel = preload("res://ui/equip_model.gd")
const PlayerView = preload("res://ui/player_view.gd")
const RemotePlayerView = preload("res://ui/remote_player_view.gd")
const MobView = preload("res://ui/mob_view.gd")
const GroundItems = preload("res://ui/ground_items.gd")
const ViewEquipmentUI = preload("res://ui/view_equipment_ui.gd")
@@ -56,6 +58,7 @@ const RefineUI = preload("res://ui/refine_ui.gd")
const DragonSoulUI = preload("res://ui/dragon_soul_ui.gd")
const Minimap = preload("res://ui/minimap.gd")
const AtlasUI = preload("res://ui/atlas_ui.gd")
const DungeonState = preload("res://dungeon_state.gd")
const WorldTime = preload("res://world/world_time.gd")
const Weather = preload("res://fx/weather.gd")
@@ -72,6 +75,8 @@ var net_world: Node
var net_play: Node
var hud: Node
var ui: CanvasLayer # UiManager
var cursor_manager: Node
var mouse_controller: Node
var proto: Node # Metin2Proto
var inventory: Node # InventoryUI
var item_list: RefCounted # ItemList
@@ -105,6 +110,7 @@ var refine_ui: Node # RefineUI
var dragon_soul_ui: Node # DragonSoulUI
var minimap: Node # Minimap
var atlas_ui: Node # AtlasUI
var dungeon_state: Node # GC_DUNGEON destination / compass state
var world_time: Node # WorldTime
var weather: Node3D # Weather
var _assets := ""
@@ -116,9 +122,16 @@ var _mount: Node3D
var _sun: DirectionalLight3D
var _env: Environment
var _env_from_msenv := false # true = 用 Metin2World 从 .msenv 建的 Sun/WorldEnv
var _pending_shoot_skill := 0
# CNormalBowAttack_FlyEventHandler_AutoClear 的等价:每次弓挥击 / 弓技能起手压一个
# uSkill`.msa` MOTION_EVENT_TYPE_FLY 帧(_on_local_motion_event 的 type 6)按 FIFO 弹一个
# 发 CG_SHOOT —— pop 即自清(AutoClear),队列天然支持连射多箭在途。§3.6
# 上限兜底:真客户端每个动作必到 FLY 帧,headless / 缺 `.msa` 时丢最旧的,避免无界增长。
var _pending_shots: Array[int] = []
const MAX_PENDING_SHOTS := 8
var _guild_invite_dialog: ConfirmationDialog
var _gift_dialog: AcceptDialog
var _quick_page_mode := false
var _show_names := false
func setup(m2client: Node, assets_root: String,
initial_map_path: String = "OutdoorA1/metin2_map_a1") -> void:
@@ -159,6 +172,14 @@ func setup(m2client: Node, assets_root: String,
net_world.world = world
net_world.setup(client, _mount)
net_world.set_local_node(player)
net_world.entity_added.connect(func(node: Node3D, _vid: int):
if node not in pc.pickables:
pc.pickables.append(node))
net_world.entity_removed.connect(func(vid: int):
for pickable in pc.pickables.duplicate():
if pickable == null or not is_instance_valid(pickable) \
or int(pickable.get_meta("vid", -1)) == vid:
pc.pickables.erase(pickable))
hud = Hud.new()
if _map_loaded():
@@ -171,6 +192,11 @@ func setup(m2client: Node, assets_root: String,
add_child(net_play)
net_play.setup(client, pc, net_world, hud)
net_play.camera = cam
# §8.6 targetBoard.GetTargetVID() -> net_world:选中目标始终强显名字。
if net_play.has_signal("target_changed") and net_world and net_world.has_method("set_target_vid"):
net_play.target_changed.connect(func(vid: int): net_world.set_target_vid(int(vid)))
if _assets != "":
net_play.set_asset_root(_assets) # §3.5:加载 playersettingmodule.py 连击段表
await _yield()
_audio = Audio.new()
@@ -178,9 +204,6 @@ func setup(m2client: Node, assets_root: String,
if assets_root != "":
_audio.setup(assets_root)
await _yield()
var life := AppLifecycle.new()
add_child(life)
life.bind(client, _audio)
# §9.1 地图 BGMM2Client.bgm_changed -> Audio。放在独立节点里,W4 补全曲目解析。
_bgm_director = BgmDirector.new()
@@ -190,11 +213,18 @@ func setup(m2client: Node, assets_root: String,
# UI 层 + 物品 proto + 背包窗(I 键开关)
ui = UiManager.new()
add_child(ui)
cursor_manager = CursorManager.new()
add_child(cursor_manager)
mouse_controller = MouseController.new()
add_child(mouse_controller)
mouse_controller.setup(ui, cursor_manager, _audio)
# 聊天窗(Enter 聚焦输入)
chat = ChatUI.new()
add_child(chat)
chat.setup(client, ui)
chat.setup(client, ui, _assets)
if net_play and net_play.has_signal("fishing_feedback") and chat.has_method("on_fishing_feedback"):
net_play.fishing_feedback.connect(chat.on_fishing_feedback)
if net_world and net_world.has_signal("main_bubble"):
net_world.main_bubble.connect(_player_bubble)
@@ -218,20 +248,35 @@ func setup(m2client: Node, assets_root: String,
char_status_ui.setup(ui, client, assets_root)
quickbar = Quickbar.new()
add_child(quickbar)
quickbar.item_mouse = mouse_controller
quickbar.net_play = net_play # §3.8 修改 1:技能三层校验的运行期上下文
quickbar.setup(client, skill_table, ui, func() -> Node: return player)
# §3.4 MODE_USE_SKILL:预约技能进射程后由 quickbar 执行实际施法。
if net_play:
net_play.use_skill_hook = func(slot: int) -> bool: return quickbar.activate_reserved(slot)
if quickbar.has_signal("skill_rejected"):
quickbar.skill_rejected.connect(func(_sid: int, code: String):
if net_play and net_play.has_signal("cannot_act"):
net_play.cannot_act.emit(code) # HUD 走同一条 OnCannotUseSkill 文案通道
)
if net_play and net_play.has_signal("fishing_feedback") and net_play.has_signal("cannot_act"):
net_play.fishing_feedback.connect(func(code: String): net_play.cannot_act.emit(code))
if quickbar.has_signal("skill_activated"):
quickbar.skill_activated.connect(func(sid: int):
if skill_fx and player:
skill_fx.spawn_skill(sid, _skill_master(sid), player)
# Bow skills send CG_SHOOT from the motion event, matching
# CNormalBowAttack_FlyEventHandler_AutoClear::OnShoot.
# Bow skills send CG_SHOOT from the .msa FLY frame, matching
# CNormalBowAttack_FlyEventHandler_AutoClear::OnShoot —— 压队列,
# 不再用 1.5s 定时器兜底(AutoClear 由 FLY 帧 pop 自清)。§3.6
if skill_table and skill_table.has_method("is_ranged") and skill_table.is_ranged(sid):
_pending_shoot_skill = sid
get_tree().create_timer(1.5).timeout.connect(func():
if _pending_shoot_skill == sid:
_pending_shoot_skill = 0)
_queue_shot(sid)
)
# 弓普攻起手(net_play._emit_swing):OnSetFlyTarget 已在那里发过 CG_FLY_TARGETING
# 这里只把 uSkill 压进 FLY 帧待发队列(普攻恒 0)。§3.6
if net_play and net_play.has_signal("bow_shot_fired"):
net_play.bow_shot_fired.connect(_queue_shot)
if client.has_signal("effect_cue"):
client.effect_cue.connect(func(vid: int, name: String, special: int):
var host: Node3D = _fx_host(vid)
@@ -241,16 +286,31 @@ func setup(m2client: Node, assets_root: String,
fx.spawn(name, host, true) # GC_SPECIFIC_EFFECT
elif special >= 0 and skill_fx:
skill_fx.spawn_special(special, host)) # GC_SPECIAL_EFFECT 内建 id
if client.has_signal("emoticon_requested"):
client.emoticon_requested.connect(_play_emoticon)
if client.has_signal("gift_available"):
client.gift_available.connect(_on_gift_available)
# 任务 / NPC(P7)—— 对话窗自动弹,J 键任务日志
quest_dialog = QuestDialog.new()
add_child(quest_dialog)
quest_dialog.setup(client, ui)
quest_dialog.setup(client, ui, proto, assets_root)
quest_log = QuestLog.new()
add_child(quest_log)
quest_log.setup(client, ui)
if quest_dialog.has_signal("quest_button_received"):
quest_dialog.quest_button_received.connect(func(index: int, title: String, icon_type: String, icon_name: String):
if quest_log and quest_log.has_method("recv_quest"):
quest_log.recv_quest(index, title, icon_type, icon_name))
quest_dialog.quest_button_cleared.connect(func(index: int):
if quest_log and quest_log.has_method("clear_quest"):
quest_log.clear_quest(index))
quest_dialog.opened.connect(func():
if quest_log and quest_log.has_method("set_buttons_suppressed"):
quest_log.set_buttons_suppressed(true))
quest_dialog.closed.connect(func():
if quest_log and quest_log.has_method("set_buttons_suppressed"):
quest_log.set_buttons_suppressed(false))
# 死亡窗 + 状态图标条(P4
var death := DeathUI.new()
@@ -268,6 +328,8 @@ func setup(m2client: Node, assets_root: String,
if FileAccess.file_exists(ip):
proto.call("load_item_proto", ip)
await _yield()
if chat and chat.has_method("set_proto"):
chat.set_proto(proto)
var mp := assets_root.path_join("locale/locale/en/mob_proto")
if FileAccess.file_exists(mp):
proto.call("load_mob_proto", mp)
@@ -275,14 +337,26 @@ func setup(m2client: Node, assets_root: String,
net_play.proto = proto # 按 race 分类 NPC/怪(bType 在本 fork 不可靠)
if net_world and "name_resolver" in net_world:
net_world.name_resolver = net_play._entity_name # 头顶名字:怪走 mob_proto
if net_world and "kind_resolver" in net_world:
net_world.kind_resolver = net_play._entity_kind # §8.8 名字色分类:怪 / NPC 走 mob_proto bType
if net_world and "title_name_resolver" in net_world and assets_root != "":
# §8.8 称号名:introloading.__RegisterTitleName -> localeInfo.PVP_LEVEL<grade>
var _tt_loc: RefCounted = load("res://locale.gd").new()
_tt_loc.setup(assets_root, "en")
net_world.title_name_resolver = func(g: int) -> String:
var k := "PVP_LEVEL%d" % g
return _tt_loc.t(k) if _tt_loc.has(k) else ""
# 怪 / NPC 真模型:race -> mob_proto.name -> monster/npc 目录
net_world.set_model_factory(_make_entity_model)
if assets_root != "":
item_list = ItemListDB.new()
item_list.load_file(assets_root.path_join("locale/locale/common/item_list.txt"))
await _yield()
if quest_log and quest_log.has_method("set_item_sources"):
quest_log.set_item_sources(proto, item_list, assets_root)
inventory = InventoryUI.new()
add_child(inventory)
inventory.item_mouse = mouse_controller
inventory.setup(ui, client, proto, assets_root, item_list)
# 装备 → 模型部件:武器 / 盾 / 身体 / 头盔 / 头发
equip_model = EquipModel.new()
@@ -294,6 +368,8 @@ func setup(m2client: Node, assets_root: String,
ground_items = GroundItems.new()
add_child(ground_items)
ground_items.setup(client, _mount, func() -> Node: return player, proto, item_list)
pc.set_input_surfaces(cursor_manager, ui, ground_items,
Callable(net_play, "cancel_fishing"), Callable(net_play, "on_ground_click"))
view_equipment_ui = ViewEquipmentUI.new()
add_child(view_equipment_ui)
view_equipment_ui.setup(client, ui, proto, item_list)
@@ -309,6 +385,10 @@ func setup(m2client: Node, assets_root: String,
party_ui = PartyUI.new()
add_child(party_ui)
party_ui.setup(client, ui)
# __ArrangeQuestButton 的 xPos 随组队窗可见性移位;party_ui 无信号,用探针轮询。
if quest_log and quest_log.has_method("set_party_probe"):
quest_log.set_party_probe(func() -> bool:
return party_ui != null and party_ui.has_method("is_open") and party_ui.is_open())
friend_ui = FriendUI.new()
add_child(friend_ui)
friend_ui.setup(client, ui)
@@ -321,21 +401,27 @@ func setup(m2client: Node, assets_root: String,
chat.start_whisper(nm))
shop_ui = ShopUI.new()
add_child(shop_ui)
shop_ui.item_mouse = mouse_controller
shop_ui.audio = _audio
shop_ui.setup(client, ui, proto, item_list)
exchange_ui = ExchangeUI.new()
add_child(exchange_ui)
exchange_ui.item_mouse = mouse_controller
exchange_ui.setup(client, ui, proto)
safebox_ui = SafeboxUI.new()
add_child(safebox_ui)
safebox_ui.item_mouse = mouse_controller
safebox_ui.setup(client, ui, proto)
mall_ui = MallUI.new()
add_child(mall_ui)
mall_ui.item_mouse = mouse_controller
mall_ui.setup(client, ui, proto)
cube_ui = CubeUI.new()
add_child(cube_ui)
cube_ui.setup(client, ui, proto)
private_shop_ui = PrivateShopUI.new()
add_child(private_shop_ui)
private_shop_ui.item_mouse = mouse_controller
private_shop_ui.setup(client, ui, proto, assets_root)
guild_ui = GuildUI.new()
add_child(guild_ui)
@@ -360,9 +446,26 @@ func setup(m2client: Node, assets_root: String,
minimap = Minimap.new()
add_child(minimap)
minimap.setup(client, ui, func() -> Node3D: return player)
dungeon_state = DungeonState.new()
add_child(dungeon_state)
dungeon_state.setup(client, func() -> Node3D: return player)
if dungeon_state.has_signal("destination_changed") and hud and hud.has_method("set_dungeon_destination"):
dungeon_state.destination_changed.connect(hud.set_dungeon_destination)
atlas_ui = AtlasUI.new()
add_child(atlas_ui)
atlas_ui.setup(world, ui, func() -> Node3D: return player, map_path.get_file(), client)
if quest_dialog:
quest_dialog.map_signal_added.connect(func(x: float, y: float):
if minimap and minimap.has_method("add_signal_point"):
minimap.add_signal_point(x, y)
if atlas_ui and atlas_ui.has_method("open"):
atlas_ui.open())
quest_dialog.map_signals_cleared.connect(func():
if minimap and minimap.has_method("clear_signal_points"):
minimap.clear_signal_points())
quest_dialog.atlas_center_requested.connect(func(x: int, y: int):
if atlas_ui and atlas_ui.has_method("set_center_position_adjust"):
atlas_ui.set_center_position_adjust(x, y))
world_time = WorldTime.new()
add_child(world_time)
# .msenv 已经给了固定的白天关照(A1 没有昼夜预设)——别让 world_time 每帧覆盖它。
@@ -381,6 +484,11 @@ func setup(m2client: Node, assets_root: String,
game_option_ui = GameOptionUI.new()
add_child(game_option_ui)
game_option_ui.setup(ui, client, assets_root)
# §8.4「总显示名字」radio -> net_worldCPythonSystem::SetAlwaysShowNameFlag)。
if game_option_ui.has_signal("display_option_changed"):
game_option_ui.display_option_changed.connect(_on_display_option)
if net_world and net_world.has_method("set_always_show_name") and game_option_ui.has_method("display_value"):
net_world.set_always_show_name(game_option_ui.display_value("always_show_name") == 1)
system_menu_ui = SystemMenuUI.new()
add_child(system_menu_ui)
system_menu_ui.setup(ui, client, assets_root, system_option_ui, game_option_ui)
@@ -408,6 +516,17 @@ func setup(m2client: Node, assets_root: String,
net_world.catch_up()
set_process_unhandled_input(true)
set_process(true)
func _process(_dt: float) -> void:
# MilesLib::CSoundManager::SetPosition/SetDirection + Update. The audio
# node keeps its listener at the origin and rebases active 3D instances.
if _audio == null or player == null or not is_instance_valid(player):
return
var basis := player.global_transform.basis
_audio.set_listener(player.global_position, -basis.z, basis.y)
if world and world.has_method("get_ambience_sources") and _audio.has_method("update_ambience_sources"):
_audio.update_ambience_sources(world.call("get_ambience_sources"))
# 会徽上传图源:优先 res://ui/default_guild_mark.png,没有就现造一张 16×12 占位。
func _guild_mark_upload_image() -> Image:
@@ -465,7 +584,10 @@ func _on_gift_available() -> void:
func _on_local_motion_event(type: int, effect: String, sound: String, pos: Vector3) -> void:
var anchor: Node3D = player
if sound != "" and _audio:
_audio.play_at(sound, anchor.global_position, anchor)
# Motion sounds are character sounds in ClientVS22 and use the optional
# 0.3s / 5000cm frequency-distance guard.
_audio.play_character_sound_3d(anchor.global_position.x, anchor.global_position.y,
anchor.global_position.z, sound, true)
if effect != "" and fx:
# EffectPosition 是骨骼本地锚点(cm);简版直接挂角色根 + y 偏移
var gp := anchor.global_position + Vector3(pos.x, pos.y, pos.z) * 0.01
@@ -476,13 +598,21 @@ func _on_local_motion_event(type: int, effect: String, sound: String, pos: Vecto
cam.shake(0.06, 9.0)
3: # SCREEN_FLASHING
_screen_flash(0.12)
6: # FLY —— 弓技能在动画飞行点发送 CG_SHOOT
if _pending_shoot_skill != 0 and client and client.has_method("shoot"):
client.shoot(_pending_shoot_skill)
_pending_shoot_skill = 0
6: # FLY —— `.msa` MOTION_EVENT_TYPE_FLY 帧:CActorInstance::ProcessMotionEventFly
# → m_pFlyEventHandler->OnShoot(m_kCurMotNode.uSkill)ActorInstanceMotionEvent.cpp:290)。
# FIFO pop = AutoClear + 连射多箭在途各自一发。
if client and client.has_method("shoot") and not _pending_shots.is_empty():
client.shoot(_pending_shots.pop_front())
9: # WARP —— 传送起点,不是投射物
pass
# 一次弓挥击 / 弓技能起手 → 压一个待发 uSkill(对齐 AutoClear handler 的 Set())。
# 满了丢最旧的:真客户端每个动作必到 FLY 帧,headless / 缺 `.msa` 时不至无界增长。§3.6
func _queue_shot(skill: int) -> void:
_pending_shots.append(skill)
while _pending_shots.size() > MAX_PENDING_SHOTS:
_pending_shots.pop_front()
var _flash_rect: ColorRect
func _screen_flash(secs: float) -> void:
if hud == null:
@@ -591,47 +721,168 @@ func _on_inv_context(window: int, cell: int) -> bool:
return false
func _unhandled_input(e: InputEvent) -> void:
if not (e is InputEventKey and e.pressed and not e.echo):
if not (e is InputEventKey) or e.echo:
return
if e.keycode in [KEY_ENTER, KEY_KP_ENTER] and chat and not chat.is_typing():
chat.focus_input()
get_viewport().set_input_as_handled()
elif e.keycode == KEY_I and inventory:
inventory.toggle()
elif e.keycode == KEY_K and skills:
skills.set_job(_job_name())
skills.toggle()
elif e.keycode in [KEY_V, KEY_C] and char_status_ui:
char_status_ui.toggle()
elif e.keycode == KEY_H and not e.ctrl_pressed and not e.meta_pressed and system_menu_ui:
# 参考端 game.py __PressHKey:非 Ctrl → interface.OpenHelpWindow()Ctrl+H 是 /user_horse_ride
system_menu_ui.toggle_help()
elif e.keycode == KEY_J and quest_log:
quest_log.toggle()
elif e.keycode == KEY_O and friend_ui:
friend_ui.toggle()
elif e.keycode == KEY_G and guild_ui:
guild_ui.toggle()
elif e.keycode == KEY_L and ENABLE_DRAGON_SOUL and dragon_soul_ui:
dragon_soul_ui.toggle()
elif e.keycode == KEY_M and atlas_ui:
atlas_ui.toggle()
elif e.keycode == KEY_Z and ground_items:
ground_items.try_pickup()
elif e.keycode >= KEY_F1 and e.keycode <= KEY_F4 and quickbar:
# 参考端 game.py:318 —— F1..F4 固定 __PressQuickSlot(4..7)。
quickbar.activate(4 + (e.keycode - KEY_F1))
elif e.keycode >= KEY_1 and e.keycode <= KEY_9:
# 参考端 __PressNumKeygame.py:431):
# Ctrl+1..9 → 表情;否则 1..4 → 快捷栏 0..3,5..9 无操作。
var n: int = e.keycode - KEY_1
if e.ctrl_pressed or e.meta_pressed:
_emote(n)
elif n < 4 and quickbar:
quickbar.activate(n)
elif e.keycode == KEY_ESCAPE and system_menu_ui:
# ESCui_manager 先关最顶层窗口并吃掉事件;到这里说明没有窗口打开。
system_menu_ui.toggle()
var key: Key = e.keycode
if ui and ui.has_method("blocks_game_input") and ui.blocks_game_input(e):
return
# The reference keeps a different callback for LSHIFT press/release. The
# flag is also useful on macOS where Command is the modifier for emotes.
if key == KEY_SHIFT:
_quick_page_mode = e.pressed
return
if not e.pressed:
match key:
KEY_SPACE:
if net_play and net_play.has_method("set_attack_key"):
net_play.set_attack_key(false)
KEY_ALT:
_set_names(false)
KEY_Q, KEY_E:
if cam and cam.has_method("set_key_orbit"):
cam.set_key_orbit(0)
KEY_R, KEY_F:
if cam and cam.has_method("set_key_zoom"):
cam.set_key_zoom(0)
KEY_T, KEY_G:
if cam and cam.has_method("set_key_pitch"):
cam.set_key_pitch(0)
return
var command: bool = e.ctrl_pressed or e.meta_pressed
match key:
KEY_ENTER, KEY_KP_ENTER:
if chat and not chat.is_typing():
chat.focus_input()
get_viewport().set_input_as_handled()
KEY_ALT:
_set_names(true)
KEY_PRINT:
_save_screen()
KEY_SPACE:
if net_play and net_play.has_method("set_attack_key"):
net_play.set_attack_key(true)
KEY_I:
if inventory: inventory.toggle()
KEY_K:
if skills:
skills.set_job(_job_name())
skills.toggle()
KEY_V:
if char_status_ui: char_status_ui.open("SKILL")
KEY_C:
if not command and char_status_ui: char_status_ui.open("STATUS")
KEY_B:
if command:
_send_command("/user_horse_back")
elif char_status_ui:
char_status_ui.open("EMOTICON")
KEY_H:
if command:
_send_command("/user_horse_ride")
elif system_menu_ui:
# game.py __PressHKey: normal H opens the help window.
system_menu_ui.toggle_help()
KEY_J:
_toggle_horse()
KEY_N:
if not command and quest_log: quest_log.toggle()
KEY_F:
if command:
_send_command("/user_horse_feed")
elif cam and cam.has_method("set_key_zoom"):
cam.set_key_zoom(1)
KEY_G:
if command:
_send_command("/ride")
elif _show_names and guild_ui:
guild_ui.toggle()
elif cam and cam.has_method("set_key_pitch"):
cam.set_key_pitch(1)
KEY_Q:
if command:
if quest_log and quest_log.has_method("toggle_buttons"): quest_log.toggle_buttons()
elif cam and cam.has_method("set_key_orbit"):
cam.set_key_orbit(-1)
KEY_E:
if not command and cam and cam.has_method("set_key_orbit"):
cam.set_key_orbit(1)
KEY_R:
if not command and cam and cam.has_method("set_key_zoom"):
cam.set_key_zoom(-1)
KEY_T:
if not command and cam and cam.has_method("set_key_pitch"):
cam.set_key_pitch(-1)
KEY_L:
if not command and chat and chat.has_method("toggle_log"): chat.toggle_log()
KEY_M:
if not command and atlas_ui: atlas_ui.toggle()
KEY_KP_ADD:
if minimap and minimap.has_method("get_scale"):
minimap.set_scale(float(minimap.get_scale()) * 1.15)
KEY_KP_SUBTRACT:
if minimap and minimap.has_method("get_scale"):
minimap.set_scale(float(minimap.get_scale()) / 1.15)
KEY_Z:
if not command and ground_items: ground_items.try_pickup()
KEY_O:
if not command and ENABLE_DRAGON_SOUL and dragon_soul_ui: dragon_soul_ui.toggle()
KEY_ESCAPE:
if system_menu_ui:
# ui_manager consumes topmost windows before this reaches ESC.
system_menu_ui.toggle()
_:
if int(key) == 96 or int(e.physical_keycode) == 96:
if not command and ground_items: ground_items.try_pickup()
elif key >= KEY_F1 and key <= KEY_F4 and quickbar:
# game.py __PressQuickSlot(4..7), independent of page selection.
quickbar.activate(4 + (key - KEY_F1))
elif key >= KEY_1 and key <= KEY_9:
var n: int = key - KEY_1
if command:
_emote(n)
elif _quick_page_mode and n < 4 and quickbar:
quickbar.set_page(n)
elif n < 4 and quickbar:
quickbar.activate(n)
func _set_names(visible: bool) -> void:
_show_names = visible
if net_world and net_world.has_method("set_names_visible"):
net_world.set_names_visible(visible)
# GameOptionUI 显示开关 radio 选中(uigameoption RefreshAlwaysShowName 系)。
func _on_display_option(key: String, value: int) -> void:
if key == "always_show_name" and net_world and net_world.has_method("set_always_show_name"):
net_world.set_always_show_name(value == 1)
func _send_command(text: String) -> void:
if client and client.has_method("say"):
client.say(0, text)
func _toggle_horse() -> void:
if client == null:
return
var main: Dictionary = client.get_entity(client.get_main_vid()) if client.has_method("get_entity") else {}
if int(main.get("mount_vnum", 0)) != 0:
_send_command("/unmount")
return
if private_shop_ui and private_shop_ui.has_method("is_open") and private_shop_ui.is_open():
return
if not client.has_method("get_inventory") or not client.has_method("use_item"):
return
for item in client.get_inventory():
if int(item.get("vnum", 0)) in [71114, 71116, 71118, 71120]:
client.use_item(1, int(item.get("cell", 0)))
return
func _save_screen() -> void:
var texture := get_viewport().get_texture()
if texture == null:
return
var dir := OS.get_user_data_dir().path_join("screenshots")
DirAccess.make_dir_recursive_absolute(dir)
texture.get_image().save_png(dir.path_join("screen_%d.png" % Time.get_ticks_msec()))
# 表情快捷键(§8.6 / §4.8)—— 参考端 Ctrl+1..9 = chrmgr.SetEmoticon(-1, n) + net.SendEmoticon(n)。
# W3 在 §8.6 里补全:本地立即播表情动作 + 发 classic 表情包。这里先留统一入口。
@@ -639,6 +890,13 @@ func _emote(index: int) -> void:
if client and client.has_method("send_emoticon"):
client.send_emoticon(index)
func _play_emoticon(index: int) -> void:
# playersettingmodule.py registers these in this exact order (0..11).
const EFFECTS := ["sweat", "money", "happy", "like", "love_s", "angry",
"aha", "gloom", "sorry", "!_mix_back", "question", "fish"]
if fx and player and index >= 0 and index < EFFECTS.size():
fx.spawn(EFFECTS[index], player, true)
func _job_name() -> String:
var r := int(client.get_entity(client.get_main_vid()).get("race", 0)) & 3
return ["WARRIOR", "ASSASSIN", "SURA", "SHAMAN"][r]
@@ -663,8 +921,9 @@ func _player_bubble(text: String) -> void:
lbl.pixel_size = 0.0055
lbl.outline_size = 6
player.add_child(lbl)
# §8.8:本地玩家气泡寿命对齐 CPythonTextTail gs_TextTail_LivingTime(默认 5s
var tw := create_tween()
tw.tween_interval(3.0)
tw.tween_interval(4.0)
tw.tween_property(lbl, "modulate:a", 0.0, 1.0)
tw.tween_callback(lbl.queue_free)
@@ -676,17 +935,34 @@ func set_entity_model_factory(factory: Callable) -> void:
net_world.set_model_factory(factory)
var _mob_view_cache := {} # race -> bool(该 race 是否有可用模型;失败就别再试)
var _remote_player_view_cache := {} # race -> bool(远端 PC 真模型可用性)
# 默认工厂:怪 / NPC -> MobViewrace = mob_proto vnum。失败返回 null 用占位胶囊。
# 默认工厂:远端 PC -> RemotePlayerView怪 / NPC -> MobView。失败返回 null 用占位胶囊。
func _make_entity_model(d: Dictionary) -> Node3D:
if bool(d.get("is_main", false)) or proto == null or _assets == "":
if bool(d.get("is_main", false)) or _assets == "":
return null
var ch_type := int(d.get("ch_type", -1))
var race := int(d.get("race", 0))
var pump: Callable = client.net_poll if client.has_method("net_poll") else Callable()
if ch_type == 0:
if race < 0 or _remote_player_view_cache.get(race, true) == false:
return null
var pv := RemotePlayerView.new()
var parts: Array = d.get("parts", []) if d.get("parts", []) is Array else []
if pv.build_remote(_assets, race, parts, item_list, proto, pump):
pv.set_audio(_audio)
_remote_player_view_cache[race] = true
return pv
_remote_player_view_cache[race] = false
pv.free()
return null
if proto == null:
return null
if race < 1 or _mob_view_cache.get(race, true) == false:
return null
var mv := MobView.new()
var pump: Callable = client.net_poll if client.has_method("net_poll") else Callable()
if mv.build(_assets, proto, race, pump):
mv.set_audio(_audio)
_mob_view_cache[race] = true
return mv
_mob_view_cache[race] = false
@@ -871,6 +1147,7 @@ func _on_main_set(vid: int) -> void:
var pv := PlayerView.new()
var pump: Callable = client.net_poll if client.has_method("net_poll") else Callable()
if pv.build(_assets, int(e.get("race", 0)), pump):
pv.set_audio(_audio)
set_player_model(pv)
_model_built = true
if net_play:
@@ -893,6 +1170,11 @@ func _on_main_set(vid: int) -> void:
p.y = float(world.call("sample_height", p.x, p.z))
player.position = p
# AppFlow 的唯一生命周期协调者在 GameScene 装配完成后调用,绑定当前场景的音频对象。
# GameScene 不创建 AppLifecycle,避免切屏时重复监听暂停 / 恢复通知。
func lifecycle_audio() -> Node:
return _audio
func _make_placeholder_player() -> Node3D:
var root := Node3D.new()
root.name = "LocalPlayer"
+4
View File
@@ -70,6 +70,10 @@ func _run() -> void:
_ck(gs.net_world != null, "net_world built")
_ck(gs.net_play != null, "net_play built")
_ck(gs.hud != null, "hud built")
_ck(gs.get_node_or_null("AppLifecycle") == null,
"GameScene does not create a second lifecycle coordinator")
_ck(gs.lifecycle_audio() != null and gs.lifecycle_audio().get_parent() == gs,
"GameScene exposes its audio to AppFlow lifecycle owner")
_ck(gs.dragon_soul_ui == null, "Dragon Soul runtime entry disabled for target 40250")
_ck(gs.atlas_ui != null, "atlas_ui built")
_ck(gs.get_node_or_null("Entities") != null, "entity mount present")
+48 -5
View File
@@ -31,6 +31,11 @@ var _target_name: Label
var _target_fill: ColorRect
# 状态图标条(P4
var _affect_row: HBoxContainer
# GC_DUNGEON destination compass (ClientVS22: AlarmHaveToGo).
var _dungeon_panel: Control
var _dungeon_label: Label
var _dungeon_arrow: Label
var _dungeon_destination := Vector3.ZERO
func setup(world: Node, player: Node3D) -> void:
_world = world
@@ -43,6 +48,7 @@ func setup(world: Node, player: Node3D) -> void:
_build_status()
_build_hotbar()
_build_inventory()
_build_dungeon_compass()
func reload_world(new_world: Node) -> void:
_world = new_world
@@ -312,6 +318,36 @@ func clear_target() -> void:
if _target_panel:
_target_panel.visible = false
func set_dungeon_destination(active: bool, world_pos: Vector3) -> void:
_dungeon_destination = world_pos
if _dungeon_panel:
_dungeon_panel.visible = active
if not active:
_dungeon_label.text = ""
func _build_dungeon_compass() -> void:
_dungeon_panel = Control.new()
_dungeon_panel.name = "DungeonCompass"
_dungeon_panel.set_anchors_preset(Control.PRESET_CENTER_TOP)
_dungeon_panel.position = Vector2(-110, 18)
_dungeon_panel.size = Vector2(220, 42)
_dungeon_panel.visible = false
add_child(_dungeon_panel)
var bg := ColorRect.new()
bg.color = Color(0.04, 0.05, 0.08, 0.78)
bg.size = _dungeon_panel.size
bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
_dungeon_panel.add_child(bg)
_dungeon_label = Label.new()
_dungeon_label.position = Vector2(30, 5)
_dungeon_label.add_theme_font_size_override("font_size", 12)
_dungeon_panel.add_child(_dungeon_label)
_dungeon_arrow = Label.new()
_dungeon_arrow.text = ""
_dungeon_arrow.position = Vector2(8, 9)
_dungeon_arrow.add_theme_font_size_override("font_size", 18)
_dungeon_panel.add_child(_dungeon_arrow)
func _build_target_panel() -> void:
_target_panel = Control.new()
_target_panel.set_anchors_preset(Control.PRESET_CENTER_TOP)
@@ -365,8 +401,15 @@ func _input(ev: InputEvent) -> void:
_inv.visible = not _inv.visible
func _process(_dt: float) -> void:
if _player == null or _minimap_rect == null:
return
var u := clampf(_player.position.x / _map_w_m, 0.0, 1.0)
var v := clampf(_player.position.z / _map_h_m, 0.0, 1.0)
_dot.position = Vector2(u * _minimap_rect.size.x - 3, v * _minimap_rect.size.y - 3)
if _player:
if _dungeon_panel and _dungeon_panel.visible:
var d := _dungeon_destination - _player.global_position
var distance := Vector2(d.x, d.z).length()
_dungeon_label.text = "副本目标 %.0fm" % distance
# Player forward is +Z in this scene; rotate the north-facing arrow
# relative to the current player yaw, keeping the target prompt local.
_dungeon_arrow.rotation = atan2(d.x, d.z) - _player.rotation.y
if _minimap_rect:
var u := clampf(_player.position.x / _map_w_m, 0.0, 1.0)
var v := clampf(_player.position.z / _map_h_m, 0.0, 1.0)
_dot.position = Vector2(u * _minimap_rect.size.x - 3, v * _minimap_rect.size.y - 3)
+145
View File
@@ -0,0 +1,145 @@
# input_key_test —— ClientVS22 game.py 键盘映射的离线回归。
# 覆盖 macOS Command / Windows Ctrl 表情键、快捷栏分页、按住攻击、
# 相机按住/释放、名称显示和几个窗口/坐骑入口。
extends SceneTree
const GameScene = preload("res://game_scene.gd")
class FakeClient extends Node:
var emotes := []
var commands := []
var used_items := []
var main := 1000
func send_emoticon(index: int) -> bool: emotes.append(index); return true
func say(_type: int, text: String) -> bool: commands.append(text); return true
func get_main_vid() -> int: return main
func get_entity(_vid: int) -> Dictionary: return {"mount_vnum": 123}
func get_inventory() -> Array: return [{"cell": 7, "vnum": 71114}]
func use_item(_window: int, cell: int) -> bool: used_items.append(cell); return true
class FakeQuickbar extends Node:
var activated := []
var pages := []
func activate(slot: int) -> void: activated.append(slot)
func set_page(page: int) -> void: pages.append(page)
class FakeNetPlay extends Node:
var attack_states := []
func set_attack_key(down: bool) -> void: attack_states.append(down)
class FakeCamera extends Camera3D:
var orbit := []
var zoom := []
var pitch := []
func set_key_orbit(direction: int) -> void: orbit.append(direction)
func set_key_zoom(direction: int) -> void: zoom.append(direction)
func set_key_pitch(direction: int) -> void: pitch.append(direction)
class FakeWorld extends Node:
var names := []
func set_names_visible(visible: bool) -> void: names.append(visible)
class FakeChat extends Node:
var toggles := 0
func is_typing() -> bool: return false
func focus_input() -> void: pass
func toggle_log() -> void: toggles += 1
class FakeStatus extends Node:
var states := []
func open(state: String) -> void: states.append(state)
class FakeToggle extends Node:
var toggles := 0
var button_toggles := 0
func toggle() -> void: toggles += 1
func toggle_help() -> void: toggles += 1
func toggle_buttons() -> void: button_toggles += 1
var _fail := 0
func _ck(condition: bool, message: String) -> void:
if not condition:
_fail += 1
printerr("FAIL: " + message)
func _key(code: Key, pressed := true, ctrl := false, meta := false) -> InputEventKey:
var event := InputEventKey.new()
event.keycode = code
event.pressed = pressed
event.echo = false
event.ctrl_pressed = ctrl
event.meta_pressed = meta
return event
func _init() -> void:
_run()
if _fail == 0:
print("PASS: input_key_test (ClientVS22 desktop key map)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
func _run() -> void:
var client := FakeClient.new()
var qb := FakeQuickbar.new()
var np := FakeNetPlay.new()
var camera := FakeCamera.new()
var world := FakeWorld.new()
var chat := FakeChat.new()
var status := FakeStatus.new()
var help := FakeToggle.new()
var quest := FakeToggle.new()
var gs: Node = GameScene.new()
gs.client = client
gs.quickbar = qb
gs.net_play = np
gs.cam = camera
gs.net_world = world
gs.chat = chat
gs.char_status_ui = status
gs.system_menu_ui = help
gs.quest_log = quest
get_root().add_child(gs)
gs._unhandled_input(_key(KEY_1, true, true, false))
gs._unhandled_input(_key(KEY_9, true, false, true))
_ck(client.emotes == [0, 8], "Ctrl/Command+number -> matching emoticon")
gs._unhandled_input(_key(KEY_1))
gs._unhandled_input(_key(KEY_F1))
gs._unhandled_input(_key(KEY_F4))
_ck(qb.activated == [0, 4, 7], "number/F1-F4 -> reference local quick slots")
gs._unhandled_input(_key(KEY_SHIFT))
gs._unhandled_input(_key(KEY_4))
gs._unhandled_input(_key(KEY_SHIFT, false))
_ck(qb.pages == [3], "held Shift + number -> quick page 4")
gs._unhandled_input(_key(KEY_SPACE))
gs._unhandled_input(_key(KEY_SPACE, false))
_ck(np.attack_states == [true, false], "Space press/release -> attack key state")
gs._unhandled_input(_key(KEY_Q))
gs._unhandled_input(_key(KEY_Q, false))
gs._unhandled_input(_key(KEY_R))
gs._unhandled_input(_key(KEY_R, false))
_ck(camera.orbit == [-1, 0] and camera.zoom == [-1, 0], "camera Q/R release stops continuous motion")
gs._unhandled_input(_key(KEY_ALT))
gs._unhandled_input(_key(KEY_ALT, false))
_ck(world.names == [true, false], "Alt press/release -> ShowName/HideName")
gs._unhandled_input(_key(KEY_B))
gs._unhandled_input(_key(KEY_V))
gs._unhandled_input(_key(KEY_C))
gs._unhandled_input(_key(KEY_H))
gs._unhandled_input(_key(KEY_N))
_ck(status.states == ["EMOTICON", "SKILL", "STATUS"], "character tabs match B/V/C")
_ck(help.toggles == 1 and quest.toggles == 1, "H help and N quest log")
gs._unhandled_input(_key(KEY_Q, true, true))
_ck(quest.button_toggles == 1, "Ctrl+Q toggles quest buttons")
gs._unhandled_input(_key(KEY_L))
_ck(chat.toggles == 1, "L toggles chat log")
gs._unhandled_input(_key(KEY_H, true, true))
gs._unhandled_input(_key(KEY_B, true, false, true))
gs._unhandled_input(_key(KEY_J, true, true))
_ck(client.commands == ["/user_horse_ride", "/user_horse_back", "/unmount"],
"Ctrl/Command horse commands follow reference")
+8
View File
@@ -23,6 +23,14 @@ func _check(ok: bool, message: String) -> void:
func _init() -> void:
var inv := InventoryUI.new()
_check(inv._to_wire(0) == [1, 0], "normal inventory uses INVENTORY cell")
# 旧 11 格 wearBODY..SHIELD)走 EQUIPMENT 窗(window=2, 零基 wear cell)。
_check(inv._to_wire(90) == [2, 0], "wear body uses EQUIPMENT window cell 0")
_check(inv._to_wire(100) == [2, 10], "wear shield uses EQUIPMENT window cell 10")
# ENABLE_NEW_EQUIPMENT_SYSTEM 的 costume/ring/belt 留在 INVENTORY 窗全局 cellGameType.h)。
_check(inv._to_wire(109) == [1, 109], "costume body uses legacy global cell 109")
_check(inv._to_wire(110) == [1, 110], "costume hair uses legacy global cell 110")
_check(inv._to_wire(111) == [1, 111], "ring1 uses legacy global cell 111")
_check(inv._to_wire(112) == [1, 112], "ring2 uses legacy global cell 112")
_check(inv._to_wire(113) == [1, 113], "wear belt uses legacy global cell 113")
_check(inv._to_wire(200) == [1, 152], "belt UI cell 0 uses global cell 152")
_check(inv._to_wire(215) == [1, 167], "belt UI cell 15 uses global cell 167")
+11 -1
View File
@@ -26,7 +26,9 @@ class FakeClient extends Node:
class FakeProto extends Node:
func item(vnum: int) -> Dictionary:
return {"vnum": vnum, "name": "Item%d" % vnum, "locale_name": "物品%d" % vnum, "type": 1}
# type 1 = WEAPON;给出可穿戴位 + 空限制,让 EquipRules 前置门放行。
return {"vnum": vnum, "name": "Item%d" % vnum, "locale_name": "物品%d" % vnum,
"type": 1, "sub_type": 0, "anti_flags": 0, "wear_flags": 1 << 4, "limits": []}
var _fail := 0
func _ck(c: bool, m: String) -> void:
@@ -61,6 +63,10 @@ func _run() -> void:
fc.equip[1] = {"vnum": 11209, "count": 1, "wear": 1}
var iv: Node = InventoryUI.new()
var mouse := preload("res://ui/mouse_controller.gd").new()
get_root().add_child(mouse)
mouse.setup(ui)
iv.item_mouse = mouse
get_root().add_child(iv)
iv.setup(ui, fc, fp, assets)
iv.open()
@@ -88,6 +94,10 @@ func _run() -> void:
# 拖 5 -> 91(背包 -> 装备)
iv.move_to(5, 91, 1)
_ck(fc.moved.size() == 1 and fc.moved[0] == [1, 5, 2, 1, 1], "move_to(5,91) -> move_item(1,5,2,1,1)")
_ck(iv._drop_mouse_item({"window": 1, "cell": 6, "vnum": 27, "count": 3}, 8),
"mouse drop accepts inventory stack")
_ck(fc.moved.size() == 2 and fc.moved[1] == [1, 6, 1, 8, 3],
"mouse drop preserves attached stack count")
iv.close()
_ck(not iv.is_open(), "window closed")
+69
View File
@@ -0,0 +1,69 @@
# item_tooltip_test —— item_proto 固定字段 + itemdesc + 实例 socket/attribute 的回归。
extends SceneTree
const ItemTooltip = preload("res://ui/item_tooltip.gd")
class FakeMobProto extends RefCounted:
func mob(vnum: int) -> Dictionary:
return {"vnum": vnum, "name": "Wolf", "locale_name": "Wolf"}
var _fail := 0
func _ck(condition: bool, message: String) -> void:
if not condition:
_fail += 1
printerr("FAIL: " + message)
func _init() -> void:
var tooltip := ItemTooltip.new()
var assets := AssetRoot.path()
_ck(tooltip.setup(assets, "en"), "itemdesc en loaded")
var proto := {
"vnum": 71114,
"name": "Wild Boar Seal",
"locale_name": "Wild Boar Seal",
"type": 2,
"values": [12, 30, 0, 0, 0, 2],
"limits": [{"type": 1, "value": 15}, {"type": 0, "value": 0}],
"applies": [{"type": 1, "value": 100}, {"type": 0, "value": 0}, {"type": 0, "value": 0}],
"refined_vnum": 71115,
"refine_set": 42,
}
var instance := {
"count": 3,
"sockets": [28030, 0, -1],
"attrs": [{"type": 5, "value": 7}],
}
var text := tooltip.format(71114, 3, proto, instance)
_ck(text.begins_with("Wild Boar Seal\n数量:3"), "title and stack count")
_ck(text.contains("This seal's magic"), "itemdesc description included")
_ck(text.contains("等级要求:15"), "limit included")
_ck(text.contains("防御力:34"), "armor value formula included")
_ck(text.contains("最大生命:+100"), "proto apply included")
_ck(text.contains("附加力量:+7"), "instance attribute included")
_ck(text.contains("孔 1#28030") and text.contains("孔 3:损坏"), "instance sockets included")
_ck(text.contains("精炼后:#71115") and text.contains("精炼套件:42"), "refine links included")
var rod_proto := {"locale_name": "Fishing Rod", "type": 13, "values": [30, 0, 120]}
var rod_text := tooltip.format(20000, 1, rod_proto, {"sockets": [120, 0, 0]})
_ck(rod_text.contains("等级:3") and rod_text.contains("经验:120 / 120") and rod_text.contains("可升级"),
"rod level and experience included")
var fish_text := tooltip.format(27800, 1, {"locale_name": "Fish", "type": 12}, {"sockets": [235, 0, 0]})
_ck(fish_text.contains("长度:2.35 cm"), "fish length included")
var ds_text := tooltip.format(12345, 1, {"locale_name": "Dragon Soul", "type": 29}, {})
_ck(ds_text.contains("龙魂石阶段:4") and ds_text.contains("龙魂石强化:4"), "dragon soul stage included")
var special := ItemTooltip.new()
_ck(special.setup(assets, "en", FakeMobProto.new()), "special tooltip sources loaded")
var book_text := special.format(50300, 1, {"locale_name": "Skill Book", "type": 17}, {"sockets": [1, 0, 0]})
_ck(book_text.begins_with("Three-Way Cut Skill Book"), "skill book title includes skill name")
var poly_text := special.format(70103, 1, {"locale_name": "Polymorph Marble", "type": 19}, {"sockets": [77, 0, 0]})
_ck(poly_text.begins_with("Wolf Polymorph Marble"), "polymorph title includes monster name")
var timed := special.format(90001, 1, {"locale_name": "Timed", "type": 18,
"limits": [{"type": 7, "value": 0}, {"type": 0, "value": 0}]},
{"sockets": [int(Time.get_unix_time_from_system()) + 65, 0, 0]})
_ck(timed.contains("剩余时间:1分5秒"), "realtime limit renders remaining time")
if _fail == 0:
print("PASS: item_tooltip_test")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
+6 -5
View File
@@ -62,8 +62,8 @@ func _init() -> void:
v.add_child(title)
_host = _field(v, _loc.t("LOGIN_DEFAULT_SERVERADDR").split(",")[0], "192.168.21.203")
_id = _field(v, _loc.t("LOGIN_ID"), "admin")
_pw = _field(v, _loc.t("LOGIN_PASSWORD"), "123456789")
_id = _field(v, _loc.t("LOGIN_ID"), OS.get_environment("MT_ACCOUNT"))
_pw = _field(v, _loc.t("LOGIN_PASSWORD"), OS.get_environment("MT_PASSWORD"))
_pw.secret = true
var btn := Button.new()
@@ -143,9 +143,10 @@ func _on_entered_game() -> void:
_msg("已进入游戏 —— 加载场景")
if _layer:
_layer.visible = false
if _life: # 让 game_scene 自己的 AppLifecycle 接管
_life.queue_free()
_life = null
# 兼容启动器没有 AppFlow,因此由这里创建的生命周期协调者贯穿
# 登录→游戏;GameScene 不再创建第二个协调者,也不能在切屏时销毁它。
if _life:
_life.bind(_client, _audio)
_game = GameScene.new()
get_root().add_child(_game)
_game.setup(_client, _assets)
+6
View File
@@ -4,6 +4,7 @@
extends SceneTree
const MobView = preload("res://ui/mob_view.gd")
const Audio = preload("res://audio.gd")
var _fail := 0
func _ck(c: bool, m: String) -> void:
@@ -30,6 +31,9 @@ func _run() -> void:
return
var proto: Object = ClassDB.instantiate("Metin2Proto")
get_root().add_child(proto)
var audio: Node = Audio.new()
get_root().add_child(audio)
audio.setup(assets)
var mp := assets.path_join("locale/locale/en/mob_proto")
if not FileAccess.file_exists(mp) or not proto.call("load_mob_proto", mp):
print(" (skip: 无 mob_proto)")
@@ -42,6 +46,7 @@ func _run() -> void:
_ck(ok, "MobView.build(102 = Wolf) 成功")
if not ok:
return
mv.set_audio(audio)
_ck(mv._dir.get_file() == "wolf", "解到 wolf 目录(%s" % mv._dir)
_ck(mv.model != null and mv.model.get_class() == "Metin2Model", "Metin2Model 建好")
_ck(mv._motions.size() >= 3, "motlist.txt 解出 %d 个动作" % mv._motions.size())
@@ -55,6 +60,7 @@ func _run() -> void:
var s1: String = mv._state
mv.set_anim_state("attack")
_ck(mv._state == "attack" and s1 == "run", "set_anim_state 切换记录")
_ck(mv._sound_instances.size() > 0, "MobView attack motion loads paired .mss instances")
# 未知 race -> build 失败(返回 null 用占位)
var mv2: Node3D = MobView.new()
+68
View File
@@ -0,0 +1,68 @@
extends SceneTree
var _failed := false
class CursorStub extends Node:
var current := "NORMAL"
func set_cursor(shape: String) -> void:
current = shape
func reset() -> void:
current = "NORMAL"
class AudioStub extends Node:
var calls: Array[String] = []
func play_ui(name: String) -> void:
calls.append(name)
func _init() -> void:
var host := Node.new()
root.add_child(host)
var cur := CursorStub.new()
host.add_child(cur)
var audio := AudioStub.new()
host.add_child(audio)
var mouse := preload("res://ui/mouse_controller.gd").new()
host.add_child(mouse)
mouse.setup(host, cur, audio)
_check(not mouse.is_attached(), "starts detached")
_check(mouse.attach_item(1, 7, 1001, 3), "attach inventory item")
_check(audio.calls == ["pick.wav"], "attach plays pick sound")
_check(mouse.is_attached(), "attached state visible")
_check(int(mouse.attached().get("cell", -1)) == 7, "attached source cell")
_check(cur.current == "ITEM", "item cursor state")
var target := Control.new()
target.position = Vector2.ZERO
target.size = Vector2(32, 32)
host.add_child(target)
mouse.register_target(target, func(payload: Dictionary):
host.set_meta("dropped_cell", int(payload.get("cell", -1)))
return int(payload.get("cell", -1)) == 7)
mouse._drop_at(target.get_global_rect().position + Vector2(4, 4))
_check(int(host.get_meta("dropped_cell", -1)) == 7, "drop callback receives source payload")
_check(not mouse.is_attached(), "successful drop clears attached state")
_check(audio.calls == ["pick.wav", "drop.wav"], "successful drop plays drop sound")
mouse.attach_money(123)
_check(mouse.is_attached() and int(mouse.attached().get("vnum", 0)) == -1
and int(mouse.attached().get("count", 0)) == 123, "money has independent attached state")
mouse.cancel()
mouse.cancel()
_check(not mouse.is_attached(), "cancel clears attached state")
_check(cur.current == "NORMAL", "cancel resets cursor")
mouse.attach_item(1, 7, 1001, 1)
mouse._drop_at(Vector2(999, 999))
_check(not mouse.is_attached(), "invalid drop cancels attached state")
mouse._notification(Node.NOTIFICATION_WM_WINDOW_FOCUS_OUT)
mouse.attach_item(1, 7, 1001, 1)
mouse._notification(Node.NOTIFICATION_WM_WINDOW_FOCUS_OUT)
_check(not mouse.is_attached(), "focus loss cancels drag")
_check(audio.calls == ["pick.wav", "drop.wav", "pick.wav", "pick.wav", "loginfail.wav", "pick.wav"],
"invalid drop plays cancel sound and focus loss cancels")
host.queue_free()
print("PASS: mouse_controller (global item drag state + cancel)")
quit(1 if _failed else 0)
func _check(ok: bool, label: String) -> void:
if ok:
return
_failed = true
push_error("FAIL: " + label)
+175
View File
@@ -0,0 +1,175 @@
# name_color.gd —— CInstanceBase::GetNameColorIndex / GetNameColor 的 1:1 迁移(§8.8
#
# 参考(REF/ = /Users/shenlei/Work/mt/40250/ClientVS22/source/):
# UserInterface/InstanceBaseEffect.cpp:525 GetNameColorIndex —— 逐行照抄
# UserInterface/InstanceBaseEffect.cpp:520 GetNameColor
# UserInterface/InstanceBaseEffect.cpp:52 SetEmpireNameMode
# UserInterface/InstanceBaseEffect.cpp:75 GetIndexedNameColor
# UserInterface/InstanceBase.h:216 NAMECOLOR_* 枚举(EMPIRE_NUM = 4
# UserInterface/PythonCharacterManagerModule.cpp:767 chrmgr.NAMECOLOR_* -> C++ 枚举重映射
# assets/root/introloading.py:264 IntroLoading.__RegisterColorRGB 注册)
# assets/root/colorInfo.py:11 CHR_NAME_RGB_*
# assets/root/constinfo.py:126 SET_DEFAULT_CHRNAME_COLOR(默认 CHRNAME_COLOR_INDEX = 0 → 帝国名色关)
#
# 纯静态。调用方把网络快照拼成 self_e(目标实例)/ main_e(主角,空 = 无主角实例)/
# ctx,语义与 entity_rules.gd 完全一致(net_play._attack_ctx / net_world._name_color_ctx
# 同款 ctxmain_vid / duel_mode / duel_opponents / pvp_pairs / gvg_pairs / party_vids)。
extends RefCounted
const EntityRules = preload("res://entity_rules.gd")
# InstanceBase.h 的匿名枚举,EMPIRE_NUM = 4EMPIRE_NONE/A/B/C)。
const EMPIRE_NUM := 4
# NAMECOLOR_*EMPIRE_NUM 展开后的实际序号)
const NAMECOLOR_MOB := 0
const NAMECOLOR_NPC := 1
const NAMECOLOR_PC := 2 # + GetEmpireID()0..3)→ 2..5
const NAMECOLOR_PC_END := 6
const NAMECOLOR_NORMAL_MOB := 7
const NAMECOLOR_NORMAL_NPC := 8
const NAMECOLOR_NORMAL_PC := 9
const NAMECOLOR_NORMAL_PC_END := 13
const NAMECOLOR_EMPIRE_MOB := 14
const NAMECOLOR_EMPIRE_NPC := 15
const NAMECOLOR_EMPIRE_PC := 16 # + uEmpire → 16..20
const NAMECOLOR_EMPIRE_PC_END := 20
const NAMECOLOR_FUNC := 21
const NAMECOLOR_PK := 22
const NAMECOLOR_PVP := 23
const NAMECOLOR_PARTY := 24
const NAMECOLOR_WARP := 25
const NAMECOLOR_WAYPOINT := 26
const NAMECOLOR_NUM := 41
const _WHITE := Color(1, 1, 1)
# IntroLoading.__RegisterColor 通过 chrmgr.RegisterNameColor 写进 g_akD3DXClrName 的槽。
# chrmgr.NAMECOLOR_PC/NPC/MOB 三个常量经 PythonCharacterManagerModule.cpp:767 重映射
# 到 NORMAL_PC/NORMAL_NPC/NORMAL_MOB,其余(PK/PVP/PARTY/WARP/WAYPOINT/EMPIRE_*)直通。
# 未在此表里的索引 = D3DXCOLOR() 默认(透明黑),但 GetNameColorIndex 不会命中它们。
static func _registered_slots() -> Dictionary:
return {
NAMECOLOR_NORMAL_MOB: Color8(235, 22, 9),
NAMECOLOR_NORMAL_NPC: Color8(122, 231, 93),
NAMECOLOR_NORMAL_PC: Color8(255, 215, 76),
NAMECOLOR_PK: Color8(180, 100, 0),
NAMECOLOR_PVP: Color8(238, 54, 223),
NAMECOLOR_PARTY: Color8(128, 192, 255),
NAMECOLOR_WARP: Color8(136, 218, 241),
NAMECOLOR_WAYPOINT: Color8(255, 255, 255),
NAMECOLOR_EMPIRE_MOB: Color8(235, 22, 9),
NAMECOLOR_EMPIRE_NPC: Color8(122, 231, 93),
NAMECOLOR_EMPIRE_PC + 1: Color8(157, 0, 0),
NAMECOLOR_EMPIRE_PC + 2: Color8(222, 160, 47),
NAMECOLOR_EMPIRE_PC + 3: Color8(23, 30, 138),
}
# g_akD3DXClrName 的当前解析态(SetEmpireNameMode 重写 MOB/NPC + PC..PC+3 槽)。
static var _palette := {}
static var _empire_name_mode := false
static func _ensure_palette() -> void:
if _palette.is_empty():
set_empire_name_mode(false)
static func is_empire_name_mode() -> bool:
_ensure_palette()
return _empire_name_mode
# CInstanceBase::SetEmpireNameModeInstanceBaseEffect.cpp:52)。默认关(constinfo
# .CHRNAME_COLOR_INDEX == 0 → SET_DEFAULT_CHRNAME_COLOR → SetEmpireNameMode(0))。
static func set_empire_name_mode(enabled: bool) -> void:
_empire_name_mode = enabled
var p := _registered_slots()
if enabled:
p[NAMECOLOR_MOB] = p[NAMECOLOR_EMPIRE_MOB]
p[NAMECOLOR_NPC] = p[NAMECOLOR_EMPIRE_NPC]
p[NAMECOLOR_PC] = p[NAMECOLOR_NORMAL_PC]
for u in range(1, EMPIRE_NUM):
p[NAMECOLOR_PC + u] = p.get(NAMECOLOR_EMPIRE_PC + u, _WHITE)
else:
p[NAMECOLOR_MOB] = p[NAMECOLOR_NORMAL_MOB]
p[NAMECOLOR_NPC] = p[NAMECOLOR_NORMAL_NPC]
for u in range(0, EMPIRE_NUM):
p[NAMECOLOR_PC + u] = p[NAMECOLOR_NORMAL_PC]
_palette = p
# CInstanceBase::GetIndexedNameColorInstanceBaseEffect.cpp:75
static func indexed_name_color(index: int) -> Color:
_ensure_palette()
if index < 0 or index >= NAMECOLOR_NUM:
return _WHITE
return _palette.get(index, _WHITE)
# --- 类别谓词(entity_rules 已有 _is_pc/_is_enemy/_is_polyNPC 这里补) ---------
static func _is_npc(e: Dictionary) -> bool:
return EntityRules._kind(e) == EntityRules.KIND_NPC
static func _empire_id(e: Dictionary) -> int:
# GetEmpireID() → m_dwEmpireID0 = 无,1..3)。防御性 clamp 到合法槽。
return clampi(int(e.get("empire", 0)), 0, EMPIRE_NUM - 1)
# --- CInstanceBase::GetNameColorIndexInstanceBaseEffect.cpp:525)—— 逐行 --------
static func name_color_index(self_e: Dictionary, main_e: Dictionary, ctx: Dictionary) -> int:
if EntityRules._is_pc(self_e):
# if (m_isKiller) return NAMECOLOR_PK;
if EntityRules._is_killer(self_e):
return NAMECOLOR_PK
var self_vid := int(self_e.get("vid", 0))
var main_vid := int(ctx.get("main_vid", int(main_e.get("vid", 0))))
var is_main := self_vid != 0 and self_vid == main_vid
# if (__IsExistMainInstance() && !__IsMainInstance())
if not main_e.is_empty() and not is_main:
var vid_main := int(main_e.get("vid", main_vid))
# if (pkInstMain->GetDuelMode()) switch(...)
var duel := int(ctx.get("duel_mode", 0))
if duel != 0:
match duel:
EntityRules.DUEL_CANNOTATTACK:
return NAMECOLOR_PC + _empire_id(self_e)
EntityRules.DUEL_START:
if EntityRules._find_duel_key(vid_main, self_vid, ctx):
return NAMECOLOR_PVP
return NAMECOLOR_PC + _empire_id(self_e)
# if (pkInstMain->IsSameEmpire(*this))
if EntityRules._is_same_empire(main_e, self_e):
if EntityRules._pair_in(ctx.get("pvp_pairs", []), vid_main, self_vid):
return NAMECOLOR_PVP
var guild_main := int(main_e.get("guild", 0))
var guild_self := int(self_e.get("guild", 0))
# __FindGVGKeyguild 0 的 PVPKEY 恒为 0,参考端集合里不会有 →
# 这里与 entity_rules._is_pvp_instance 一样加 !=0 防御。
if guild_main != 0 and guild_self != 0 \
and EntityRules._pair_in(ctx.get("gvg_pairs", []), guild_main, guild_self):
return NAMECOLOR_PVP
# __FindDUELKey 分支在参考端此处被 /* */ 注释掉,故不实现。
else:
return NAMECOLOR_PVP
# IAbstractPlayer::IsPartyMemberByVID(GetVirtualID())
if self_vid in ctx.get("party_vids", []):
return NAMECOLOR_PARTY
return NAMECOLOR_PC + _empire_id(self_e)
elif _is_npc(self_e):
return NAMECOLOR_NPC
elif EntityRules._is_enemy(self_e):
return NAMECOLOR_MOB
elif EntityRules._is_poly(self_e):
return NAMECOLOR_MOB
# 参考端 fallthrough`return D3DXCOLOR(0xffffffff);` 被当成 UINT 传给
# GetIndexedNameColor → 越界 → 白。这里用 -1 表达同一含义。
return -1
# CInstanceBase::GetNameColorInstanceBaseEffect.cpp:520
static func name_color(self_e: Dictionary, main_e: Dictionary, ctx: Dictionary) -> Color:
return indexed_name_color(name_color_index(self_e, main_e, ctx))
+180
View File
@@ -0,0 +1,180 @@
# name_color_test —— §8.8 头顶名字色 CInstanceBase::GetNameColorIndex / GetNameColor 1:1
# NAMECOLOR_* 序号、GetNameColorIndex 逐分支、SetEmpireNameMode 调色板重写、
# GetIndexedNameColor 越界回退,以及 introloading.__RegisterColor + colorInfo.py 的 RGB。
# godot --headless --path project --script name_color_test.gd
# 退出码 0 = 全过。
extends SceneTree
const NameColor = preload("res://name_color.gd")
const EntityRules = preload("res://entity_rules.gd")
var _fail := 0
func _ck(c: bool, m: String) -> void:
if not c:
_fail += 1
printerr("FAIL: " + m)
func _init() -> void:
_run()
if _fail == 0:
print("PASS: name_color_test (§8.8 GetNameColorIndex / SetEmpireNameMode 1:1)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
func _pc(vid: int, empire := 1, extra := {}) -> Dictionary:
var d := {"vid": vid, "kind": EntityRules.KIND_PC, "empire": empire, "guild": 0,
"alignment": 0, "pk_mode": 0, "state_flags": 0}
for k in extra:
d[k] = extra[k]
return d
func _ctx(extra := {}) -> Dictionary:
var c := {"main_vid": 1, "duel_mode": 0, "duel_opponents": [],
"pvp_pairs": [], "gvg_pairs": [], "party_vids": []}
for k in extra:
c[k] = extra[k]
return c
func _run() -> void:
# --- 1. NAMECOLOR_* 序号(InstanceBase.hEMPIRE_NUM = 4------------------
_ck(NameColor.NAMECOLOR_MOB == 0, "NAMECOLOR_MOB == 0")
_ck(NameColor.NAMECOLOR_NPC == 1, "NAMECOLOR_NPC == 1")
_ck(NameColor.NAMECOLOR_PC == 2, "NAMECOLOR_PC == 2")
_ck(NameColor.NAMECOLOR_NORMAL_MOB == 7, "NAMECOLOR_NORMAL_MOB == 7")
_ck(NameColor.NAMECOLOR_NORMAL_NPC == 8, "NAMECOLOR_NORMAL_NPC == 8")
_ck(NameColor.NAMECOLOR_NORMAL_PC == 9, "NAMECOLOR_NORMAL_PC == 9")
_ck(NameColor.NAMECOLOR_EMPIRE_MOB == 14, "NAMECOLOR_EMPIRE_MOB == 14")
_ck(NameColor.NAMECOLOR_EMPIRE_NPC == 15, "NAMECOLOR_EMPIRE_NPC == 15")
_ck(NameColor.NAMECOLOR_EMPIRE_PC == 16, "NAMECOLOR_EMPIRE_PC == 16")
_ck(NameColor.NAMECOLOR_FUNC == 21, "NAMECOLOR_FUNC == 21")
_ck(NameColor.NAMECOLOR_PK == 22, "NAMECOLOR_PK == 22")
_ck(NameColor.NAMECOLOR_PVP == 23, "NAMECOLOR_PVP == 23")
_ck(NameColor.NAMECOLOR_PARTY == 24, "NAMECOLOR_PARTY == 24")
_ck(NameColor.NAMECOLOR_WARP == 25, "NAMECOLOR_WARP == 25")
_ck(NameColor.NAMECOLOR_WAYPOINT == 26, "NAMECOLOR_WAYPOINT == 26")
_ck(NameColor.NAMECOLOR_NUM == 41, "NAMECOLOR_NUM == 41")
# --- 2. 帝国名色模式关(默认):GetNameColorIndex 逐分支 --------------------
NameColor.set_empire_name_mode(false)
_ck(not NameColor.is_empire_name_mode(), "default empire-name-mode OFF")
var main := _pc(1, 1)
# 普通 PC(同帝国、无关系)→ NAMECOLOR_PC + empire
_ck(NameColor.name_color_index(_pc(2, 1), main, _ctx()) == NameColor.NAMECOLOR_PC + 1,
"normal PC (same empire) -> NAMECOLOR_PC + empire(1)")
_ck(NameColor.name_color_index(_pc(2, 3), _pc(1, 3), _ctx()) == NameColor.NAMECOLOR_PC + 3,
"normal PC empire 3 (same-empire main) -> NAMECOLOR_PC + 3")
# 关模式下 PC..PC+3 槽全解析成 NORMAL_PC(黄)
_ck(NameColor.name_color(_pc(2, 1), main, _ctx()).is_equal_approx(Color8(255, 215, 76)),
"empire-off: PC name colour == CHR_NAME_RGB_PC (255,215,76)")
_ck(NameColor.name_color(_pc(2, 3), _pc(1, 3), _ctx()).is_equal_approx(Color8(255, 215, 76)),
"empire-off: empire-3 PC still yellow")
# m_isKiller → PK(最先判,压过一切)
var killer := _pc(2, 1, {"state_flags": EntityRules.STATE_KILLER})
_ck(NameColor.name_color_index(killer, main, _ctx()) == NameColor.NAMECOLOR_PK,
"killer PC -> NAMECOLOR_PK")
_ck(NameColor.name_color(killer, main, _ctx()).is_equal_approx(Color8(180, 100, 0)),
"PK name colour == CHR_NAME_RGB_PK (180,100,0)")
# 队伍成员 → PARTY
_ck(NameColor.name_color_index(_pc(7, 1), main, _ctx({"party_vids": [7]})) == NameColor.NAMECOLOR_PARTY,
"party member -> NAMECOLOR_PARTY")
_ck(NameColor.name_color(_pc(7, 1), main, _ctx({"party_vids": [7]})).is_equal_approx(Color8(128, 192, 255)),
"PARTY name colour == CHR_NAME_RGB_PARTY (128,192,255)")
# 但 killer 优先于 party
_ck(NameColor.name_color_index(_pc(7, 1, {"state_flags": EntityRules.STATE_KILLER}),
main, _ctx({"party_vids": [7]})) == NameColor.NAMECOLOR_PK,
"killer beats party")
# 异帝国 → PVP
_ck(NameColor.name_color_index(_pc(2, 2), _pc(1, 1), _ctx()) == NameColor.NAMECOLOR_PVP,
"different empire -> NAMECOLOR_PVP")
_ck(NameColor.name_color(_pc(2, 2), _pc(1, 1), _ctx()).is_equal_approx(Color8(238, 54, 223)),
"PVP name colour == CHR_NAME_RGB_PVP (238,54,223)")
# victim empire 0 视作同帝国(IsSameEmpire 短路 true)→ 不判 PVP
_ck(NameColor.name_color_index(_pc(2, 0), _pc(1, 1), _ctx()) == NameColor.NAMECOLOR_PC + 0,
"victim empire 0 -> same-empire, NAMECOLOR_PC + 0")
# 同帝国 + PVP 对表命中 → PVP
_ck(NameColor.name_color_index(_pc(2, 1), _pc(1, 1), _ctx({"pvp_pairs": [[1, 2]]})) == NameColor.NAMECOLOR_PVP,
"same empire + pvp pair -> NAMECOLOR_PVP")
# 同帝国 + 公会战对表命中(两边 guild 非 0)→ PVP
_ck(NameColor.name_color_index(_pc(2, 1, {"guild": 20}), _pc(1, 1, {"guild": 10}),
_ctx({"gvg_pairs": [[10, 20]]})) == NameColor.NAMECOLOR_PVP,
"same empire + gvg pair -> NAMECOLOR_PVP")
# guild 0 不触发公会战(防御性 !=0)
_ck(NameColor.name_color_index(_pc(2, 1), _pc(1, 1), _ctx({"gvg_pairs": [[0, 0]]})) == NameColor.NAMECOLOR_PC + 1,
"guild 0 gvg pair ignored")
# 决斗:主角 DUEL_CANNOTATTACK → 恒 NAMECOLOR_PC + empire(即便异帝国)
_ck(NameColor.name_color_index(_pc(2, 2), _pc(1, 1),
_ctx({"duel_mode": EntityRules.DUEL_CANNOTATTACK})) == NameColor.NAMECOLOR_PC + 2,
"main DUEL_CANNOTATTACK -> NAMECOLOR_PC + empire, no PVP")
# 决斗:主角 DUEL_START + 对手命中 → PVP;未命中 → NAMECOLOR_PC + empire
_ck(NameColor.name_color_index(_pc(2, 1), _pc(1, 1),
_ctx({"duel_mode": EntityRules.DUEL_START, "duel_opponents": [2]})) == NameColor.NAMECOLOR_PVP,
"main DUEL_START + opponent -> NAMECOLOR_PVP")
_ck(NameColor.name_color_index(_pc(2, 1), _pc(1, 1),
_ctx({"duel_mode": EntityRules.DUEL_START, "duel_opponents": [99]})) == NameColor.NAMECOLOR_PC + 1,
"main DUEL_START, not an opponent -> NAMECOLOR_PC + empire")
# 无主角实例(main_e 空)→ 跳过相对块,直接 party / NAMECOLOR_PC + empire
_ck(NameColor.name_color_index(_pc(2, 2), {}, _ctx()) == NameColor.NAMECOLOR_PC + 2,
"no main instance -> NAMECOLOR_PC + empire (no PVP)")
# 自己(vid == main_vid)→ 跳过相对块
_ck(NameColor.name_color_index(_pc(1, 2), _pc(1, 2), _ctx({"main_vid": 1})) == NameColor.NAMECOLOR_PC + 2,
"self -> NAMECOLOR_PC + empire")
# NPC / MOB / poly / 未知
_ck(NameColor.name_color_index({"kind": EntityRules.KIND_NPC}, main, _ctx()) == NameColor.NAMECOLOR_NPC,
"NPC -> NAMECOLOR_NPC")
_ck(NameColor.name_color({"kind": EntityRules.KIND_NPC}, main, _ctx()).is_equal_approx(Color8(122, 231, 93)),
"NPC name colour == CHR_NAME_RGB_NPC (122,231,93)")
_ck(NameColor.name_color_index({"kind": EntityRules.KIND_MONSTER}, main, _ctx()) == NameColor.NAMECOLOR_MOB,
"MONSTER -> NAMECOLOR_MOB")
_ck(NameColor.name_color({"kind": EntityRules.KIND_MONSTER}, main, _ctx()).is_equal_approx(Color8(235, 22, 9)),
"MOB name colour == CHR_NAME_RGB_MOB (235,22,9)")
_ck(NameColor.name_color_index({"kind": 99, "poly_vnum": 101}, main, _ctx()) == NameColor.NAMECOLOR_MOB,
"standalone poly -> NAMECOLOR_MOB")
_ck(NameColor.name_color_index({"kind": 99}, main, _ctx()) == -1,
"unknown kind -> -1 (fallthrough)")
_ck(NameColor.name_color({"kind": 99}, main, _ctx()).is_equal_approx(Color(1, 1, 1)),
"unknown kind name colour -> white")
# --- 3. SetEmpireNameMode(true):调色板重写 -------------------------------
NameColor.set_empire_name_mode(true)
_ck(NameColor.is_empire_name_mode(), "empire-name-mode ON")
# 序号不变,但槽解析改了
_ck(NameColor.name_color_index(_pc(2, 1), main, _ctx()) == NameColor.NAMECOLOR_PC + 1,
"empire-on: normal PC index unchanged (PC + empire)")
_ck(NameColor.name_color(_pc(2, 1), main, _ctx()).is_equal_approx(Color8(157, 0, 0)),
"empire-on: empire-1 PC -> CHR_NAME_RGB_EMPIRE_PC_A (157,0,0)")
_ck(NameColor.name_color(_pc(2, 2), _pc(1, 2), _ctx()).is_equal_approx(Color8(222, 160, 47)),
"empire-on: empire-2 PC -> CHR_NAME_RGB_EMPIRE_PC_B (222,160,47)")
_ck(NameColor.name_color(_pc(2, 3), _pc(1, 3), _ctx()).is_equal_approx(Color8(23, 30, 138)),
"empire-on: empire-3 PC -> CHR_NAME_RGB_EMPIRE_PC_C (23,30,138)")
# empire 0 的 PC 槽(NAMECOLOR_PC + 0)在 ON 分支仍是 NORMAL_PC(黄)
_ck(NameColor.name_color(_pc(2, 0), _pc(1, 1), _ctx()).is_equal_approx(Color8(255, 215, 76)),
"empire-on: empire-0 PC slot stays NORMAL_PC yellow")
# MOB / NPC 在这套 colorInfo 里帝国色 == 普通色
_ck(NameColor.name_color({"kind": EntityRules.KIND_MONSTER}, main, _ctx()).is_equal_approx(Color8(235, 22, 9)),
"empire-on: MOB colour unchanged (235,22,9)")
# 还原默认,避免污染后续(静态状态)
NameColor.set_empire_name_mode(false)
_ck(NameColor.name_color(_pc(2, 1), main, _ctx()).is_equal_approx(Color8(255, 215, 76)),
"restore empire-off: PC yellow again")
# --- 4. GetIndexedNameColor 越界 → 白 -----------------------------------
_ck(NameColor.indexed_name_color(-1).is_equal_approx(Color(1, 1, 1)), "indexed_name_color(-1) -> white")
_ck(NameColor.indexed_name_color(NameColor.NAMECOLOR_NUM).is_equal_approx(Color(1, 1, 1)),
"indexed_name_color(NUM) -> white")
_ck(NameColor.indexed_name_color(9999).is_equal_approx(Color(1, 1, 1)), "indexed_name_color(9999) -> white")
_ck(NameColor.indexed_name_color(NameColor.NAMECOLOR_WARP).is_equal_approx(Color8(136, 218, 241)),
"indexed_name_color(WARP) == CHR_NAME_RGB_WARP (136,218,241)")
_ck(NameColor.indexed_name_color(NameColor.NAMECOLOR_WAYPOINT).is_equal_approx(Color8(255, 255, 255)),
"indexed_name_color(WAYPOINT) == CHR_NAME_RGB_WAYPOINT (255,255,255)")
+77
View File
@@ -0,0 +1,77 @@
# name_show —— §8.4 / §8.6 头顶名字「是否显示」1:1CPythonSystem::IsAlwaysShowName /
# SetAlwaysShowNameFlag + game.py::__IsShowName + CPythonTextTail::ShowAllTextTail 的
# 3500 距离裁剪 + ShowCharacterTextTail 的资格判定 + UpdateAllTextTail 的 bNameFlag 强显。
# (与 name_color.gd / text_tail.gd / chat_tail.gd 同一套纯静态镜像套路。)
#
# REF40250 ClientVS22 source/ 为唯一基线):
# UserInterface/PythonSystem.cpp:5 #define DEFAULT_VALUE_ALWAYS_SHOW_NAME true
# UserInterface/PythonSystem.cpp:307 m_Config.bAlwaysShowName = DEFAULT_VALUE_ALWAYS_SHOW_NAME
# UserInterface/PythonSystem.cpp:327 IsAlwaysShowName() -> m_Config.bAlwaysShowName
# UserInterface/PythonSystem.cpp:334 SetAlwaysShowNameFlag(iFlag) -> bAlwaysShowName = (iFlag == 1)
# UserInterface/PythonSystem.cpp:449 config "ALWAYS_VIEW_NAME" -> atoi(value) == 1
# UserInterface/PythonSystem.cpp:535 save "ALWAYS_VIEW_NAME %d"(仅当 != 默认值时落盘)
# UserInterface/PythonSystemModule.cpp:181/214 systemSetAlwaysShowNameFlag / systemIsAlwaysShowName
# root/uigameoption.py:220 __OnClickAlwaysShowNameOnButton -> systemSetting.SetAlwaysShowNameFlag(True) + RefreshAlwaysShowName()
# root/game.py:323 onPressKeyDict[app.DIK_LALT] -> ShowName()
# root/game.py:406 onClickKeyDict[app.DIK_LALT] -> HideName()
# root/game.py:1143 ShowName() -> self.ShowNameFlag = True
# root/game.py:1149 __IsShowName() -> if IsAlwaysShowName(): return True; if ShowNameFlag: return True; return False
# root/game.py:1533 OnRenderchr.Pick() 命中 -> textTail.ShowCharacterTextTail(idx)
# root/game.py:1535 OnRendertargetBoard.GetTargetVID() != 0 -> textTail.ShowCharacterTextTail(vid)
# root/game.py:1550 OnRenderif __IsShowName(): textTail.ShowAllTextTail()
# UserInterface/PythonTextTail.cpp:416 UpdateDistancefDistanceFromPlayer = |(ownerX-centerX, -ownerY-centerY)|
# UserInterface/PythonTextTail.cpp:422 ShowAllTextTailm_CharacterTextTailMap 里 fDistanceFromPlayer < 3500.0f 才 ShowCharacterTextTail
# UserInterface/PythonTextTail.cpp:440 ShowCharacterTextTail:不在图 / 已入列 / !pOwner->isShow() / 无实例 / IsGuildWall() -> returnCanPickInstance() 才入列
# UserInterface/PythonTextTail.cpp:74 UpdateAllTextTailchat 尾标 bNameFlag -> ShowCharacterTextTail(不受 3500 限制)
#
# seam ⑩:参考端「名字显示」是每帧重建 m_CharacterTextTailListPick / target / ShowAll
# 再 Render / Arrange(防重叠)/ Hide 的屏幕空间流程;本移植是 net_world 里每个实体一块
# billboard Label3D,靠本模块谓词逐帧 set .visible。鼠标悬停 chr.Pick() 未接(net_world
# 无悬停拾取)、屏幕空间 ArrangeTextTail 防重叠未做 —— 记为 seam ⑩。
# 另:参考 UpdateDistance 因 owner 在渲染帧(Y 取反)、center 在像素帧才写成
# (ownerX-centerX, -ownerY-centerY);本移植所有坐标统一在 pos_cm 帧,等价于同帧平面距。
extends RefCounted
const ALWAYS_SHOW_NAME_DEFAULT := true # DEFAULT_VALUE_ALWAYS_SHOW_NAME
const SHOW_ALL_DISTANCE := 3500.0 # ShowAllTextTail 的 fDistanceFromPlayer < 3500px==cm
const CFG_KEY := "ALWAYS_VIEW_NAME" # PythonSystem 配置行 / 存盘键
# SetAlwaysShowNameFlag(int iFlag): m_Config.bAlwaysShowName = iFlag == 1
static func always_show_from_flag(flag: int) -> bool:
return flag == 1
# config "ALWAYS_VIEW_NAME" 行:atoi(value) == 1
static func always_show_from_cfg(value: Variant) -> bool:
return int(value) == 1
# __IsShowName() = IsAlwaysShowName() or ShowNameFlagLALT 按住)
static func is_show_name(always_show_name: bool, alt_held: bool) -> bool:
return always_show_name or alt_held
# ShowAllTextTail:只有平面距离 < 3500 的角色尾标才 ShowCharacterTextTail
static func within_show_all(dist_cm: float) -> bool:
return dist_cm < SHOW_ALL_DISTANCE
# UpdateDistance 的平面距。net_world 的 pos_cm 已是统一帧,直接同帧平面距
# (参考端的 -ownerY 是渲染帧 / 像素帧混用的产物,见文件头 seam ⑩ 注)。
static func planar_distance_cm(a_cm: Vector2, b_cm: Vector2) -> float:
return a_cm.distance_to(b_cm)
# ShowCharacterTextTail 的资格判定(把早退条件取反):
# pOwner->isShow()(可见 / 未死透)、!IsGuildWall()、CanPickInstance()
static func eligible_character(is_shown: bool, is_guild_wall: bool, can_pick: bool) -> bool:
return is_shown and not is_guild_wall and can_pick
# 综合:某实体这一帧的头顶名字是否显示。
# forced —— chr.Pick() 命中 / targetBoard VID / chat 尾标 bNameFlag:都走 ShowCharacterTextTail
# 但绕过 ShowAllTextTail 的 3500 裁剪(资格判定仍要过)。
# 非 forced —— 需 __IsShowName() 为真 且 距离 < 3500 且 资格通过。
static func name_visible_for(forced: bool, show_name: bool, dist_cm: float,
is_shown: bool, is_guild_wall: bool, can_pick: bool) -> bool:
if not eligible_character(is_shown, is_guild_wall, can_pick):
return false
if forced:
return true
if not show_name:
return false
return within_show_all(dist_cm)
+79
View File
@@ -0,0 +1,79 @@
# name_show_test —— §8.4 / §8.6 头顶名字「是否显示」1:1:
# CPythonSystem::IsAlwaysShowName / SetAlwaysShowNameFlagflag / 配置行 == 1)、
# game.py::__IsShowNameIsAlwaysShowName or ShowNameFlag)、
# CPythonTextTail::ShowAllTextTail 的 fDistanceFromPlayer < 3500、
# ShowCharacterTextTail 的 isShow / !IsGuildWall / CanPickInstance 资格判定,
# 以及 forcedPick / target / bNameFlag)绕过 3500 但仍过资格判定。
# godot --headless --path project --script name_show_test.gd
# 退出码 0 = 全过。
extends SceneTree
const NameShow = preload("res://name_show.gd")
var _fail := 0
func _ck(c: bool, m: String) -> void:
if not c:
_fail += 1
printerr("FAIL: " + m)
func _init() -> void:
_run()
if _fail == 0:
print("PASS: name_show_test (§8.4/§8.6 IsAlwaysShowName / __IsShowName / ShowAllTextTail 3500 1:1)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
func _run() -> void:
# --- 0. 常量(PythonSystem.cpp / PythonTextTail.cpp------------------------
_ck(NameShow.ALWAYS_SHOW_NAME_DEFAULT == true, "DEFAULT_VALUE_ALWAYS_SHOW_NAME == true")
_ck(is_equal_approx(NameShow.SHOW_ALL_DISTANCE, 3500.0), "ShowAllTextTail 距离阈 == 3500")
_ck(NameShow.CFG_KEY == "ALWAYS_VIEW_NAME", "配置键 == ALWAYS_VIEW_NAME")
# --- 1. SetAlwaysShowNameFlag(iFlag): bAlwaysShowName = iFlag == 1 ----------
_ck(NameShow.always_show_from_flag(1) == true, "flag 1 -> true")
_ck(NameShow.always_show_from_flag(0) == false, "flag 0 -> false")
_ck(NameShow.always_show_from_flag(2) == false, "flag 2 -> false(严格 == 1")
# --- 2. config "ALWAYS_VIEW_NAME" -> atoi(value) == 1 ---------------------
_ck(NameShow.always_show_from_cfg("1") == true, "cfg \"1\" -> true")
_ck(NameShow.always_show_from_cfg("0") == false, "cfg \"0\" -> false")
_ck(NameShow.always_show_from_cfg("2") == false, "cfg \"2\" -> false")
_ck(NameShow.always_show_from_cfg(1) == true, "cfg int 1 -> true")
# --- 3. __IsShowName() = IsAlwaysShowName() or ShowNameFlag ---------------
_ck(NameShow.is_show_name(false, false) == false, "都关 -> 不显示")
_ck(NameShow.is_show_name(true, false) == true, "总显示名字开 -> 显示")
_ck(NameShow.is_show_name(false, true) == true, "LALT 按住 -> 显示")
_ck(NameShow.is_show_name(true, true) == true, "都开 -> 显示")
# --- 4. ShowAllTextTailfDistanceFromPlayer < 3500.0f -------------------
_ck(NameShow.within_show_all(0.0) == true, "距 0 在范围内")
_ck(NameShow.within_show_all(3499.9) == true, "距 3499.9 在范围内")
_ck(NameShow.within_show_all(3500.0) == false, "距 3500.0 严格 < 不含边界")
_ck(NameShow.within_show_all(5000.0) == false, "距 5000 超范围")
# --- 5. UpdateDistance 平面距(本移植同帧)------------------------------
_ck(is_equal_approx(NameShow.planar_distance_cm(Vector2(0, 0), Vector2(3, 4)), 5.0), "(0,0)-(3,4) == 5")
_ck(is_equal_approx(NameShow.planar_distance_cm(Vector2(10, 10), Vector2(10, 10)), 0.0), "同点距 0")
_ck(is_equal_approx(NameShow.planar_distance_cm(Vector2(-100, 0), Vector2(200, 0)), 300.0), "x 轴距 300")
# --- 6. ShowCharacterTextTail 资格:isShow && !IsGuildWall && CanPickInstance
_ck(NameShow.eligible_character(true, false, true) == true, "可见 / 非墙 / 可拾取 -> 合格")
_ck(NameShow.eligible_character(false, false, true) == false, "!isShow() -> 早退")
_ck(NameShow.eligible_character(true, true, true) == false, "IsGuildWall() -> 早退")
_ck(NameShow.eligible_character(true, false, false) == false, "!CanPickInstance() -> 不入列")
# --- 7. name_visible_for 综合 ------------------------------------------
# 公会墙:无论 forced 与否都不显示
_ck(NameShow.name_visible_for(true, true, 0.0, true, true, true) == false, "公会墙即便 forced 也不显示")
# forcedPick / target / bNameFlag):绕过 3500,但资格仍要过
_ck(NameShow.name_visible_for(true, false, 9999.0, true, false, true) == true, "forced + 合格 + 超距 -> 显示(绕过 3500")
_ck(NameShow.name_visible_for(true, true, 0.0, false, false, true) == false, "forced 但 !isShow() -> 不显示")
_ck(NameShow.name_visible_for(true, true, 0.0, true, false, false) == false, "forced 但 !CanPickInstance() -> 不显示")
# 非 forced:要 __IsShowName() 且 < 3500 且 合格
_ck(NameShow.name_visible_for(false, false, 100.0, true, false, true) == false, "非 forced + __IsShowName 假 -> 不显示")
_ck(NameShow.name_visible_for(false, true, 1000.0, true, false, true) == true, "非 forced + 显示开 + 距 1000 + 合格 -> 显示")
_ck(NameShow.name_visible_for(false, true, 4000.0, true, false, true) == false, "非 forced + 显示开 但 距 4000 -> 被 3500 裁掉")
_ck(NameShow.name_visible_for(false, true, 1000.0, false, false, true) == false, "非 forced + 显示开 + 近 但 死透 -> 不显示")
+1325 -65
View File
File diff suppressed because it is too large Load Diff
+405 -59
View File
@@ -16,11 +16,19 @@
# nw.main_entity_ready.connect(func(node, vid): _cam.target = node)
extends Node
const FlyObject = preload("res://fly_object.gd")
const NameColor = preload("res://name_color.gd")
const TextTail = preload("res://text_tail.gd")
const ChatTail = preload("res://chat_tail.gd")
const NameShow = preload("res://name_show.gd")
const EntityRules = preload("res://entity_rules.gd")
signal entity_added(node: Node3D, vid: int)
signal entity_removed(vid: int)
signal main_entity_ready(node: Node3D, vid: int)
signal chat_line(type: int, vid: int, text: String)
signal fly_targeting(shooter_vid: int, target_vid: int, target_cm: Vector2, append: bool)
signal fly_exploded(world_pos: Vector3, cause: String) # §3.6 飞行物命中 / 超程 / 触地
signal fishing_event(vid: int, subheader: int, dir: int)
signal motion_event(vid: int, victim_vid: int, motion: int)
signal vitals(vid: int, hp: int, max_hp: int, dead: bool)
@@ -58,8 +66,10 @@ const WALL_RACES := [14201, 14202, 14203, 14204]
var client: Node # M2Client
var parent: Node3D # 实体挂这下面
var world: Node # Metin2World(可空,用 sample_height 贴地)
var follow_lerp := 14.0 # 位置逼近速度
var snap_dist := 6.0 # 超过这么多米直接瞬移(初次出现/大跳)
# §3.2 后位置插值全在 C++ EntityStore::tick() 里做,这里直接跟随 e.pos。
# 两个字段保留只为兼容外部读取 / 旧测试,_process 不再使用。
var follow_lerp := 14.0
var snap_dist := 6.0
var _by_vid := {} # vid:int -> Node3D
var _main_vid := 0
@@ -67,10 +77,35 @@ var _local_vid := 0 # 由 net_play 设:这个 vid 由本地
var _local_node: Node3D # 本地玩家的真模型(不挂在 _mount 下)
var _model_factory: Callable # func(Dictionary) -> Node3D
var name_resolver: Callable = Callable() # func(entity_dict) -> String(怪名走 mob_proto
var kind_resolver: Callable = Callable() # §8.8 func(entity_dict) -> int0 PC/1 NPC/2 怪/3 石/4 warpnet_play._entity_kind
var title_name_resolver: Callable = Callable() # §8.8 func(grade:int) -> StringPVP_LEVEL<grade> 本地化;未注入回退裸 key,seam ⑧)
var _name_color_accum := 0.0 # §8.8 头顶名字色 / 称号标签的节流刷新累加器(~2Hz)
var _chat_tails := {} # §8.8 vid:int -> {text,color,name_flag,living_time}CPythonTextTail::m_ChatTailMap
var _pvp_relations := {} # unordered pair key -> {src_vid,dst_vid,mode}
var _duel_opponents := {} # server-provided duel opponent VID set
var _duel_cannot_attack := false
var _fading := {} # vid:int -> true while a culled node tweens out (not re-created until done)
# §8.4 / §8.6 头顶名字「是否显示」(name_show.gd)。game.py::__IsShowName() 的两个来源:
# _always_show_name —— uigameoption「总显示名字」radioCPythonSystem::IsAlwaysShowName
# _alt_held —— LALT 按住(game.py ShowName/HideName 的 ShowNameFlag
# 另加 forced 路径:_target_vidtargetBoard.GetTargetVID()+ chat 尾标 bNameFlag。
var _always_show_name := NameShow.ALWAYS_SHOW_NAME_DEFAULT
var _alt_held := false
var _target_vid := 0
var _fly_mgr: Node # FlyManagerfly_object.gd)—— GC_CREATE_FLY 的真实弹道 + 命中判定。§3.6
# Fishing surface provider bridge. NetWorld owns entity presentation while
# Metin2World owns map data; keep the ClientVS22 GetFishingRot query on the
# latter and expose it without making NetPlay depend on the native class.
func get_fishing_rotation(gx_m: float, gz_m: float, heading_deg: float) -> float:
if world == null or not world.has_method("get_fishing_rotation"):
return -2.0 # provider unavailable; NetPlay keeps the protocol-only fallback
return float(world.call("get_fishing_rotation", gx_m, gz_m, heading_deg))
func can_fishing_position(gx_m: float, gz_m: float, heading_deg: float) -> bool:
if world == null or not world.has_method("can_fishing_position"):
return true
return bool(world.call("can_fishing_position", gx_m, gz_m, heading_deg))
func setup(m2client: Node, mount: Node3D) -> void:
client = m2client
@@ -82,8 +117,10 @@ func setup(m2client: Node, mount: Node3D) -> void:
client.entity_info.connect(_on_info)
client.chat.connect(func(t, v, s):
chat_line.emit(t, v, s)
if t in [0, 3, 4, 6]: # 普通/队伍/公会/喊话 -> 头顶气泡
_bubble(int(v), String(s)))
# §8.8 RecvChatPacket:1372 —— 带 VID 的聊天里,除 SHOUT / COMMAND 外都进头顶「聊天尾标」
# line 去掉 "名字: " 前缀,INFO/NOTICE 类不去)
if int(v) != 0 and ChatTail.wants_chat_tail(int(t)):
_bubble(int(v), ChatTail.line_for_tail(int(t), String(s))))
# entity_moved 不强依赖:_process 每帧统一插值。留个钩子便于调试。
client.entity_moved.connect(_on_moved)
client.vitals_changed.connect(_on_vitals)
@@ -103,6 +140,12 @@ func setup(m2client: Node, mount: Node3D) -> void:
client.pvp_changed.connect(_on_pvp_changed)
if client.has_signal("duel_changed"):
client.duel_changed.connect(_on_duel_changed)
# §3.6 飞行物实体:GC_CREATE_FLY 之后由 FlyManager 跑真实弹道 + 线段命中判定。
_fly_mgr = FlyObject.new()
_fly_mgr.name = "FlyManager"
add_child(_fly_mgr)
_fly_mgr.setup(parent, world)
_fly_mgr.exploded.connect(func(wp: Vector3, cause: String): fly_exploded.emit(wp, cause))
# 已在局内(重连 / setup 是协程,进来时 spawn burst 已被 pump 抽干)——
# 把当前所有实体补建一遍,别漏掉进游戏那一批怪 / NPC / 玩家。
@@ -134,6 +177,83 @@ func set_local_vid(vid: int) -> void:
func set_local_node(node: Node3D) -> void:
_local_node = node
# game.py ShowName/HideName toggles actor name plates without changing the
# network data layer. Shop/PVP labels and chat bubbles remain independent.
# LALT 按 / 抬 -> game.py ShowNameFlag__IsShowName 的一半)。
func set_names_visible(visible: bool) -> void:
_alt_held = visible
_refresh_all_name_visibility()
func names_visible() -> bool:
return _alt_held
# uigameoption「总显示名字」radio -> CPythonSystem::SetAlwaysShowNameFlag__IsShowName 的另一半)。
func set_always_show_name(on: bool) -> void:
_always_show_name = on
_refresh_all_name_visibility()
# targetBoard.GetTargetVID():目标始终强显名字(game.py OnRender ShowCharacterTextTail(targetVID))。
func set_target_vid(vid: int) -> void:
if _target_vid == vid:
return
var prev := _target_vid
_target_vid = vid
for v in [prev, vid]:
if v != 0 and _by_vid.has(v):
_set_name_visibility(_by_vid[v])
func _refresh_all_name_visibility() -> void:
for node in _by_vid.values():
_set_name_visibility(node)
# game.py OnRender:本地主角平面坐标(ShowAllTextTail 的距离中心);未知返回 (INF,INF)。
func _main_planar_cm() -> Vector2:
if client and client.has_method("get_entity") and _main_vid != 0:
var m: Dictionary = client.get_entity(_main_vid)
if not m.is_empty() and m.get("pos_cm", null) is Vector3:
var mc: Vector3 = m["pos_cm"]
return Vector2(mc.x, mc.y)
return Vector2(INF, INF)
# name_show.name_visible_for 的 net_world 侧组装:forcedtarget / chat bNameFlag+
# __IsShowName()_always_show_name or _alt_held+ 3500 平面距 + isShow/wall/CanPick 资格。
func _name_shown(root: Node3D) -> bool:
if root == null or not is_instance_valid(root):
return false
var vid := int(root.get_meta("vid", 0))
var e: Dictionary = {}
if vid != 0 and client and client.has_method("get_entity"):
e = client.get_entity(vid)
var dead := bool(e.get("dead", false))
var is_wall := _is_wall(int(e.get("race", 0)))
# CanPickInstance() 更细(隐身 / 特定 affect 亦不可拾取)—— 这里只按死透近似,记 seam ⑩。
var alive := not dead
var forced := vid != 0 and vid == _target_vid
var ct: Variant = _chat_tails.get(vid, null)
if ct is Dictionary and bool(ct.get("name_flag", false)):
forced = true
var show_name := NameShow.is_show_name(_always_show_name, _alt_held)
var dist := 0.0 # 主角位置未知 -> 不按距离裁剪(fail-open,同 _is_visible_entity
if not e.is_empty() and e.get("pos_cm", null) is Vector3:
var mc := _main_planar_cm()
if mc.x != INF:
dist = NameShow.planar_distance_cm(_planar_cm(e), mc)
return NameShow.name_visible_for(forced, show_name, dist, alive, is_wall, alive)
func _set_name_visibility(root: Node3D) -> void:
if root == null or not is_instance_valid(root):
return
var shown := _name_shown(root)
var tag := root.get_node_or_null("Label3D") as Label3D
if tag:
tag.visible = shown
# §8.8 称号 / 等级 / 公会名子标签随主名字整体开关;各自「是否有内容」仍由
# _apply_text_tail 按 RefreshTextTail 决定。
for sub in ["GuildTag", "TitleTag", "LevelTag"]:
var st := root.get_node_or_null(sub) as Label3D
if st:
st.visible = shown and st.text != ""
# ClientVS22 destroys the network actor manager when Loading starts. This is
# called from M2Client.world_reset before the replacement connection sends its
# first GC_MAIN_CHARACTER/GC_CHARACTER_ADD burst.
@@ -202,10 +322,15 @@ func _on_spawn(d: Dictionary) -> void:
node.set_meta("vid", vid)
node.set_meta("func", int(d.get("func", FUNC_WAIT)))
node.set_meta("mount_vnum", int(d.get("mount_vnum", 0)))
var ch_type := int(d.get("ch_type", 0))
node.set_meta("cursor_shape", "TALK" if ch_type in [1, 4] else "ATTACK")
_by_vid[vid] = node
_set_name_visibility(node)
_refresh_shop_sign(node, str(d.get("shop_sign", "")))
_refresh_pvp_tag(vid)
_apply_field_updates(node, d)
_apply_name_color(vid)
_apply_text_tail(vid)
entity_added.emit(node, vid)
if vid == _main_vid:
main_entity_ready.emit(node, vid)
@@ -223,6 +348,8 @@ func _on_info(vid: int, d: Dictionary) -> void:
n.set_display_name(nm)
_refresh_shop_sign(n, str(d.get("shop_sign", "")))
_apply_field_updates(n, d)
_apply_name_color(vid)
_apply_text_tail(vid)
# §2.5: push the mutable entity fields onto a live view in the exact order the
# reference client uses (CNetworkActorManager refresh -> NetworkActorManager.cpp
@@ -230,12 +357,18 @@ func _on_info(vid: int, d: Dictionary) -> void:
# SetAffectFlagContainer -> SetMoveSpeed -> SetAttackSpeed -> SetAlignment ->
# SetPKMode -> SetStateFlags. Every setter is optional: the placeholder capsule
# and views that do not model a field simply skip it.
#
# §4.1 修改 5(远端换装):armor/weapon/hair 字段顺序已 1:1(上面三行)。变身 / 时装
# 遮挡由模型侧统一处理 —— 真模型(PlayerView + EquipModel)在 refresh() 里过
# part_hiding.gd(见 equip_model.gd)。远端 PC 由 RemotePlayerView 消费
# awPart[CHR_EQUIPPART_ARMOR..HAIR];本地玩家仍由 EquipModel 读取 get_equipment()。
func _apply_field_updates(n: Node3D, d: Dictionary) -> void:
var parts: Variant = d.get("parts", [])
if parts is Array and parts.size() >= 3:
if parts is Array and parts.size() >= 4:
_set_wear_slot(n, "set_armor", 0, int(parts[0]))
_set_wear_slot(n, "set_weapon", 1, int(parts[1]))
_set_wear_slot(n, "set_hair", 2, int(parts[2]))
_set_wear_slot(n, "set_head", 2, int(parts[2]))
_set_wear_slot(n, "set_hair", 3, int(parts[3]))
if n.has_method("set_guild"):
n.call("set_guild", int(d.get("guild", 0)))
if n.has_method("set_affect_flags"):
@@ -366,9 +499,167 @@ func _display_name(d: Dictionary) -> String:
return r
return str(d.get("name", ""))
# --- §8.8 头顶名字色(CInstanceBase::GetNameColor--------------------------------
# net_world 直接持有 M2Client,能自建 name_color_index 需要的会话上下文
# net_play._attack_ctx 同款)。kind_resolver 由 game_scene 注入 net_play._entity_kind
# 以补本 fork「NPC/怪 bType 都是 0」的分类缺口;未注入时退化到 ch_type。
func _entity_kind_of(d: Dictionary) -> int:
if kind_resolver.is_valid():
return int(kind_resolver.call(d))
return int(d.get("ch_type", 0))
func _name_color_ctx() -> Dictionary:
var duel: Dictionary = client.get_duel() if client and client.has_method("get_duel") else {}
var duel_mode := EntityRules.DUEL_NONE
if bool(duel.get("active", false)):
duel_mode = EntityRules.DUEL_CANNOTATTACK if bool(duel.get("cannot_attack", false)) \
else EntityRules.DUEL_START
var pvp_pairs: Array = []
if client and client.has_method("get_pvp_relations"):
for r in client.get_pvp_relations():
if int(r.get("mode", 0)) != 0:
pvp_pairs.append([int(r.get("src_vid", 0)), int(r.get("dst_vid", 0))])
var gvg_pairs: Array = []
if client and client.has_method("get_guild_wars"):
for w in client.get_guild_wars():
gvg_pairs.append([int(w.get("src", 0)), int(w.get("dst", 0))])
var party_vids: Array = []
if client and client.has_method("get_party"):
for m in client.get_party():
party_vids.append(int(m.get("vid", 0)))
return {
"main_vid": _main_vid,
"duel_mode": duel_mode,
"duel_opponents": duel.get("opponents", []),
"pvp_pairs": pvp_pairs,
"gvg_pairs": gvg_pairs,
"party_vids": party_vids,
}
func _main_name_snapshot() -> Dictionary:
if _main_vid == 0 or client == null or not client.has_method("get_entity"):
return {}
var m: Dictionary = client.get_entity(_main_vid)
if m.is_empty():
return {}
m = m.duplicate()
m["kind"] = _entity_kind_of(m)
return m
func _apply_name_color(vid: int) -> void:
if vid == 0:
return
var n: Node3D = _by_vid.get(vid, null)
if n == null or not is_instance_valid(n) or not n.has_node("Label3D"):
return
if client == null or not client.has_method("get_entity"):
return
var e: Dictionary = client.get_entity(vid)
if e.is_empty():
return
var self_e := e.duplicate()
self_e["kind"] = _entity_kind_of(e)
var col := NameColor.name_color(self_e, _main_name_snapshot(), _name_color_ctx())
(n.get_node("Label3D") as Label3D).modulate = col
func _retint_all_names() -> void:
if client == null or not client.has_method("get_entity"):
return
var ctx := _name_color_ctx()
var main_e := _main_name_snapshot()
for vid in _by_vid.keys():
var n: Node3D = _by_vid[vid]
if not is_instance_valid(n) or not n.has_node("Label3D"):
continue
var e: Dictionary = client.get_entity(int(vid))
if e.is_empty():
continue
var self_e := e.duplicate()
self_e["kind"] = _entity_kind_of(e)
(n.get_node("Label3D") as Label3D).modulate = NameColor.name_color(self_e, main_e, ctx)
# constinfo.SET_CHRNAME_COLOR_INDEX 对应的运行期开关(默认关)。改后立即整表重刷。
func set_empire_name_mode(enabled: bool) -> void:
NameColor.set_empire_name_mode(enabled)
_retint_all_names()
# --- §8.8 头顶「公会名 / 称号 / 等级」标签(CInstanceBase::RefreshTextTail 系)--------
# _apply_name_color 的姊妹:把 self_e 快照喂给 text_tail.gd,落到 GuildTag/TitleTag/LevelTag。
func _resolve_title_name(grade: int) -> String:
if title_name_resolver.is_valid():
var s: String = str(title_name_resolver.call(grade))
if s != "":
return s
return TextTail.title_name_key(grade) # seam ⑧:未注入 Locale -> 裸 key
func _resolve_guild_name(guild_id: int) -> String:
if guild_id != 0 and client and client.has_method("get_guild_name"):
return str(client.get_guild_name(guild_id))
return ""
func _apply_text_tail_to(n: Node3D, e: Dictionary) -> void:
if n == null or not is_instance_valid(n):
return
var alignment := int(e.get("alignment", 0))
var level := int(e.get("level", 0))
var guild_id := int(e.get("guild", 0))
var shown := _name_shown(n)
var gtag := n.get_node_or_null("GuildTag") as Label3D
if gtag:
var g := TextTail.refresh_guild(guild_id, _resolve_guild_name(guild_id))
gtag.text = g["name"] if g["show_guild"] else ""
gtag.modulate = g["color"]
gtag.visible = shown and gtag.text != ""
var ttag := n.get_node_or_null("TitleTag") as Label3D
if ttag:
var t := TextTail.refresh_title(alignment)
ttag.text = _resolve_title_name(int(t["grade"])) if t["show_title"] else ""
ttag.modulate = t["color"]
ttag.visible = shown and ttag.text != ""
var ltag := n.get_node_or_null("LevelTag") as Label3D
if ltag:
var lv := TextTail.refresh_level(level)
ltag.text = lv["text"] if lv["show_level"] else ""
ltag.modulate = lv["color"]
ltag.visible = shown and ltag.text != ""
func _apply_text_tail(vid: int) -> void:
if vid == 0 or client == null or not client.has_method("get_entity"):
return
var n: Node3D = _by_vid.get(vid, null)
if n == null or not is_instance_valid(n):
return
var e: Dictionary = client.get_entity(vid)
if e.is_empty():
return
_apply_text_tail_to(n, e)
func _retint_all_text_tails() -> void:
if client == null or not client.has_method("get_entity"):
return
for vid in _by_vid.keys():
var n: Node3D = _by_vid[vid]
if not is_instance_valid(n):
continue
var e: Dictionary = client.get_entity(int(vid))
if e.is_empty():
continue
_apply_text_tail_to(n, e)
# CPythonTextTail::EnablePKTitlegame.py:155 用 constInfo.PVPMODE_ENABLE 初始化)。
func set_pk_title_enabled(enabled: bool) -> void:
TextTail.set_pk_title_enabled(enabled)
_retint_all_text_tails()
func _on_despawn(vid: int) -> void:
_duel_opponents.erase(vid)
_fading.erase(vid)
_chat_tails.erase(vid)
var n: Node3D = _by_vid.get(vid, null)
if n:
_by_vid.erase(vid)
@@ -380,6 +671,8 @@ func _on_main_set(vid: int) -> void:
var n: Node3D = _by_vid.get(vid, null)
if n:
main_entity_ready.emit(n, vid)
# 主角变化会改写所有 PC 的相对名字色(同帝国 / PVP / 决斗 / 队伍判定都相对主角)。
_retint_all_names()
func _on_moved(_vid: int) -> void:
pass # 插值在 _process 里统一做
@@ -425,8 +718,6 @@ func _on_motion(vid: int, victim_vid: int, motion: int) -> void:
n.call("set_anim_state", state)
else:
n.set_meta("motion_state", state)
if n.has_node("Label3D"):
(n.get_node("Label3D") as Label3D).modulate = _state_tint(state)
# GC_PVP: 显示当前与任一角色的挑战 / 战斗 / 复仇关系。原客户端同时会影响
# TargetBoard 和名字颜色;这里先提供始终可见的世界标签,避免关系状态无表现。
@@ -440,6 +731,8 @@ func _on_pvp_changed(src_vid: int, dst_vid: int, mode: int) -> void:
_pvp_relations[key] = {"src_vid": src_vid, "dst_vid": dst_vid, "mode": mode}
_refresh_pvp_tag(src_vid)
_refresh_pvp_tag(dst_vid)
_apply_name_color(src_vid)
_apply_name_color(dst_vid)
func _pvp_key(a: int, b: int) -> String:
return "%d:%d" % [min(a, b), max(a, b)]
@@ -496,34 +789,60 @@ func _on_duel_changed(duel: Dictionary) -> void:
_duel_opponents[opponent] = true
for vid in _by_vid.keys():
_refresh_pvp_tag(int(vid))
_apply_name_color(int(vid))
# 头顶聊天气泡(~4s 淡出)。本地玩家 vid 走 main_bubble 信号让上层处理
# 头顶聊天 / 信息尾标(CPythonTextTail::RegisterChatTail / RegisterInfoTail
# 每 vid 一条 "bubble" 子节点,LivingTime 到点(默认 5s)由 _expire_chat_tails 硬删——
# 参考端无淡出。本地玩家 vid 走 main_bubble 信号让上层(game_scene._player_bubble)处理。
signal main_bubble(text: String)
func _bubble(vid: int, text: String) -> void:
# CPythonTextTail::RegisterChatTail —— 普通聊天尾标(白、bNameFlag TRUE
if vid == _local_vid or vid == 0:
main_bubble.emit(text)
return
var n: Node3D = _by_vid.get(vid, null)
if n == null:
return
var old := n.get_node_or_null("bubble")
if old:
old.queue_free()
var lbl := Label3D.new()
lbl.name = "bubble"
lbl.text = text
lbl.position.y = 2.35
lbl.billboard = BaseMaterial3D.BILLBOARD_ENABLED
lbl.no_depth_test = true
lbl.pixel_size = 0.0055
lbl.modulate = Color(1, 1, 1)
lbl.outline_size = 6
n.add_child(lbl)
var tw := create_tween()
tw.tween_interval(3.0)
tw.tween_property(lbl, "modulate:a", 0.0, 1.0)
tw.tween_callback(lbl.queue_free)
_apply_chat_tail(n, ChatTail.register_chat(_chat_tails, vid, text, Time.get_ticks_msec()))
func register_info_tail(vid: int, text: String) -> void:
# CPythonTextTail::RegisterInfoTail —— 信息尾标(淡红、bNameFlag FALSE)。
# game.py:930 / :936 技能 / 射击错误尾标走这里;与聊天尾标共用同一张 _chat_tails。
if vid == _local_vid or vid == 0:
main_bubble.emit(text)
return
var n: Node3D = _by_vid.get(vid, null)
if n == null:
return
_apply_chat_tail(n, ChatTail.register_info(_chat_tails, vid, text, Time.get_ticks_msec()))
func _apply_chat_tail(n: Node3D, tail: Dictionary) -> void:
var lbl := n.get_node_or_null("bubble") as Label3D
if lbl == null:
lbl = Label3D.new()
lbl.name = "bubble"
lbl.position.y = 2.35
lbl.billboard = BaseMaterial3D.BILLBOARD_ENABLED
lbl.no_depth_test = true
lbl.pixel_size = 0.0055
lbl.outline_size = 6
n.add_child(lbl)
lbl.text = String(tail.get("text", ""))
lbl.modulate = tail.get("color", ChatTail.CHAT_COLOR)
# UpdateAllTextTailchat 尾标 bNameFlag TRUE 时强制显示该角色名字(不受 3500 限制)。
_set_name_visibility(n)
func _expire_chat_tails() -> void:
# UpdateAllTextTailLivingTime < now 即删除对应 "bubble" 节点
for vid in ChatTail.expire(_chat_tails, Time.get_ticks_msec()):
var n: Node3D = _by_vid.get(vid, null)
if n:
var lbl := n.get_node_or_null("bubble")
if lbl:
lbl.queue_free()
# 尾标没了 -> bNameFlag 强显撤销,名字回到 __IsShowName / 距离裁决。
_set_name_visibility(n)
func _on_vitals(vid: int) -> void:
var n: Node3D = _by_vid.get(vid, null)
@@ -549,12 +868,15 @@ func _on_dead(vid: int) -> void:
if n.has_method("set_anim_state"):
n.call("set_anim_state", "dead")
# P6:飞行道具(GC_CREATE_FLY)—— 从 start 实体飞向 end 实体。原客户端在
# GC_CREATE_FLY 之前可能只收到 FLY_TARGETINGtarget VID=0 时是坐标),所以
# 这里也消费 shooter 的目标队列,避免远端技能没有终点被静默丢弃。
# P6:飞行道具(GC_CREATE_FLY)—— 从 start 实体飞向 end 实体 / 坐标。原客户端在
# GC_CREATE_FLY 之前可能只收到 FLY_TARGETINGtarget VID=0 时是坐标),所以这里也消费
# shooter 的目标队列,避免远端技能没有终点被静默丢弃。§3.6:交给 FlyManager 跑真实弹道
# CFlyingManager::CreateIndexedFly 的 INDEX_FLY_TYPE_NORMAL 分支:起点取射手像素位、
# 终点取目标 actor、canAttack=false —— 命中 / 伤害是服务端权威,客户端只做可视 + 命中时机)。
func _on_fly(_type: int, start_vid: int, end_vid: int) -> void:
var a: Vector3 = _entity_pos(start_vid)
var b: Vector3 = _entity_pos(end_vid)
var target_node: Node3D = _by_vid.get(end_vid, null)
var shooter: Node3D = _by_vid.get(start_vid, null)
if b == Vector3.INF and shooter:
var target: Dictionary = {}
@@ -565,28 +887,28 @@ func _on_fly(_type: int, start_vid: int, end_vid: int) -> void:
var target_vid := int(target.get("vid", 0))
if target_vid != 0:
b = _entity_pos(target_vid)
target_node = _by_vid.get(target_vid, null)
else:
var target_cm: Variant = target.get("cm", null)
if target_cm is Vector2:
b = _server_cm_to_world(target_cm)
target_node = null
if a == Vector3.INF or b == Vector3.INF or parent == null:
return
var proj := MeshInstance3D.new()
var sm := SphereMesh.new()
sm.radius = 0.08
sm.height = 0.16
proj.mesh = sm
var mat := StandardMaterial3D.new()
mat.albedo_color = Color(1.0, 0.9, 0.4)
mat.emission_enabled = true
mat.emission = Color(0.9, 0.7, 0.2)
proj.material_override = mat
parent.add_child(proj)
proj.global_position = a + Vector3(0, 1.0, 0)
var dur := maxf(0.15, a.distance_to(b) / 40.0)
var tw := create_tween()
tw.tween_property(proj, "global_position", b + Vector3(0, 1.0, 0), dur)
tw.tween_callback(proj.queue_free)
if _fly_mgr == null:
return
_fly_mgr.parent = parent
_fly_mgr.world = world
var start_w := a + Vector3(0, 1.0, 0)
var goal_w := b + Vector3(0, 1.0, 0)
# POC 无 .fly 加载器:合成一组弓箭弹道参数(换算成米),其余留 CFlyingData 默认。
var d = FlyObject.FlyData.new()
d.init_vel = 45.0 # ~4500 cm/s 的箭速
d.flat_range = maxf(start_w.distance_to(goal_w) * 1.5, 30.0)
d.bomb_range = 0.6 # 60 cm 命中球
d.pierce_count = 0
var tgt: Variant = target_node if is_instance_valid(target_node) else goal_w
_fly_mgr.spawn(start_w, tgt, false, d, _type)
# GC_FLY_TARGETING / GC_ADD_FLY_TARGETING — retain the authoritative target on
# the shooter node so a later projectile cue can use it even before a target
@@ -688,6 +1010,17 @@ func _process(dt: float) -> void:
if client == null or parent == null:
return
_update_visibility()
# §8.8:名字色随队伍 / 帝国名色模式 / PVP·决斗边沿变化——用 ~2Hz 节流整表刷新
# 兜底(单发路径已覆盖 spawn/info/pvp/duel/main_set;参考端每帧都算 GetNameColor)。
_name_color_accum += dt
if _name_color_accum >= 0.5:
_name_color_accum = 0.0
_retint_all_names()
_retint_all_text_tails()
_expire_chat_tails()
# §8.4 / §8.6ShowAllTextTail 的 3500 平面距随主角 / 实体移动变化 —— 逐帧太密,
# 用同一 ~2Hz 节流兜底(LALT / 选项 / 选中 / 尾标切换已各自即时刷新)。
_refresh_all_name_visibility()
for vid in _by_vid.keys():
var n: Node3D = _by_vid[vid]
if not is_instance_valid(n):
@@ -696,11 +1029,11 @@ func _process(dt: float) -> void:
var e: Dictionary = client.get_entity(vid)
if e.is_empty():
continue
# §3.2:位置插值现在完全在 C++ EntityStore::tick() 里做(命令队列 +
# SNetworkActorData::UpdatePosition 式线性插值),这里只跟随 e.pos,不再
# 叠第二层 follow_lerp——否则会把 C++ 的到达时机拖慢、和动作事件脱节。
var want := _grounded(e.get("pos", n.position))
if n.position.distance_to(want) > snap_dist:
n.position = want
else:
n.position = n.position.lerp(want, clampf(follow_lerp * dt, 0.0, 1.0))
n.position = want
# 朝向:Metin2 heading(度) -> Godot yaw。经验换算,真机再校准。
var yaw := MapCoord.heading_to_yaw(float(e.get("angle_deg", 0.0)))
n.rotation.y = lerp_angle(n.rotation.y, yaw, clampf(10.0 * dt, 0.0, 1.0))
@@ -727,16 +1060,8 @@ func _apply_anim(n: Node3D, f: int, moving: bool, walk_mode := 1) -> void:
state = "emotion"
if n.has_method("set_anim_state"):
n.call("set_anim_state", state)
elif n.has_node("Label3D"):
(n.get_node("Label3D") as Label3D).modulate = _state_tint(state)
func _state_tint(s: String) -> Color:
match s:
"walk": return Color(0.7, 0.9, 1.0)
"run": return Color(0.6, 1.0, 0.6)
"attack": return Color(1.0, 0.5, 0.4)
"skill": return Color(1.0, 0.8, 0.3)
_: return Color(1, 1, 1)
# 占位胶囊(无 set_anim_state)不再借头顶名字 Label3D 的 modulate 表现动作态——
# 该 modulate 现在专属 §8.8 名字色(GetNameColor)。
func _grounded(p: Variant) -> Vector3:
# p 是 M2Client 的网络帧 pos —— 先转到 Metin2World 本地帧,再贴地。
@@ -771,7 +1096,16 @@ func _attach_nameplate(root: Node3D, d: Dictionary) -> void:
tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
tag.no_depth_test = true
tag.pixel_size = 0.006
tag.visible = false # _on_spawn 随后 _set_name_visibility(node) 按 __IsShowName / 距离定夺
root.add_child(tag)
# §8.8 头顶「公会名 / 称号 / 等级」子标签(CPythonTextTail 的 pGuildNameTextInstance /
# pTitleTextInstance / pLevelTextInstance)。参考端是屏幕空间:公会名在名字上方
# c_fyGuildNamePosition(15px)、称号 / 等级贴右侧。这里在 billboard 3D 里近似成竖排
# (公会名上、称号 / 等级下),精确像素排版差异记为 §8.8 seam ⑦。内容 / 颜色由
# _apply_text_tail 按 RefreshTextTail / AttachTitle / AttachLevel 决定,初始空 = 隐藏。
root.add_child(_mk_sub_tag("GuildTag", 2.34, TextTail.GUILD_NAME_COLOR))
root.add_child(_mk_sub_tag("TitleTag", 1.86, Color(1, 1, 1)))
root.add_child(_mk_sub_tag("LevelTag", 1.74, TextTail.LEVEL_COLOR))
var bar := Sprite3D.new()
bar.name = "HpBar"
bar.texture = _white_1px()
@@ -783,6 +1117,18 @@ func _attach_nameplate(root: Node3D, d: Dictionary) -> void:
bar.scale.x = 1.0
root.add_child(bar)
func _mk_sub_tag(sub_name: String, y: float, col: Color) -> Label3D:
var t := Label3D.new()
t.name = sub_name
t.text = ""
t.position.y = y
t.billboard = BaseMaterial3D.BILLBOARD_ENABLED
t.no_depth_test = true
t.pixel_size = 0.005
t.modulate = col
t.visible = false
return t
static var _white_tex: Texture2D
static func _white_1px() -> Texture2D:
if _white_tex == null:
+5 -5
View File
@@ -74,7 +74,7 @@ func _test_m2client_surface() -> void:
var c: Object = ClassDB.instantiate("M2Client")
get_root().add_child(c)
for m in ["move", "character_position", "sync_positions", "request_warp", "fishing", "request_dungeon", "attack", "set_target", "say", "shoot", "add_fly_targeting", "change_name", "select_empire", "download_guild_symbol", "get_guild_symbol", "script_select_item", "quest_cancel", "party_use_skill", "get_skill_group", "get_entity", "get_entities",
"get_main_vid", "get_main_pid", "get_points", "get_target", "select_character", "enter_game", "reconnect",
"get_main_vid", "get_main_pid", "get_points", "get_target", "select_character", "enter_game", "reconnect", "send_emoticon",
"suspend", "resume", "move_item", "use_item", "drop_item", "drop_item_count", "use_item_to_item", "give_item", "pickup_item",
"get_inventory", "get_equipment", "get_belt_inventory", "get_view_equipment", "get_item", "get_ground_items", "get_pvp_relations", "get_duel", "get_lover", "get_land_areas", "get_observers", "is_observer_mode", "get_observer_count", "has_mobile_flag", "combo_skill_enabled", "get_stamina_state", "whisper",
"party_invite", "party_answer", "party_leave", "party_set_distribute", "party_set_state", "get_party", "get_party_distribute_mode",
@@ -89,7 +89,7 @@ func _test_m2client_surface() -> void:
"refine", "ds_refine", "get_dragon_souls", "get_channel", "get_server_time", "get_npc_marks", "get_world_markers"]:
_ck(c.has_method(m), "M2Client.%s bound" % m)
for s in ["entity_spawned", "entity_despawned", "entity_moved", "entity_main_set", "empire_changed",
"chat", "suspended", "resumed", "vitals_changed", "entity_dead", "damage",
"chat", "emoticon_requested", "suspended", "resumed", "vitals_changed", "entity_dead", "damage",
"motion", "points_changed", "target_info", "inventory_changed",
"ground_item_added", "ground_item_removed", "item_picked_up", "item_used", "whisper_received",
"entity_info", "warp", "time_changed", "channel_changed", "npc_marks_changed",
@@ -224,11 +224,11 @@ func _test_netbridge() -> void:
_ck(mob.position.distance_to(Vector3(10, 0, -40)) < 0.5, "far move -> snap") # to_world 翻 Z
_ck(int(mob.get_meta("func")) == 1, "func -> FUNC_MOVE")
# §3.2:位置插值移交 C++ EntityStore::tick()net_world._process 直接跟随 e.pos
# 不再叠自己的 follow_lerp。这里 FakeClient 的 pos 是瞬时值,节点应当直接对齐。
fc.move_to(2000, Vector3(11, 0, 41))
await process_frame
var d1 := mob.position.distance_to(Vector3(11, 0, -41))
for i in 20: await process_frame
_ck(mob.position.distance_to(Vector3(11, 0, -41)) < d1, "near move -> lerp closes in")
_ck(mob.position.distance_to(Vector3(11, 0, -41)) < 0.01, "move -> node follows e.pos directly")
var got_chat := [""]
nw.chat_line.connect(func(_t, _v, s): got_chat[0] = s)
+255 -6
View File
@@ -18,18 +18,28 @@ class FakeClient extends Node:
signal points_changed(points: Dictionary)
signal target_info(vid: int, hp_percent: int)
signal observer_mode_changed(enabled: bool)
signal fishing_event(subheader: int, info: int, dir: int)
signal chat(type: int, vid: int, text: String)
var ents := {}
var main := 0
var in_game := true
var calls := {"move": [], "attack": [], "set_target": [], "click_npc": []}
var ground_items := []
var skills := []
var calls := {"move": [], "attack": [], "set_target": [], "click_npc": [],
"pickup": [], "fly": [], "fishing": []}
func is_in_game() -> bool: return in_game
func get_main_vid() -> int: return main
func get_entity(vid: int) -> Dictionary: return ents.get(vid, {})
func get_entities() -> Array: return ents.values()
func move(f, a, rot, x, y) -> bool: calls.move.append([f, a, rot, x, y]); return true
func attack(motion, vid) -> bool: calls.attack.append([motion, vid]); return true
func set_target(vid) -> bool: calls.set_target.append(vid); return true
func click_npc(vid) -> bool: calls.click_npc.append(vid); return true
func pickup_item(vid) -> bool: calls.pickup.append(vid); return true
func add_fly_targeting(vid, x, y) -> bool: calls.fly.append([vid, x, y]); return true
func fishing(rot_deg) -> bool: calls.fishing.append(rot_deg); return true
func get_ground_items() -> Array: return ground_items
func get_skills() -> Array: return skills
class FakePC extends Node:
signal target_selected(node: Node3D)
@@ -37,7 +47,10 @@ class FakePC extends Node:
signal anim_state(state: String)
var player: Node3D
var server_speed := 1.0
var walk_calls := []
var frozen := false
func set_server_speed(moving_speed: int) -> void: server_speed = float(moving_speed) / 100.0
func walk_to(p: Vector3) -> void: walk_calls.append(p)
class FakeHud extends Node:
var vitals := []
@@ -87,7 +100,41 @@ func _run() -> void:
fc.entity_main_set.emit(1000)
fc.entity_info.emit(1000, fc.ents[1000])
_ck(is_equal_approx(pc.server_speed, 1.5), "GC_CHANGE_SPEED -> local prediction speed scale")
_ck(is_equal_approx(np.attack_period, 0.4), "entity attack_speed -> attack cadence")
# §3.3attack_speed 只喂攻速系数(缩放播放速率),不再在收包时把 period 直接写死
_ck(is_equal_approx(np._atk_speed_factor, 1.5), "entity attack_speed -> attack-speed factor")
_ck(is_equal_approx(np._current_attack_period(), np.DEFAULT_ATTACK_PERIOD / 1.5),
"no motion data -> cadence = DEFAULT_ATTACK_PERIOD / factor")
# §5.3SHORSE::CanAttack / CPythonPlayer::__CanAttack 的坐骑等级门。
# 普通坐骑 level 1 不能攻击;新坐骑还需要骑乘术 level 11 或 master。
fc.skills = [{"id": 109, "level": 1, "master": 0}]
fc.ents[1000]["mount_vnum"] = 20101
_ck(not np._can_attack_horse_level(), "horse level 1 -> CanAttackHorseLevel false")
fc.ents[1000]["mount_vnum"] = 20205
_ck(np._can_attack_horse_level(), "horse level 2 -> CanAttackHorseLevel true")
_ck(not np._can_attack(), "new horse skill below level 11 -> attack blocked")
fc.skills[0]["level"] = 11
_ck(np._can_attack(), "new horse skill level 11 -> attack allowed")
fc.ents[1000]["mount_vnum"] = 0
fc.skills = []
# §5.3PythonPlayerInputKeyboard::SetAttackKeyState 在 FISHING mode
# 改走 NEW_Fishing,不设置普通攻击键;GC_FISHING 到达后方向键/地面点击
# 走 NEW_CancelFishing,且 500ms 内不重复发取消包。
np.combo_motion_mode = np.MOTION_MODE_FISHING
pc.player.rotation.y = 0.0
fc.calls.fishing.clear()
np.set_attack_key(true)
_ck(fc.calls.fishing.size() == 1 and is_equal_approx(float(fc.calls.fishing[0]), 270.0),
"fishing attack key -> CG_FISHING with MapCoord heading")
_ck(not np._attack_key_down, "fishing attack key does not enable normal attack")
fc.fishing_event.emit(0, 1000, 0)
_ck(np.is_fishing(), "GC_FISHING START -> IsFishing state")
_ck(np.cancel_fishing(), "NEW_CancelFishing sends first cancel")
_ck(fc.calls.fishing.size() == 2 and is_equal_approx(float(fc.calls.fishing[1]), 0.0),
"cancel fishing -> CG_FISHING(0)")
_ck(not np.cancel_fishing(), "NEW_CancelFishing throttles within 500ms")
np.combo_motion_mode = np.MOTION_MODE_GENERAL
# 1) 点地移动 -> move(FUNC_MOVE) 带 server cm + heading
pc.player.position = Vector3(3, 0, -5) # to_server_cm -> (300, -500)
@@ -116,7 +163,7 @@ func _run() -> void:
mob.set_meta("vid", 2000)
root.add_child(mob)
mob.position = Vector3(4, 0, -5) # 距玩家(3.3,0,-5) 0.7m -> 攻击距离内
fc.ents[2000] = {"vid": 2000, "name": "Wolf", "pos": mob.position, "hp": 50, "max_hp": 50, "dead": false}
fc.ents[2000] = {"vid": 2000, "ch_type": 2, "name": "Wolf", "pos": mob.position, "hp": 50, "max_hp": 50, "dead": false}
nw._by_vid[2000] = mob # 让 net_world.node_for(2000) 命中
pc.target_selected.emit(mob)
_ck(fc.calls.set_target.size() == 1 and fc.calls.set_target[0] == 2000, "set_target(2000)")
@@ -130,14 +177,191 @@ func _run() -> void:
pc.target_selected.emit(npc)
_ck(fc.calls.click_npc == [3000], "NPC selection -> click_npc(3000)")
# 5) 攻击距离内 -> 按 attack_period 自动 attack
# 5) 只有按住 SPACE(参考端 m_isAtkKey)才在攻击距离内 attack
np.set_attack_key(true)
for i in 4: await process_frame
_ck(fc.calls.attack.size() >= 1, "auto-attack fired, got %d" % fc.calls.attack.size())
_ck(fc.calls.attack.size() >= 1, "space-held attack fired, got %d" % fc.calls.attack.size())
if fc.calls.attack.size() >= 1:
_ck(fc.calls.attack[0][1] == 2000, "attack victim == 2000")
var n1: int = fc.calls.attack.size()
await process_frame
_ck(fc.calls.attack.size() == n1, "attack throttled within period")
np.set_attack_key(false)
# 5b) §3.4 预约动作状态机(__ReserveProcess_ClickActor 的 10 步顺序)
# 步骤 2:目标在可点击距离外 -> 继续靠近(walk_to),不发攻击
pc.walk_calls.clear()
fc.calls.attack.clear()
mob.position = Vector3(40, 0, -5) # 距玩家 ~37m,远超 1.5m 默认可点击距离
np.set_attack_key(true)
for i in 3: await process_frame
_ck(pc.walk_calls.size() >= 1, "out-of-range reserved target -> walk_to (approach)")
_ck(fc.calls.attack.is_empty(), "out-of-range -> no attack packet")
_ck(np._reserved_mode == NetPlay.ReservedMode.CLICK_ACTOR, "reservation kept while approaching")
# 步骤 10:进入距离 -> 攻击且预约清空
mob.position = Vector3(4, 0, -5)
np._attack_cd = 0.0
fc.calls.attack.clear()
await process_frame
_ck(fc.calls.attack.size() >= 1, "in-range reserved target -> attack fired")
_ck(np._reserved_mode == NetPlay.ReservedMode.NONE, "reservation cleared after swing")
# 步骤 5:自己在安全区 -> OnCannotAttack('IN_SAFE') + 清预约,不攻击
var cannot: Array = []
np.cannot_act.connect(func(code): cannot.append(code))
fc.ents[1000]["in_safe"] = true
np._attack_cd = 0.0
fc.calls.attack.clear()
for i in 2: await process_frame
_ck(cannot.has("IN_SAFE"), "main in safe zone -> cannot_act('IN_SAFE')")
_ck(fc.calls.attack.is_empty(), "safe zone -> no attack packet")
fc.ents[1000]["in_safe"] = false
# __Update_AutoAttack:目标死亡 -> 清 auto-attack + 目标
fc.ents[2000]["dead"] = true
np._attack_cd = 0.0
await process_frame
_ck(np._auto_attack_vid == 0, "dead reserved target -> auto-attack cleared")
fc.ents[2000]["dead"] = false
np.set_attack_key(false)
np._clear_reserved()
# 5c) §3.4 增量 91 —— CLICK_ITEM / CLICK_POSITION / USE_SKILL 预约分派
# + __ChangeTargetToPickedInstance + 扇形/圆形补 fly-targeting
np._target_vid = 0
np._auto_attack_vid = 0
np.set_attack_key(false)
np._clear_reserved()
pc.player.position = Vector3.ZERO
pc.player.rotation.y = 0.0
pc.walk_calls.clear()
fc.calls.move.clear()
# CLICK_ITEM:远处掉落物 -> walk_to;到手 -> pickup + FUNC_WAIT + 清预约
fc.ground_items = [{"vid": 777, "vnum": 1, "owner": "", "pos": Vector3(5, 0, 5)}] # to_world -> (5,0,-5)
np.reserve_click_item(777)
await process_frame
_ck(pc.walk_calls.size() >= 1, "CLICK_ITEM far -> walk_to")
_ck(np._reserved_mode == NetPlay.ReservedMode.CLICK_ITEM, "CLICK_ITEM reservation kept while approaching")
pc.player.position = Vector3(5, 0, -5) # 站到物品上
var mv_n: int = fc.calls.move.size()
await process_frame
_ck(fc.calls.pickup == [777], "CLICK_ITEM in-range -> pickup_item(777)")
_ck(fc.calls.move.size() >= mv_n + 1 and fc.calls.move[mv_n][0] == np.FUNC_WAIT,
"CLICK_ITEM in-range -> FUNC_WAIT state packet")
_ck(np._reserved_mode == NetPlay.ReservedMode.NONE, "CLICK_ITEM cleared after pickup")
# CLICK_ITEM:物品已消失 -> 清预约,不走
pc.walk_calls.clear()
fc.ground_items = []
np.reserve_click_item(888)
await process_frame
_ck(pc.walk_calls.is_empty() and np._reserved_mode == NetPlay.ReservedMode.NONE,
"CLICK_ITEM missing item -> reservation cleared")
# CLICK_POSITION:延时未到不动;延时耗尽 -> walk_to + 清预约
pc.walk_calls.clear()
np.reserve_click_ground(Vector3(9, 0, -2))
_ck(np._reserved_delay > 0.0, "reserve_click_ground sets a reserved delay")
np._reserved_delay = 999.0
await process_frame
_ck(pc.walk_calls.is_empty(), "CLICK_POSITION waits out the reserved delay")
np._reserved_delay = 0.0
await process_frame
_ck(pc.walk_calls.size() == 1 and pc.walk_calls[0] == Vector3(9, 0, -2),
"CLICK_POSITION delay elapsed -> walk_to(target)")
_ck(np._reserved_mode == NetPlay.ReservedMode.NONE, "CLICK_POSITION cleared after move")
# USE_SKILLrange>100 存 -10;射程外 walk_to;射程内 -> hook + set_target + 清预约
var smob := Node3D.new()
smob.set_meta("vid", 4100)
root.add_child(smob)
nw._by_vid[4100] = smob
smob.position = Vector3(30, 0, 0)
fc.ents[4100] = {"vid": 4100, "ch_type": 2, "pos": smob.position, "hp": 80, "max_hp": 80, "dead": false, "race": 101}
var hook_calls: Array = []
np.use_skill_hook = func(slot: int) -> bool: hook_calls.append(slot); return true
pc.player.position = Vector3.ZERO
np.reserve_use_skill(4100, 5, 800.0)
_ck(is_equal_approx(np._skill_range_reserved, 790.0), "reserve_use_skill trims range >100 by 10")
pc.walk_calls.clear()
fc.calls.set_target.clear()
await process_frame
_ck(pc.walk_calls.size() >= 1, "USE_SKILL out of range -> walk_to")
_ck(hook_calls.is_empty(), "USE_SKILL out of range -> no cast")
smob.position = Vector3(3, 0, 0) # 3m < 7.9m 射程
await process_frame
_ck(hook_calls == [5], "USE_SKILL in range -> use_skill_hook(slot 5)")
_ck(fc.calls.set_target.has(4100), "USE_SKILL in range -> set_target(4100)")
_ck(np._reserved_mode == NetPlay.ReservedMode.NONE, "USE_SKILL cleared after cast")
np.reserve_use_skill(999999, 5, 300.0)
await process_frame
_ck(np._reserved_mode == NetPlay.ReservedMode.NONE, "USE_SKILL missing target -> cleared")
# __ChangeTargetToPickedInstanceNPC(非战斗目标)-> 清 _target_vid + set_target(0)
np._target_vid = 2000
fc.calls.set_target.clear()
fc.ents[5000] = {"vid": 5000, "ch_type": 1, "name": "NPC"}
var ok_npc: bool = np._change_target_to_picked_instance(5000)
_ck(not ok_npc and np._target_vid == 0 and fc.calls.set_target == [0],
"_change_target_to_picked_instance(NPC) -> target cleared")
fc.calls.set_target.clear()
var ok_mob: bool = np._change_target_to_picked_instance(4100)
_ck(ok_mob and np._target_vid == 4100 and fc.calls.set_target == [4100],
"_change_target_to_picked_instance(monster) -> target set")
# send_fly_targeting —— 把早前实体挪远(不销毁,后续用例还引用),铺一组已知几何
np._target_vid = 0
np._clear_reserved()
pc.player.position = Vector3.ZERO
for v in [2000, 4100]:
if nw._by_vid.has(v):
nw._by_vid[v].position = Vector3(9999, 0, 9999)
if fc.ents.has(v):
fc.ents[v]["pos"] = Vector3(9999, 0, 9999)
var mk := func(v: int, p: Vector3) -> void:
var n := Node3D.new()
n.set_meta("vid", v)
n.position = p
root.add_child(n)
nw._by_vid[v] = n
fc.ents[v] = {"vid": v, "ch_type": 2, "pos": p, "hp": 50, "max_hp": 50, "dead": false, "race": 101}
mk.call(4200, Vector3(0, 0, -10)) # 主目标,正前方 (-Z)
mk.call(4201, Vector3(2, 0, -10)) # 侧翼,扇内
mk.call(4202, Vector3(-2, 0, -10)) # 侧翼,扇内
mk.call(4203, Vector3(0, 0, 12)) # 背后,扇外
fc.calls.fly.clear()
var extras: int = np.send_fly_targeting(4200, 2500.0, 3, NetPlay.FlyShape.FAN)
_ck(extras == 2, "FAN send_fly_targeting: 2 extras (max 3 - primary), got %d" % extras)
var fly_vids := []
for f in fc.calls.fly:
fly_vids.append(f[0])
_ck(fly_vids.size() == 2 and not fly_vids.has(4200), "FAN: 2 extra packets, primary not re-sent")
_ck(not fly_vids.has(4203), "FAN: target-behind instance excluded from fan")
_ck(4201 in fly_vids and 4202 in fly_vids, "FAN: both flankers picked")
fc.calls.fly.clear()
var extras_c: int = np.send_fly_targeting(4200, 2500.0, 4, NetPlay.FlyShape.CIRCLE)
_ck(extras_c == 3, "CIRCLE: 3 extras (max 4 - primary), got %d" % extras_c)
var cvids := []
for f in fc.calls.fly:
cvids.append(f[0])
_ck(4203 in cvids, "CIRCLE: behind-target instance included (no fan filter)")
fc.calls.fly.clear()
var extras_r: int = np.send_fly_targeting(4200, 2500.0, 6, NetPlay.FlyShape.FAN)
_ck(extras_r == 5, "FAN shortfall: 5 extras for max 6, got %d" % extras_r)
var zero_fills := 0
for f in fc.calls.fly:
if f[0] == 0:
zero_fills += 1
_ck(zero_fills >= 1, "FAN shortfall: random vid=0 positions fill the remainder")
_ck(np.send_fly_targeting(4200, 2500.0, 1, NetPlay.FlyShape.FAN) == 0, "max_count<=1 -> no extras")
_ck(np.send_fly_targeting(4200, 2500.0, 3, NetPlay.FlyShape.SINGLE) == 0, "SINGLE shape -> no extras")
np._clear_reserved()
np._target_vid = 0
# 6) points_changed -> HUD
fc.points_changed.emit({"hp": 120, "max_hp": 200, "sp": 30, "max_sp": 80,
@@ -165,9 +389,34 @@ func _run() -> void:
# 10) 观战模式锁住本地移动/选目标,并清掉当前目标
fc.ents[2000]["dead"] = false
fc.observer_mode_changed.emit(true)
var move_n: int = fc.calls.move.size()
var target_n: int = fc.calls.set_target.size()
pc.player.position = Vector3(8, 0, -5)
pc.moved.emit(pc.player.position)
pc.target_selected.emit(mob)
_ck(fc.calls.move.size() == 2 and fc.calls.set_target.size() == 1,
_ck(fc.calls.move.size() == move_n and fc.calls.set_target.size() == target_n,
"observer mode blocks local movement and targeting")
fc.observer_mode_changed.emit(false)
# 11) §3.2 上行 6 回调:OnMove 立即发 + 重置节流窗、OnMoving 300ms 节流、
# OnStop 收尾、OnAttack 额外发 FUNC_COMBO、on_use_skill 发 FUNC_SKILL|motion。
fc.observer_mode_changed.emit(false)
np._observer_mode = false
np._was_moving = false
np._last_moving_sent_t = 0.0
fc.calls.move.clear()
pc.player.position = Vector3(1, 0, -1)
pc.moved.emit(pc.player.position) # OnMove
_ck(fc.calls.move.size() == 1 and fc.calls.move[0][0] == np.FUNC_MOVE, "OnMove -> immediate FUNC_MOVE")
pc.player.position = Vector3(1.5, 0, -1)
pc.moved.emit(pc.player.position) # OnMoving, throttled (<300ms)
_ck(fc.calls.move.size() == 1, "OnMoving within 300ms throttled")
np._last_moving_sent_t -= 1.0 # 让 300ms 窗口过期
pc.moved.emit(pc.player.position) # OnMoving, now allowed
_ck(fc.calls.move.size() == 2 and fc.calls.move[1][0] == np.FUNC_MOVE, "OnMoving after 300ms -> FUNC_MOVE")
pc.anim_state.emit("wait") # OnStop
_ck(fc.calls.move.size() == 3 and fc.calls.move[2][0] == np.FUNC_WAIT, "OnStop -> FUNC_WAIT")
fc.calls.move.clear()
np.on_use_skill(5, 2) # OnUseSkill
_ck(fc.calls.move.size() == 1 and fc.calls.move[0][0] == (np.FUNC_SKILL | 5) and fc.calls.move[0][1] == 2,
"on_use_skill -> FUNC_SKILL|motion + arg")
+46 -20
View File
@@ -31,6 +31,7 @@ class FakeClient extends Node:
var safebox_size := 0
var safebox_gold := 0
var inventory := []
var main_pos := Vector3.ZERO
var main_vid := 20
var distribute_mode := 0
@@ -69,6 +70,12 @@ class FakeClient extends Node:
func get_safebox_size() -> int: return safebox_size
func get_safebox_gold() -> int: return safebox_gold
func get_inventory() -> Array: return inventory
func get_item(window, cell) -> Dictionary:
if window != 1: return {}
for item in inventory:
if int(item.get("cell", -1)) == int(cell): return item
return {}
func get_entity(_vid) -> Dictionary: return {"pos_cm": main_pos}
func safebox_checkin(sp, w, c) -> bool: calls.append(["sb_checkin", sp, w, c]); return true
func safebox_checkout(sp, w, c) -> bool: calls.append(["sb_checkout", sp, w, c]); return true
@@ -233,13 +240,12 @@ func _run() -> void:
{"pos": 1, "vnum": 19, "price": 800, "count": 1}]
fc.shop_opened.emit(8080)
_ck(su.is_open(), "shop opens on shop_opened")
_ck(su._list.get_child_count() == 2, "shop: 2 item rows")
# 第一行的「买」按钮
var buy_btn: Button = null
for b in su._list.get_child(0).find_children("*", "Button", true, false):
buy_btn = b
if buy_btn:
buy_btn.pressed.emit()
_ck(su._grid.get_child_count() == 40, "shop: 5x8 item grid")
# 第一格点击进入购买确认,再确认后固定以 count=1 发包。
(su._grid.get_child(0) as Button).pressed.emit()
_ck(su._buy_confirm != null, "shop: slot click opens buy confirmation")
if su._buy_confirm:
su._buy_confirm.confirmed.emit()
_ck(fc.calls.has(["shop_buy", 0, 1]), "shop: buy row 0")
su.sell(5)
_ck(fc.calls.has(["shop_sell", 5, 1]), "shop: sell inv cell 5")
@@ -258,23 +264,19 @@ func _run() -> void:
fc.shop_opened.emit(8080)
_ck(su.is_open(), "shop_ex: opens")
_ck(su._tabbar.visible and su._tabbar.get_child_count() == 2, "shop_ex: 2 tab buttons")
_ck(su._list.get_child_count() == 2, "shop_ex: tab 0 has 2 rows")
# tab 0 第二slot 5)买 -> pos = 0*40 + 5
var r1_buy: Button = null
for b in su._list.get_child(1).find_children("*", "Button", true, false):
r1_buy = b
if r1_buy:
r1_buy.pressed.emit()
_ck(su._grid.get_child_count() == 40, "shop_ex: tab 0 has 5x8 grid")
# tab 0 第二个货位slot 5)买 -> pos = 0*40 + 5
(su._grid.get_child(5) as Button).pressed.emit()
if su._buy_confirm:
su._buy_confirm.confirmed.emit()
_ck(fc.calls.has(["shop_buy", 5, 1]), "shop_ex: tab0 slot5 -> buy pos 5")
# 切到货架 2
su._tabbar.get_child(1).pressed.emit()
_ck(su._active_tab == 1, "shop_ex: switched to tab 1")
_ck(su._list.get_child_count() == 1, "shop_ex: tab 1 has 1 row")
var t1_buy: Button = null
for b in su._list.get_child(0).find_children("*", "Button", true, false):
t1_buy = b
if t1_buy:
t1_buy.pressed.emit()
_ck(su._grid.get_child_count() == 40, "shop_ex: tab 1 has 5x8 grid")
(su._grid.get_child(3) as Button).pressed.emit()
if su._buy_confirm:
su._buy_confirm.confirmed.emit()
_ck(fc.calls.has(["shop_buy", 43, 1]), "shop_ex: tab1 slot3 -> buy pos 1*40+3=43")
fc.shop_closed.emit()
fc.shop_tabs = []
@@ -289,15 +291,39 @@ func _run() -> void:
"peer_items": [], "self_gold": 100, "peer_gold": 0,
"self_accept": false, "peer_accept": true,
}
fc.inventory = [{"cell": 7, "vnum": 901, "count": 1, "anti_flags": 0}]
fc.exchange_changed.emit()
_ck(xu.is_open(), "exchange window opens when active")
_ck(xu._self_box.get_child_count() == 1, "exchange: 1 self item row")
_ck(xu._root.get_node("PeerAccept").text == "对方: 已接受", "exchange: peer accept shown")
_ck(not xu._accept_btn.disabled, "exchange: accept enabled before server accept")
xu._on_accept()
_ck(fc.calls.has(["ex_accept"]) and xu._accept_btn.disabled,
"exchange: accept sends once and disables immediately")
fc.exchange["self_accept"] = false
fc.exchange_changed.emit()
_ck(not xu._accept_btn.disabled, "exchange: server-cleared accept re-enables button")
xu.offer(1, 7)
_ck(fc.calls.has(["ex_add_item", 1, 7, 1]), "exchange: offer item uses first free display slot (1)")
fc.exchange["self_gold"] = 0
xu._gold_input.text = "500"
xu._on_put_gold()
_ck(fc.calls.has(["ex_gold", 500]), "exchange: put gold")
fc.exchange["self_gold"] = 500
xu._gold_input.text = "100"
xu._on_put_gold()
_ck(not fc.calls.has(["ex_gold", 100]), "exchange: existing gold cannot be edited")
fc.exchange["self_gold"] = 0
xu._gold_input.text = "10000000"
xu._on_put_gold()
_ck(not fc.calls.has(["ex_gold", 10000000]), "exchange: gold is limited to 7 digits")
fc.inventory = [{"cell": 8, "vnum": 902, "count": 1, "anti_flags": 1 << 13}]
_ck(not xu._drop_to_slot({"window": 1, "cell": 8, "vnum": 902, "count": 1}, 3),
"exchange: ANTIFLAG_GIVE item rejected")
fc.main_pos = Vector3(1001, 0, 0)
xu._process(0.1)
_ck(not xu.is_open() and fc.calls.has(["ex_cancel"]),
"exchange: moving beyond 1000 exits trade")
fc.exchange = {"active": false}
fc.exchange_changed.emit()
_ck(not xu.is_open(), "exchange closes when inactive")
+39
View File
@@ -12,6 +12,7 @@ class FakeClient extends Node:
signal npc_marks_changed()
signal land_areas_changed()
signal observer_event(kind: int, vid: int, pos: Vector2)
signal world_reset()
signal world_markers_changed()
signal warp(pos: Vector3, same_server: bool)
var _time := 0
@@ -82,10 +83,48 @@ func _run() -> void:
fc.markers = [{"id": 1, "name": "Quest", "pos": Vector3(-4, 0, -4)}]
fc.observers = [{"vid": 9001, "pos": Vector3(2, 0, -2)}]
fc.land_areas = [{"id": 42, "guild_id": 77, "x": -200, "y": -200, "width": 400, "height": 400}]
# ClientVS22's CPythonMiniMap interpolates observer moves for 1 second.
fc.observer_event.emit(mm.OBSERVER_ADD, 9001, Vector2(0, 0))
var observer_start: Vector3 = mm._observer_position(9001, Vector3.ZERO)
_ck(observer_start.distance_to(Vector3.ZERO) < 0.01, "minimap: observer add seeds current position")
fc.observer_event.emit(mm.OBSERVER_MOVE, 9001, Vector2(10, 4))
var observer_immediate: Vector3 = mm._observer_position(9001, Vector3.ZERO)
_ck(observer_immediate.distance_to(Vector3.ZERO) < 0.1, "minimap: observer move starts at current position")
await create_timer(1.05).timeout
var observer_final: Vector3 = mm._observer_position(9001, Vector3.ZERO)
_ck(observer_final.distance_to(Vector3(10, 0, 4)) < 0.1, "minimap: observer move reaches destination")
fc.observer_event.emit(mm.OBSERVER_REMOVE, 9001, Vector2.ZERO)
_ck(not mm._observer_tracks.has(9001), "minimap: observer remove clears track")
fc.observer_event.emit(mm.OBSERVER_ADD, 9001, Vector2(2, -2))
fc.world_reset.emit()
_ck(mm._observer_tracks.is_empty(), "minimap: world reset clears observer tracks")
mm._view.queue_redraw()
await process_frame
_ck(mm._view.is_visible_in_tree(), "minimap: redraw scheduled, view live")
# --- 任务信标:圆内画星,越出半径贴圆周画方向箭头(§5.2 世界箭头 / 屏幕边缘指示)---
var rim: float = mm.RADIUS - mm.SIGNAL_RIM_INSET
var near_geo: Dictionary = mm.signal_marker_geometry(Vector2(10, 0), rim)
_ck(not near_geo["clamped"] and near_geo["offset"] == Vector2(10, 0),
"minimap signal: inside rim keeps true offset")
var far_geo: Dictionary = mm.signal_marker_geometry(Vector2(400, 0), rim)
_ck(far_geo["clamped"] and abs(far_geo["offset"].length() - rim) < 0.01,
"minimap signal: outside rim clamps to circle")
_ck(abs(far_geo["angle"]) < 0.001 and abs(far_geo["offset"].x - rim) < 0.01,
"minimap signal: clamp keeps bearing to target (east)")
var diag_geo: Dictionary = mm.signal_marker_geometry(Vector2(-300, -300), rim)
_ck(diag_geo["clamped"] and abs(diag_geo["angle"] - atan2(-300.0, -300.0)) < 0.001
and abs(diag_geo["offset"].length() - rim) < 0.01,
"minimap signal: diagonal clamp preserves atan2 bearing")
var edge_geo: Dictionary = mm.signal_marker_geometry(Vector2(rim, 0), rim)
_ck(edge_geo["clamped"], "minimap signal: exactly on rim counts as clamped (>=)")
mm.add_signal_point(120000.0, 90000.0)
_ck(mm.signal_point_count() == 1, "minimap signal: add_signal_point stores waypoint")
mm._view.queue_redraw()
await process_frame
mm.clear_signal_points()
_ck(mm.signal_point_count() == 0, "minimap signal: clear_signal_points empties waypoints")
# --- 昼夜 ---
var sun := DirectionalLight3D.new()
get_root().add_child(sun)
+59
View File
@@ -0,0 +1,59 @@
# part_hiding.gd —— §4.1 修改 4:变身 / 时装的部位遮挡规则(InstanceBase.cpp 1:1)。
#
# 纯函数,无状态。EquipModel.refresh() 在解出身体 shape 后调用,决定武器 / 盾 / 头发
# 是否该隐藏、身体 shape 是否被变身强制归零。远端角色换装走同一条路(EquipModel 是
# 「渲染别的玩家用的同一条路」)。
#
# 参考 REF/UserInterface/InstanceBase*.cpp
# IsPoly() -> SetShape(0)SetWeapon / SetParts 提前 return
# -> 身体 shape 归 0、无武器、无头发 / 挂件
# __IsShapeAnimalWear() -> GetShape() ∈ {100,101,102,103}SetWeapon(:2747) 与
# SetParts 返回 false -> 穿动物 / 怪物时装时武器 + 头发被抑制
# IsWearingDress() -> m_eShape == 201(婚纱);__IsChangableWeapon(iWeaponID)
# (:901) 除 c_iBouquets 外一律 false -> 非捧花时武器隐藏
# (头发不受婚纱影响)
# __ArmorVnumToShape() -> shape = item_proto values[3]vnum ∉ {0,1} 且
# USE_ARMOR_SPECULAR),否则 shape = vnum
extends RefCounted
const POLY_SHAPE := 0
const ANIMAL_WEAR_SHAPES := [100, 101, 102, 103] # __IsShapeAnimalWear
const WEDDING_DRESS_SHAPE := 201 # IsWearingDress
const BOUQUET_VNUMS := [50201, 50202, 50203, 50204] # c_iBouquets(原表 0 结尾)
static func is_animal_wear(shape: int) -> bool:
return shape in ANIMAL_WEAR_SHAPES
static func is_wearing_dress(shape: int) -> bool:
return shape == WEDDING_DRESS_SHAPE
static func is_bouquet(weapon_vnum: int) -> bool:
return weapon_vnum in BOUQUET_VNUMS
# __IsChangableWeapon:婚纱状态只有捧花可换,其它 shape 一律可换。
static func is_changable_weapon(weapon_vnum: int, shape: int) -> bool:
if is_wearing_dress(shape):
return is_bouquet(weapon_vnum)
return true
# 武器(含盾,走同一挂点规则)是否该隐藏:变身 / 动物时装 / 婚纱且非捧花。
static func weapon_hidden(shape: int, is_poly: bool, weapon_vnum: int = 0) -> bool:
if is_poly:
return true
if is_animal_wear(shape):
return true
if is_wearing_dress(shape) and not is_bouquet(weapon_vnum):
return true
return false
# 头发 / 挂件是否该隐藏:变身 / 动物时装(婚纱不影响头发)。
static func hair_hidden(shape: int, is_poly: bool) -> bool:
return is_poly or is_animal_wear(shape)
# 身体 shape:变身强制 0SetShape(0)),否则原样(动物时装 100-103 保留,用来出时装体)。
static func body_shape(shape: int, is_poly: bool) -> int:
return POLY_SHAPE if is_poly else shape
# 有效武器 vnum:被隐藏则 0SetWeapon 提前 return 的等效结果)。
static func effective_weapon(weapon_vnum: int, shape: int, is_poly: bool) -> int:
return 0 if weapon_hidden(shape, is_poly, weapon_vnum) else weapon_vnum
+76
View File
@@ -0,0 +1,76 @@
# part_hiding_test —— §4.1 修改 4:变身 / 时装部位遮挡(PartHiding1:1 自检。
# canonical #18。 godot --headless --path project --script part_hiding_test.gd
extends SceneTree
const PartHiding = preload("res://part_hiding.gd")
var failed := 0
func _ck(ok: bool, message: String) -> void:
if not ok:
failed += 1
printerr("FAIL: " + message)
func _init() -> void:
_test_animal_wear()
_test_dress_and_bouquet()
_test_changable_weapon()
_test_weapon_hidden()
_test_hair_hidden()
_test_body_shape()
_test_effective_weapon()
if failed == 0:
print("PASS: part_hiding_test (§4.1 修改 4 poly / animal-wear / dress 遮挡 1:1)")
quit(0)
else:
quit(1)
func _test_animal_wear() -> void:
for s in [100, 101, 102, 103]:
_ck(PartHiding.is_animal_wear(s), "shape %d is animal-wear" % s)
for s in [0, 5, 99, 104, 201]:
_ck(not PartHiding.is_animal_wear(s), "shape %d not animal-wear" % s)
func _test_dress_and_bouquet() -> void:
_ck(PartHiding.is_wearing_dress(201), "shape 201 is wedding dress")
for s in [0, 200, 202, 100]:
_ck(not PartHiding.is_wearing_dress(s), "shape %d not dress" % s)
for v in [50201, 50202, 50203, 50204]:
_ck(PartHiding.is_bouquet(v), "vnum %d is bouquet" % v)
for v in [0, 50200, 50205, 19]:
_ck(not PartHiding.is_bouquet(v), "vnum %d not bouquet" % v)
func _test_changable_weapon() -> void:
# 婚纱:只有捧花可换
_ck(PartHiding.is_changable_weapon(50201, 201), "dress + bouquet -> changable")
_ck(not PartHiding.is_changable_weapon(19, 201), "dress + sword -> not changable")
# 非婚纱 shape:任何武器可换(动物时装的隐藏走 weapon_hidden,不走这条)
_ck(PartHiding.is_changable_weapon(19, 5), "normal shape + sword -> changable")
_ck(PartHiding.is_changable_weapon(19, 100), "animal-wear shape not blocked by __IsChangableWeapon")
func _test_weapon_hidden() -> void:
_ck(not PartHiding.weapon_hidden(5, false, 19), "normal shape, not poly -> weapon shown")
_ck(PartHiding.weapon_hidden(5, true, 19), "poly -> weapon hidden (any shape)")
_ck(PartHiding.weapon_hidden(101, false, 19), "animal-wear shape -> weapon hidden")
_ck(PartHiding.weapon_hidden(201, false, 19), "dress + non-bouquet -> weapon hidden")
_ck(not PartHiding.weapon_hidden(201, false, 50203), "dress + bouquet -> weapon shown")
_ck(PartHiding.weapon_hidden(201, false), "dress + no weapon -> hidden")
func _test_hair_hidden() -> void:
_ck(not PartHiding.hair_hidden(5, false), "normal -> hair shown")
_ck(PartHiding.hair_hidden(5, true), "poly -> hair hidden")
_ck(PartHiding.hair_hidden(102, false), "animal-wear -> hair hidden")
_ck(not PartHiding.hair_hidden(201, false), "dress -> hair NOT hidden")
func _test_body_shape() -> void:
_ck(PartHiding.body_shape(3, false) == 3, "normal shape passthrough")
_ck(PartHiding.body_shape(100, false) == 100, "animal-wear shape kept (出时装体)")
_ck(PartHiding.body_shape(3, true) == 0, "poly -> body shape 0")
_ck(PartHiding.body_shape(201, true) == 0, "poly overrides dress shape")
func _test_effective_weapon() -> void:
_ck(PartHiding.effective_weapon(19, 5, false) == 19, "normal -> weapon passthrough")
_ck(PartHiding.effective_weapon(19, 100, false) == 0, "animal-wear -> weapon 0")
_ck(PartHiding.effective_weapon(50201, 201, false) == 50201, "dress + bouquet -> keep bouquet")
_ck(PartHiding.effective_weapon(19, 201, false) == 0, "dress + sword -> 0")
_ck(PartHiding.effective_weapon(19, 5, true) == 0, "poly -> weapon 0")
+192 -27
View File
@@ -15,6 +15,11 @@ signal moved(pos: Vector3)
var player: Node3D
var camera: Camera3D
var world: Node
var cursor_manager: Node
var ui_manager: Node
var ground_items: Node
var cancel_fishing_input := Callable() # NEW_SetSingleDIKKeyState 等价入口
var cancel_fishing_ground := Callable() # __OnPressGround 专用入口
var pickables: Array[Node3D] = [] # 可点选实体(NPC/怪等)
const SPEED_WALK := 8.0
@@ -22,16 +27,53 @@ const SPEED_RUN := 18.0
const RUN_HOLD_KEY := KEY_SHIFT
const ARRIVE_EPS := 0.4
const PICK_RADIUS := 1.4 # 点选命中半径(米)
# __IsMovableGroundDistance:点地目标离脚下太近就不动(避免原地抖)。参考默认由
# player.SetMovableGroundDistance 从 game.py 设置;此处用参考缺省值(~1 m)。§3.1
const MOVABLE_GROUND_DISTANCE_M := 1.0
# __ReserveClickGround 设的 m_fReservedDelayTimePythonPlayerInput.cpp:834)。
const RESERVED_GROUND_DELAY := 0.1
# Actor-Actor 碰撞(对齐 CInstanceBase::CheckAdvancingInstanceBaseBattle.cpp:469 +
# CActorInstance::TestActorCollisionActorInstanceCollisionDetection.cpp)。参考用 race
# 碰撞球,POC 无骨骼碰撞数据 → 固定 body 半径近似;自身 + 对方两球相加为判定阈值。§3.1
const ACTOR_BODY_RADIUS_M := 0.55
# TestActorCollision 的距离门:LengthSq(victim - self) > 800 cm² 直接不检。
const ACTOR_COLLIDE_MAX_DIST_M := 8.0
# c_fDefaultRotationSpeed 1200 / c_fDefaultHorseRotationSpeed 300InstanceBase.cpp:17-18):
# MountHorse() 把转向速度降到 300,下马恢复 1200。
const ROT_SPEED_DEFAULT_DEG := 1200.0
const ROT_SPEED_HORSE_DEG := 300.0
var force_run := false # 脚本化 / 自动跑
var frozen := false # 受击硬直等:本帧不响应移动输入(net_play 设)
var locked := false # CInstanceBase::isLock():完全锁死移动(过场 / 特定 affect)
var moving_skill := false # IsUsingMovingSkill():移动技能中,只允许转向不平移
var server_speed_scale := 1.0 # GC_CHANGE_SPEED moving_speed / 100
var rotation_speed_deg := ROT_SPEED_DEFAULT_DEG # 上/下马时由 net_play 切换(§3.1 / §6.4
# CActorInstance::CanSkipCollision():网络状态包驱动的移动整段跳过 Actor 碰撞
# (§3.2 __EnableSkipCollision,观战 / 服务器纠正时置真)。
var skip_actor_collision := false
var _last_wasd := Vector2.ZERO
# --- §3.1 Src/Dst 移动模型(对齐 InstanceBaseMovement.cpp NEW_Goto / NEW_MoveToDirection---
# _is_going = m_isGoing(点地走向 Dst);方向键移动 _is_going 保持 false。
var _src_pos := Vector3.ZERO
var _dst_pos := Vector3.ZERO
var _is_going := false
var _dst_rot := 0.0
var _reserved_ground: Variant = null # __ReserveClickGround 的待处理点
var _reserved_delay_time := 0.0 # NEW_IsEmptyReservedDelayTime 递减到 0 才生效
# CInstanceBase::__IsSyncing()Dead / Stun / Pushing 中不接受移动输入(net_play 置 frozen)。
func is_going() -> bool:
return _is_going
# NEW_Goto / NEW_MoveToDirection 的三道前置门:syncing / moving-skill(只转向) / lock。
func _can_translate() -> bool:
return not frozen and not locked and not moving_skill
const _TAP_TRAVEL_MAX := 12.0 # 触点位移超过这么多像素 -> 视作拖拽(归相机),不是轻点
const _TAP_TIME_MAX := 0.35 # 按下到抬起超过这么久 -> 不是轻点
var _move_target := Vector3.ZERO
var _has_target := false
var _run := false
var _touch_count := 0
var _multi_gesture := false # 曾有 ≥2 指同时按下 -> 本轮不产生点地
@@ -46,10 +88,36 @@ func set_server_speed(moving_speed: int) -> void:
return
server_speed_scale = clampf(float(moving_speed) / 100.0, 0.25, 3.0)
# 程序化下发一个点地目标(脚本化截图 / AI)
func set_input_surfaces(cursor: Node, ui: Node = null, ground: Node = null,
cancel_fishing_cb := Callable(), ground_cancel_fishing_cb := Callable()) -> void:
cursor_manager = cursor
ui_manager = ui
ground_items = ground
cancel_fishing_input = cancel_fishing_cb
cancel_fishing_ground = ground_cancel_fishing_cb
# 程序化下发一个点地目标(脚本化截图 / AI)——等价 NEW_MoveToDestPixelPositionDirection。
func walk_to(world_pos: Vector3) -> void:
_move_target = Vector3(world_pos.x, 0.0, world_pos.z)
_has_target = true
_goto(Vector3(world_pos.x, 0.0, world_pos.z))
# CInstanceBase::NEW_GotoInstanceBaseMovement.cpp:251):三门通过后设 Src/Dst + m_isGoing。
# 被门挡下(syncing / lock)时按 __ReserveClickGround 预约,delay 后重试。
func _goto(dst_flat: Vector3) -> bool:
if player == null:
return false
var flat := Vector3(dst_flat.x - player.position.x, 0.0, dst_flat.z - player.position.z)
# __IsMovableGroundDistance:离脚下太近不动
if flat.length() < MOVABLE_GROUND_DISTANCE_M:
return true
if not _can_translate():
_reserved_ground = dst_flat
_reserved_delay_time = RESERVED_GROUND_DELAY
return false
_src_pos = player.position
_dst_pos = Vector3(dst_flat.x, 0.0, dst_flat.z)
_dst_rot = atan2(flat.x, flat.z)
_is_going = true
return true
func _unhandled_input(e: InputEvent) -> void:
if e is InputEventMouseButton and e.button_index == MOUSE_BUTTON_LEFT and e.pressed:
@@ -103,11 +171,14 @@ func _on_click(screen_pos: Vector2) -> void:
target_selected.emit(best)
return
# 2) 点地:射线与地表求交(沿射线二分找 y == 地表高度)
# 2) 点地:射线与地表求交(沿射线二分找 y == 地表高度)→ NEW_Goto
var hit: Variant = _ray_ground(from, dir)
if hit != null:
_move_target = hit
_has_target = true
if cancel_fishing_ground.is_valid() and bool(cancel_fishing_ground.call()):
return
if cancel_fishing_input.is_valid() and bool(cancel_fishing_input.call()):
return
_goto(hit)
# 射线 vs 实体(节点下所有 MeshInstance3D 的世界 AABB 合并)的进入距离 t;不命中返回 -1。
func _ray_pick_t(from: Vector3, dir: Vector3, node: Node3D) -> float:
@@ -189,11 +260,56 @@ func _wasd() -> Vector2:
func _blocked(x: float, z: float) -> bool:
return world != null and world.has_method("is_blocked") and bool(world.call("is_blocked", x, z))
# CActorInstance::TestActorCollisionActorInstanceCollisionDetection.cpp)近似:距离门
# 800 cm)→ body 球重叠 → 「趋近」判定(新位比旧位更近才算撞,远离时放行 → 能从
# 重叠里走出来)。每个 pickable(已生成的远端 NPC / 怪 / 他人节点)当作一个 Actor 碰撞体;
# `skip_actor_collision`CanSkipCollision)为真整段跳过(≈ CheckAdvancing 开头)。
func _actor_blocked(next_pos: Vector3) -> bool:
if skip_actor_collision or player == null:
return false
var cur := player.position
var combined := ACTOR_BODY_RADIUS_M * 2.0
for n in pickables:
if not is_instance_valid(n):
continue
if bool(n.get_meta("dead", false)): # rVictim.IsDead()
continue
var op: Vector3 = n.global_position
var d_now := Vector2(op.x - cur.x, op.z - cur.z).length()
if d_now > ACTOR_COLLIDE_MAX_DIST_M: # LengthSq > 800² → 不检
continue
var d_next := Vector2(op.x - next_pos.x, op.z - next_pos.z).length()
if d_next < combined and d_next <= d_now + 1e-4:
return true
return false
# SetAdvancingRotationInstanceBaseMovement.cpp):按转向速度逐帧收敛朝向;移动方向本身
# 不受此限制(骑乘时 rotation_speed_deg = 300 → 转向明显变慢)。
func _turn_toward(target_yaw: float, dt: float) -> void:
if player == null:
return
var max_step := deg_to_rad(rotation_speed_deg) * dt
var diff: float = wrapf(target_yaw - player.rotation.y, -PI, PI)
if max_step <= 0.0 or absf(diff) <= max_step:
player.rotation.y = target_yaw
else:
player.rotation.y += signf(diff) * max_step
func _process(dt: float) -> void:
if player == null:
return
if frozen:
_has_target = false
_update_hover_cursor()
# __ReserveClickGround 的 delay 递减(NEW_IsEmptyReservedDelayTime);到点且门已开则重试。
if _reserved_delay_time > 0.0:
_reserved_delay_time -= dt
if _reserved_delay_time <= 0.0 and _reserved_ground != null and _can_translate():
var rg: Vector3 = _reserved_ground
_reserved_ground = null
_goto(rg)
if frozen or locked:
# 停止平移,但保留 m_isGoing(解锁后继续走向 Dst,等价预约不丢)。
anim_state.emit("wait")
return
_run = force_run or Input.is_key_pressed(RUN_HOLD_KEY)
@@ -201,34 +317,54 @@ func _process(dt: float) -> void:
var wasd := _wasd()
if wasd != Vector2.ZERO:
_has_target = false # 键盘覆盖点地
# PythonPlayerInputKeyboard 只在方向键按下时尝试取消钓鱼;记录方向
# 边沿,避免按住键时每帧重复发送 CG_FISHING(0)。
if wasd != _last_wasd and cancel_fishing_input.is_valid() \
and bool(cancel_fishing_input.call()):
_last_wasd = wasd
anim_state.emit("wait")
return
_last_wasd = wasd
_is_going = false # 键盘覆盖点地(NEW_MoveToDirectionm_isGoing = FALSE
var yaw: float = (camera.heading() if camera and camera.has_method("heading") else 0.0)
var fwd := Vector3(sin(yaw), 0, cos(yaw))
var right := Vector3(fwd.z, 0, -fwd.x)
wish = (fwd * -wasd.y + right * wasd.x).normalized()
elif _has_target:
var flat := Vector3(_move_target.x - player.position.x, 0, _move_target.z - player.position.z)
elif _is_going:
var flat := Vector3(_dst_pos.x - player.position.x, 0, _dst_pos.z - player.position.z)
if flat.length() <= ARRIVE_EPS:
_has_target = false
_is_going = false
else:
wish = flat.normalized()
else:
_last_wasd = Vector2.ZERO
var speed := 0.0
if wish != Vector3.ZERO:
var want_speed := (SPEED_RUN if _run else SPEED_WALK) * server_speed_scale
var before := player.position
var np := player.position + wish * want_speed * dt
if not _blocked(np.x, player.position.z):
player.position.x = np.x
if not _blocked(player.position.x, np.z):
player.position.z = np.z
# 动画看「实际位移」而非意图:贴墙磨蹭时该切回 wait
var disp := Vector2(player.position.x - before.x, player.position.z - before.z).length()
if disp > want_speed * dt * 0.25:
speed = want_speed
player.rotation.y = atan2(wish.x, wish.z)
# IsUsingMovingSkill():移动技能中只转向,不平移。
if moving_skill:
_turn_toward(atan2(wish.x, wish.z), dt)
else:
_has_target = false
var want_speed := (SPEED_RUN if _run else SPEED_WALK) * server_speed_scale
var before := player.position
var np := player.position + wish * want_speed * dt
# 逐轴推进:地形 ATTRIBUTE_BLOCK + Actor 碰撞任一挡下该轴 → 不动那一轴
# (单个 Actor 时等价 AdjustDynamicCollisionMovement 的滑移;两轴都挡 = BlockMovement)。
var block_x := _actor_blocked(Vector3(np.x, before.y, player.position.z)) \
or _blocked(np.x, player.position.z)
var block_z := _actor_blocked(Vector3(player.position.x, before.y, np.z)) \
or _blocked(player.position.x, np.z)
if not block_x:
player.position.x = np.x
if not block_z:
player.position.z = np.z
# 动画看「实际位移」而非意图:贴墙 / 顶人磨蹭时该切回 wait
var disp := Vector2(player.position.x - before.x, player.position.z - before.z).length()
if disp > want_speed * dt * 0.25:
speed = want_speed
_turn_toward(atan2(wish.x, wish.z), dt)
else:
_is_going = false
if world and world.has_method("sample_height"):
player.position.y = float(world.call("sample_height", player.position.x, player.position.z))
@@ -240,3 +376,32 @@ func _process(dt: float) -> void:
anim_state.emit("walk")
else:
anim_state.emit("run")
func _update_hover_cursor() -> void:
if cursor_manager == null or not cursor_manager.has_method("set_cursor") or camera == null:
return
var mouse_pos := get_viewport().get_mouse_position()
if ui_manager and ui_manager.has_method("blocks_game_input"):
var motion := InputEventMouseMotion.new()
motion.position = mouse_pos
if ui_manager.blocks_game_input(motion):
cursor_manager.set_cursor("NORMAL")
return
if ground_items and ground_items.has_method("hover_at") and ground_items.hover_at(camera, mouse_pos):
cursor_manager.set_cursor("PICK")
return
var from := camera.project_ray_origin(mouse_pos)
var dir := camera.project_ray_normal(mouse_pos)
var best: Node3D = null
var best_t := 1e20
for n in pickables:
if not is_instance_valid(n):
continue
var t := _ray_pick_t(from, dir, n)
if t >= 0.0 and t < best_t:
best_t = t
best = n
if best:
cursor_manager.set_cursor(String(best.get_meta("cursor_shape", "ATTACK")))
else:
cursor_manager.set_cursor("NORMAL")
+13
View File
@@ -3,6 +3,7 @@
extends SceneTree
const PlayerView = preload("res://ui/player_view.gd")
const Audio = preload("res://audio.gd")
var _fail := 0
@@ -33,6 +34,17 @@ func _run() -> void:
_ck(pv.build(assets, 0), "warrior PlayerView builds")
if pv.anim == null:
return
await process_frame
var body_mesh := pv.model.get_node_or_null("MeshInstance3D") as MeshInstance3D
var body_layer := body_mesh.get_layer_mask() if body_mesh != null else 0
_ck(body_mesh != null and body_layer == 2,
"character mesh is assigned to the character light layer (got %d)" % body_layer)
var audio: Node = Audio.new()
get_root().add_child(audio)
audio.setup(assets)
pv.set_audio(audio)
pv.set_anim_state("run")
_ck(pv._sound_instances.size() == 3, "PlayerView run motion loads paired .mss instances")
_ck(pv.set_motion_id(305), "clap motion resolves")
_ck(String(pv.anim.get("anim_path")).ends_with("action/clap.msa"),
"clap -> action/clap.msa")
@@ -43,3 +55,4 @@ func _run() -> void:
_ck(String(pv.anim.get("anim_path")).ends_with("action/slap_hurt.msa"),
"slap hurt -> action/slap_hurt.msa")
pv.queue_free()
audio.queue_free()
+144
View File
@@ -0,0 +1,144 @@
# player_move_test —— §3.1 Src/Dst 移动模型(player_controller.gdheadless 自检。
# godot --headless --path project --script player_move_test.gd
# 覆盖:NEW_Goto 设 Src/Dst/is_going、__IsMovableGroundDistance 太近不动、
# 三门(frozen/lock/moving_skill)、__ReserveClickGround 的 0.1s delay 重试。
extends SceneTree
const PlayerCtl = preload("res://player_controller.gd")
var _fail := 0
func _ck(c: bool, m: String) -> void:
if not c:
_fail += 1
printerr("FAIL: " + m)
func _init() -> void:
await _run()
if _fail == 0:
print("PASS: player_move_test (Src/Dst move model / gates / reserved ground)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
func _run() -> void:
# SceneTree._init() 早于 root 就绪;等一帧再建 Node3D,避免 !is_inside_tree()。
await process_frame
var pc: Node = PlayerCtl.new()
get_root().add_child(pc)
pc.player = Node3D.new()
get_root().add_child(pc.player)
pc.player.position = Vector3.ZERO
# 1) NEW_Goto:远点 -> 设 Dst + is_going + 面向
var ok: bool = pc._goto(Vector3(10, 0, 0))
_ck(ok, "_goto(far) returns true")
_ck(pc.is_going(), "_goto -> is_going()")
_ck(pc._dst_pos.is_equal_approx(Vector3(10, 0, 0)), "Dst set to click point")
_ck(pc._src_pos.is_equal_approx(Vector3.ZERO), "Src set to current pos")
_ck(is_equal_approx(pc._dst_rot, atan2(10.0, 0.0)), "_dst_rot faces the target")
# 2) __IsMovableGroundDistance:太近(< 1 m)不动
pc._is_going = false
var near_ok: bool = pc._goto(Vector3(0.5, 0, 0))
_ck(near_ok, "_goto(near) returns true (treated as done)")
_ck(not pc.is_going(), "within MOVABLE_GROUND_DISTANCE -> no movement")
# 3) 每帧推进:朝 Dst 走,抵达后 is_going 归零
pc._goto(Vector3(3, 0, 0))
for i in 240:
pc._process(0.05)
if not pc.is_going():
break
_ck(pc.player.position.distance_to(Vector3(3, 0, 0)) < 0.5, "advances to Dst, got %s" % pc.player.position)
_ck(not pc.is_going(), "arrival clears is_going")
# 4) 三门:frozen / lock -> _goto 被挡 -> 预约 + 0.1s 后重试
pc.player.position = Vector3.ZERO
pc._is_going = false
pc.frozen = true
var blocked: bool = pc._goto(Vector3(8, 0, 0))
_ck(not blocked, "_goto while frozen returns false (gated)")
_ck(not pc.is_going(), "frozen -> not going yet")
_ck(pc._reserved_ground != null, "frozen -> click ground reserved")
# 冻结中推进:不平移、不消预约
pc._process(0.05)
_ck(pc.player.position.is_equal_approx(Vector3.ZERO), "frozen -> no translation")
# 解冻:delay 递减到 0 后自动重试预约点
pc.frozen = false
for i in 10:
pc._process(0.02)
_ck(pc.is_going() or pc.player.position.x > 0.1,
"reserved ground fires after delay once unfrozen (going=%s x=%.2f)" % [pc.is_going(), pc.player.position.x])
# 5) moving_skill:只转向不平移
pc.player.position = Vector3.ZERO
pc._is_going = false
pc.moving_skill = true
pc._goto(Vector3(0, 0, 6))
_ck(not pc.is_going(), "_goto while moving_skill is gated (no translate)")
pc.moving_skill = false
# 清掉第 5 组留下的预约点,免得下面各组被它改向。
pc._reserved_ground = null
pc._reserved_delay_time = 0.0
# 6) Actor-Actor 碰撞(TestActorCollision / BlockMovement 近似)——正前方有远端 Actor
# 时走不过去,停在两 body 球相接前;is_going 归零(BlockMovement = 不滑移硬停)。
pc.player.position = Vector3.ZERO
pc.player.rotation.y = 0.0
pc._is_going = false
pc.skip_actor_collision = false
var blocker := Node3D.new()
get_root().add_child(blocker)
blocker.global_position = Vector3(2, 0, 0) # 正前方 2 m
pc.pickables.append(blocker)
pc._goto(Vector3(10, 0, 0)) # 想穿过 blocker
for i in 240:
pc._process(0.05)
if not pc.is_going():
break
_ck(pc.player.position.x > 0.3 and pc.player.position.x < 1.5,
"actor collision halts before overlap (x=%.2f)" % pc.player.position.x)
_ck(not pc.is_going(), "blocked advance clears is_going (BlockMovement)")
# 6b) 死亡的 Actor 不挡(rVictim.IsDead()
pc.player.position = Vector3.ZERO
pc._is_going = false
blocker.set_meta("dead", true)
pc._goto(Vector3(10, 0, 0))
for i in 400:
pc._process(0.05)
if not pc.is_going():
break
_ck(pc.player.position.distance_to(Vector3(10, 0, 0)) < 0.6,
"dead actor does not block (x=%.2f)" % pc.player.position.x)
blocker.set_meta("dead", false)
# 6c) CanSkipCollisionskip_actor_collision)为真 → 整段跳过,可穿过
pc.player.position = Vector3.ZERO
pc._is_going = false
pc.skip_actor_collision = true
pc._goto(Vector3(10, 0, 0))
for i in 400:
pc._process(0.05)
if not pc.is_going():
break
_ck(pc.player.position.distance_to(Vector3(10, 0, 0)) < 0.6,
"skip_actor_collision passes straight through (x=%.2f)" % pc.player.position.x)
pc.skip_actor_collision = false
pc.pickables.erase(blocker)
blocker.free()
# 7) 坐骑转向速度:MountHorse → c_fDefaultHorseRotationSpeed(300),逐帧收敛不瞬转
_ck(is_equal_approx(pc.rotation_speed_deg, pc.ROT_SPEED_DEFAULT_DEG),
"default rotation speed is c_fDefaultRotationSpeed 1200")
pc.rotation_speed_deg = pc.ROT_SPEED_HORSE_DEG
pc.player.position = Vector3.ZERO
pc.player.rotation.y = 0.0
pc._is_going = false
pc._goto(Vector3(0, 0, -6)) # 需要转到 yaw ≈ ±PI
pc._process(0.05)
_ck(absf(pc.player.rotation.y) <= deg_to_rad(pc.ROT_SPEED_HORSE_DEG) * 0.05 + 1e-3,
"mounted: turn clamped to 300 deg/s in one 50ms step (%.3f rad)" % pc.player.rotation.y)
_ck(pc.player.position.z < -0.05, "mounted: still translates toward Dst while turning")
pc.rotation_speed_deg = pc.ROT_SPEED_DEFAULT_DEG
+422
View File
@@ -0,0 +1,422 @@
# PlayerSkill (§3.8 修改 1) —— 逐行移植 CPythonPlayer 的技能三层合法性校验。
#
# 参考 REF/UserInterface/PythonPlayerSkill.cpp
# (a) ClickSkillSlot(dwSlotIndex) :106 —— 槽位 / 技能数据 / 公会技路由 / 被动挡下 /
# 站立技 + 开关技 toggle-off 分支
# (b) __CanUseSkill() :439 —— 主角存在 / 观战 / 骑乘缺骑乘术 / actor 可行动
# (c) __CheckSkillUsable(dwSlotIndex) :166 —— 骑乘⇄骑乘术、安全区攻击技、瓶子、钓鱼、
# 未学、武器种类、箭矢、冷却、HP/SP
# (d) __UseSkill(dwSlotIndex) :460 —— 编排:私人商店 / __CanUseSkill / __CheckSpecialSkill /
# toggle-active 早退 / __CheckSkillUsable / IsUsingSkill /
# 目标解析(联盟 / 安全区 / 可攻击 / 尸体 / 需要目标)
#
# POC 没有 C++ 技能 protoTSkillData 判定位全部落在 `ui/skill_table.gd`(增量 87)。
# 本类是纯校验器:不发包、不播动作,返回 { ok:bool, code:String, target_vid:int }。
# 施放路径(quickbar.activate() 的 skill 分支)在调用 M2Client.use_skill() /
# cast_skill() 之前先过 use_skill()ok=false 时按 code 弹 OnCannotUseSkill 文案。
#
# 运行期上下文(网络下发 / net_play 本地占位)由调用方每次校验前刷新到公开字段,
# 对齐原客户端从 CInstanceBase / m_sysIs* / GetStatus() 读到的那些值。
extends RefCounted
# --- §3.8 修改 1 要求的命名常量 --------------------------------------------
const SKILL_MAX_NUM := 255 # Packet.h:2041
const SKILL_TYPE_GUILD := 3 # CPythonSkill::SKILL_TYPE_GUILD
const RIDING_SKILL_INDEX := 109 # __CanUseSkill 里硬编码的骑乘术判定 vnum
const RIDING_SKILL_MASTER_LEVEL := 20 # GetSkillLevel(109) < 20 的下限
const ITEM_EMPTY_BOTTLE := 27995 # __CheckSkillUsable / __HasItem
const ITEM_POISON_BOTTLE := 27996
const SKILL_INDEX_COMBO := 122 # PythonPlayerModule.cpp:6
const SKILL_INDEX_FISHING := 123 # PythonPlayerModule.cpp:7
const SKILL_INDEX_SUMMON := 131 # PythonPlayerModule.cpp:15__CheckShortMana 豁免)
# code 里这几类是「原客户端也不弹文案 / 已在别处处理」,调用方不该弹 OnCannotUseSkill
const SILENT_CODES := [
"", "OK", "PASSIVE", "BAD_SLOT", "NO_SKILL_DATA", "GUILD_ROUTED",
"TOGGLE_OFF", "SPECIAL_SKILL", "PRIVATE_SHOP", "RESERVED", "NO_RIDING_SKILL",
"CANNOT_ACT", "ALREADY_CASTING",
]
var client: Object # M2Client
var table: RefCounted # SkillTable(增量 87 谓词)
# --- 运行期上下文(调用方刷新;默认取「未知 / 放行」)-----------------------
var observer_mode := false # IsObserverMode()
var mounting := false # CInstanceBase::IsMountingHorse()
var in_safe := false # CInstanceBase::IsInSafe()
var is_fishing := false # CInstanceBase::IsFishingMode()
var is_using_skill := false # CInstanceBase::IsUsingSkill()
var can_act := true # CanProcessNetworkStatePacket(死亡/晕眩/击退 → false
var private_shop_open := false # IsOpenPrivateShop()
var level_limit := true # m_sysIsLevelLimit
var cooltime_check := true # m_sysIsCoolTime
var dash_active := false # __CheckDashAffect
var weapon_sub_type := -1 # CInstanceBase::GetWeaponType()-1 = 未知(跳过匹配)
var arrow_count := -1 # 装备箭袋数量;-1 未知/无限,0 = EMPTY_ARROW
var slot_cd_end := 0.0 # 该槽本地冷却结束时刻(Time.get_ticks_msec()/1000
var cur_sp := -1 # GetStatus(POINT_SP)-1 = 未知(不判 NOT_ENOUGH_SP
var cur_hp := -1 # GetStatus(POINT_HP)-1 = 未知(不判 NOT_ENOUGH_HP
func setup(m2client: Object, skill_table: RefCounted) -> void:
client = m2client
table = skill_table
# --- 小工具 --------------------------------------------------------------
func _now() -> float:
return Time.get_ticks_msec() / 1000.0
func _ok(target_vid := 0) -> Dictionary:
return {"ok": true, "code": "OK", "target_vid": target_vid}
func _no(code: String, target_vid := 0) -> Dictionary:
return {"ok": false, "code": code, "target_vid": target_vid}
static func is_silent_code(code: String) -> bool:
return SILENT_CODES.has(code)
func _main_vid() -> int:
if client and client.has_method("get_main_vid"):
return int(client.get_main_vid())
return 0
func _main_entity() -> Dictionary:
var v := _main_vid()
if client and client.has_method("get_entity") and v != 0:
var e: Variant = client.get_entity(v)
if e is Dictionary:
return e
return {}
# GetSkillLevel(idx) / GetSkillGrade(idx) 等价:查 M2Client.get_skills()(按技能 vnum)。
func _skill_level(skill_id: int) -> int:
if client == null or not client.has_method("get_skills"):
return 0
for s in client.get_skills():
if int(s.get("id", 0)) == skill_id:
return int(s.get("level", 0))
return 0
func _skill_grade(skill_id: int) -> int:
if client == null or not client.has_method("get_skills"):
return 0
for s in client.get_skills():
if int(s.get("id", 0)) == skill_id:
return int(s.get("master", 0)) # 0..3master 即 grade
return 0
# __HasItem(dwItemID):扫背包(POC 无独立龙魂背包,省略那段)。
func _has_item(vnum: int) -> bool:
if client == null or not client.has_method("get_inventory"):
return false
for it in client.get_inventory():
if int(it.get("vnum", 0)) == vnum and int(it.get("count", 0)) > 0:
return true
return false
# __HasEnoughArrow():原版查装备箭袋 subtype==WEAPON_ARROWPOC 用 net_play 的
# _arrow_count 占位(真值待服务端 / 后续增量)。-1 视为无限。
func _has_enough_arrow() -> bool:
return arrow_count != 0
# __CheckRestSkillCoolTime(slot)m_sysIsCoolTime 关 → 不在冷却;否则比本地剩余。
func _check_rest_skill_cooltime() -> bool:
if not cooltime_check:
return false
return _now() < slot_cd_end
# §3.8 修改 3NeedSP 公式经 skill_table.need_sp().msk NeedSPFormula)解析。
#
# __CheckShortLifePythonPlayerSkill.cpp:354):
# !IsUseHPSkill() → false
# dwNeedHP = GetNeedSP(eff%) HP 消耗也取 NeedSP,参考端怪癖)
# dwNeedHP <= GetStatus(POINT_HP) → false 否则 true
func _check_short_life(skill_id: int) -> bool:
if table == null or not table.has_method("need_hp"):
return false
if not table.is_use_hp(skill_id):
return false
if cur_hp < 0: # HP 未知 → 不误挡
return false
var need_hp := int(table.need_hp(skill_id, _skill_level(skill_id)))
if need_hp < 0: # 公式解析不出 → 不挡
return false
return need_hp > cur_hp
# __CheckShortManaPythonPlayerSkill.cpp:320):
# idx == c_iSkillIndex_Summon(131) → false
# iNeedSP = GetNeedSP(eff%) ; icurSP = GetStatus(POINT_SP)
# !IsToggleSkill() && iNeedSP == 0 → true"strange game data"
# CanUseIfNotEnough(): icurSP <= 0 → true
# else: iNeedSP != -1 && iNeedSP > icurSP → true
# 否则 false
func _check_short_mana(skill_id: int) -> bool:
if table == null or not table.has_method("need_sp"):
return false
if skill_id == SKILL_INDEX_SUMMON:
return false
if cur_sp < 0: # SP 未知 → 不误挡
return false
var need_sp := int(table.need_sp(skill_id, _skill_level(skill_id)))
# 参考端:非开关技且 iNeedSP == 0 视为坏数据并拦截。POC 约定 need_sp() 用 -1
# 表示「.msk 没有 NeedSPFormula」(解析不出),此时不套这条以免误挡;只有确有
# 公式且算出 0 才按参考拦。
if not table.is_toggle(skill_id) and need_sp == 0:
return true
if table.can_use_if_not_enough(skill_id):
return cur_sp <= 0
return need_sp != -1 and need_sp > cur_sp
func _entity_kind(e: Dictionary) -> int:
return int(e.get("kind", int(e.get("type", -1))))
# IsAttackableInstance 的近似:PC(0) / MONSTER(2) 且未死可打。
# 帝国 / 结盟 / PK / 决斗规则归 §5.4,这里不判。
func _is_attackable(e: Dictionary) -> bool:
if e.is_empty() or bool(e.get("dead", false)):
return false
return _entity_kind(e) in [0, 2]
# ========================================================================
# (a) ClickSkillSlot —— PythonPlayerSkill.cpp:106
# ========================================================================
# is_active:该槽当前是否已激活(IsSkillActive(slot)),仅开关技有意义。
func click_skill_slot(skill_id: int, is_active := false) -> Dictionary:
# if (dwSlotIndex >= SKILL_MAX_NUM) return;
if skill_id < 0 or skill_id >= SKILL_MAX_NUM:
return _no("BAD_SLOT")
# if (!GetSkillData(rkSkillInst.dwIndex, &pSkillData)) return;
if table == null or not table.has(skill_id):
return _no("NO_SKILL_DATA")
# if (SKILL_TYPE_GUILD == pSkillData->byType) { UseGuildSkill(...); return; }
if table.skill_type_of(skill_id) == SKILL_TYPE_GUILD:
return _no("GUILD_ROUTED")
# if (!pSkillData->IsCanUseSkill()) return; —— 被动技,静默
if not table.is_can_use_skill(skill_id):
return _no("PASSIVE")
# if (pSkillData->IsStandingSkill())
if table.is_standing(skill_id):
# if (IsToggleSkill() && IsSkillActive(slot)) { SendUseSkillPacket; return; }
if table.is_toggle(skill_id) and is_active:
if not can_act: # pkInstMain->IsUsingSkill() 之前的取指针失败
return _no("CANNOT_ACT")
if is_using_skill:
return _no("ALREADY_CASTING")
return _no("TOGGLE_OFF") # toggle-off:调用方直接发 use_skill 关掉
# __UseSkill(dwSlotIndex);
return use_skill(skill_id, is_active)
# 非站立技:原版还有 m_dwcurSkillSlotIndex / __IsRightButtonSkillMode 的两段式选中,
# POC 快捷栏是一键即放,等价于 !__IsRightButtonSkillMode() 分支 -> __UseSkill。
return use_skill(skill_id, is_active)
# ========================================================================
# (b) __CanUseSkill —— PythonPlayerSkill.cpp:439
# ========================================================================
func can_use_skill() -> Dictionary:
# if (!NEW_GetMainActorPtr()) return false;
if _main_vid() == 0:
return _no("CANNOT_ACT")
# if (IsObserverMode()) return false;
if observer_mode:
return _no("OBSERVER")
# if (IsMountingHorse() && GetSkillGrade(109) < 1 && GetSkillLevel(109) < 20) return false;
if mounting and _skill_grade(RIDING_SKILL_INDEX) < 1 \
and _skill_level(RIDING_SKILL_INDEX) < RIDING_SKILL_MASTER_LEVEL:
return _no("NO_RIDING_SKILL")
# return pkInstMain->CanUseSkill(); —— 死亡 / 晕眩 / 击退 / 施法中
if not can_act:
return _no("CANNOT_ACT")
return _ok()
# ========================================================================
# (c) __CheckSkillUsable —— PythonPlayerSkill.cpp:166
# ========================================================================
func check_skill_usable(skill_id: int) -> Dictionary:
# if (!NEW_GetMainActorPtr()) return false;
if _main_vid() == 0:
return _no("CANNOT_ACT")
# if (dwSlotIndex >= SKILL_MAX_NUM) return false;
if skill_id < 0 or skill_id >= SKILL_MAX_NUM:
return _no("BAD_SLOT")
# if (!GetSkillData(...)) return false;
if table == null or not table.has(skill_id):
return _no("NO_SKILL_DATA")
# if (IsMountingHorse() && !IsHorseSkill()) -> "NOT_HORSE_SKILL"
if mounting and not table.is_horse_skill(skill_id):
return _no("NOT_HORSE_SKILL")
# if (IsHorseSkill() && !IsMountingHorse()) -> "HAVE_TO_RIDE"
if table.is_horse_skill(skill_id) and not mounting:
return _no("HAVE_TO_RIDE")
# if (IsAttackSkill() && IsInSafe()) -> "IN_SAFE"
if table.is_attack(skill_id) and in_safe:
return _no("IN_SAFE")
# if (!IsCanUseSkill()) return false; —— 被动技
if not table.is_can_use_skill(skill_id):
return _no("PASSIVE")
# if (IsNeedEmptyBottle() && !__HasItem(27995)) -> "NEED_EMPTY_BOTTLE"
if table.is_need_empty_bottle(skill_id) and not _has_item(ITEM_EMPTY_BOTTLE):
return _no("NEED_EMPTY_BOTTLE")
# if (IsNeedPoisonBottle() && !__HasItem(27996)) -> "NEED_POISON_BOTTLE"
if table.is_need_poison_bottle(skill_id) and not _has_item(ITEM_POISON_BOTTLE):
return _no("NEED_POISON_BOTTLE")
# if (IsFishingMode()) -> "REMOVE_FISHING_ROD"
if is_fishing:
return _no("REMOVE_FISHING_ROD")
# if (m_sysIsLevelLimit && rkSkillInst.iLevel <= 0) -> "NOT_YET_LEARN"
if level_limit and _skill_level(skill_id) <= 0:
return _no("NOT_YET_LEARN")
# if (!CanUseWeaponType(GetWeaponType())) -> "NOT_MATCHABLE_WEAPON"
# weapon_sub_type < 0(未知)时跳过,避免误挡。
if weapon_sub_type >= 0 and not table.can_use_weapon_type(skill_id, weapon_sub_type):
return _no("NOT_MATCHABLE_WEAPON")
# if (!IsHorseSkill()) { __CheckShortArrow; if (IsNeedBow() && !__HasEnoughArrow()) ... }
if not table.is_horse_skill(skill_id):
if table.is_need_bow(skill_id) and not _has_enough_arrow():
return _no("EMPTY_ARROW")
# __CheckDashAffect 分支:冲刺 affect 下、非蓄力技才查冷却;否则查冷却 + HP + SP
if dash_active:
if not table.is_charge_skill(skill_id):
if _check_rest_skill_cooltime():
return _no("WAIT_COOLTIME")
else:
if _check_rest_skill_cooltime():
return _no("WAIT_COOLTIME")
if _check_short_life(skill_id):
return _no("NOT_ENOUGH_HP")
if _check_short_mana(skill_id):
return _no("NOT_ENOUGH_SP")
return _ok()
# ========================================================================
# (d) __UseSkill —— PythonPlayerSkill.cpp:460
# ========================================================================
func use_skill(skill_id: int, is_active := false) -> Dictionary:
# if (IsOpenPrivateShop()) return true; —— 静默成功但什么都不做
if private_shop_open:
return _no("PRIVATE_SHOP")
# if (!__CanUseSkill()) return false;
var b := can_use_skill()
if not b.ok:
return b
# if (dwSlotIndex >= SKILL_MAX_NUM) return false;
if skill_id < 0 or skill_id >= SKILL_MAX_NUM:
return _no("BAD_SLOT")
# if (__CheckSpecialSkill(rkSkillInst.dwIndex)) return true;
if _check_special_skill(skill_id):
return _no("SPECIAL_SKILL")
# if (!GetSkillData(...)) return false;
if table == null or not table.has(skill_id):
return _no("NO_SKILL_DATA")
# if (IsToggleSkill() && IsSkillActive(slot)) { SendUseSkillPacket; return false; }
if table.is_toggle(skill_id) and is_active:
return _no("TOGGLE_OFF")
# if (!__CheckSkillUsable(dwSlotIndex)) return false;
var c := check_skill_usable(skill_id)
if not c.ok:
return c
# if (pkInstMain->IsUsingSkill()) return false;
if is_using_skill:
return _no("ALREADY_CASTING")
# 目标解析(IsNeedTarget || CanChangeDirection || IsAutoSearchTarget
var need_target: bool = table.is_need_target(skill_id) \
or table.can_change_direction(skill_id) \
or table.is_auto_search_target(skill_id)
if not need_target:
return _ok(0)
return _resolve_target(skill_id)
# __UseSkill 里的目标分支(PythonPlayerSkill.cpp:520-650)。
# 距离 / __ReserveUseSkill / 扇形·圆形多目标 SendAddFlyTargetingPacket 归 §3.4/§3.9
# 这里只判目标「类型」是否合法并回传解析到的 vid。
func _resolve_target(skill_id: int) -> Dictionary:
var main_vid := _main_vid()
var tvid := 0
if client and client.has_method("get_target"):
tvid = int(client.get_target().get("vid", 0))
var need_corpse: bool = table.is_need_corpse(skill_id)
var te: Dictionary = {}
if tvid != 0 and client and client.has_method("get_entity"):
var e: Variant = client.get_entity(tvid)
if e is Dictionary:
te = e
# --- 有目标 ---
if not te.is_empty():
var is_self := tvid == main_vid
var target_dead := bool(te.get("dead", false))
# 需要尸体的技能:目标必须已死
if need_corpse and not target_dead:
return _no("ONLY_FOR_CORPSE")
if table.is_only_for_alliance(skill_id):
if is_self:
if not table.can_use_for_me(skill_id):
return _no("CANNOT_USE_SELF")
return _ok(main_vid)
# 非自己:结盟技打友方(不可攻击的 PC)合法;打敌人不合法
if _entity_kind(te) == 0 and not _is_attackable(te):
return _ok(tvid)
if table.can_use_for_me(skill_id):
return _ok(main_vid) # 目标非法 -> 回落自身
return _no("ONLY_FOR_ALLIANCE")
else:
# 攻击类:目标在安全区不可打
if bool(te.get("in_safe", false)):
return _no("CANNOT_ATTACK_ENEMY_IN_SAFE_AREA")
if _is_attackable(te):
return _ok(tvid)
return _no("CANNOT_ATTACK")
# --- 无目标 ---
if table.is_auto_search_target(skill_id):
# NEW_GetFrontInstance 在 POC 侧没有等价物;无目标即失败。
if not table.can_use_for_me(skill_id) and not need_corpse:
return _no("NEED_TARGET")
if table.can_use_for_me(skill_id):
return _ok(main_vid)
if need_corpse:
return _no("ONLY_FOR_CORPSE")
return _no("NEED_TARGET")
# __CheckSpecialSkill —— PythonPlayerSkill.cpp:950(钓鱼 123 / 连击 122)。
func _check_special_skill(skill_id: int) -> bool:
if skill_id == SKILL_INDEX_FISHING:
return true
if skill_id == SKILL_INDEX_COMBO:
return true
return false
+261
View File
@@ -0,0 +1,261 @@
# player_skill_test —— §3.8 修改 1player_skill.gd 技能三层合法性校验 headless 自检。
# godot --headless --path project --script player_skill_test.gd
#
# 逐层覆盖 ClickSkillSlot(a) / __CanUseSkill(b) / __CheckSkillUsable(c) / __UseSkill(d)
# 的每个 return 分支,断言返回 { ok, code, target_vid } 与 PythonPlayerSkill.cpp 一致。
extends SceneTree
const SkillTable = preload("res://ui/skill_table.gd")
const PlayerSkill = preload("res://player_skill.gd")
class FakeClient extends Node:
var main := 1000
var target := {"vid": 2000}
var entities := {1000: {"kind": 0}, 2000: {"kind": 2}}
var skills := [
{"id": 1, "level": 5, "master": 0},
{"id": 46, "level": 10, "master": 0},
{"id": 109, "level": 1, "master": 0},
]
var inv: Array = []
func get_main_vid() -> int: return main
func get_entity(v) -> Dictionary: return entities.get(int(v), {})
func get_target() -> Dictionary: return target
func get_skills() -> Array: return skills
func get_inventory() -> Array: return inv
var _fail := 0
func _ck(c: bool, m: String) -> void:
if not c:
_fail += 1
printerr("FAIL: " + m)
func _init() -> void:
_run()
if _fail == 0:
print("PASS: player_skill_test (§3.8 三层校验)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
# 每次校验前把 gate 的上下文复位到「放行」缺省,避免用例互相污染。
func _reset(g: RefCounted) -> void:
g.observer_mode = false
g.mounting = false
g.in_safe = false
g.is_fishing = false
g.is_using_skill = false
g.can_act = true
g.private_shop_open = false
g.level_limit = true
g.cooltime_check = true
g.dash_active = false
g.weapon_sub_type = -1
g.arrow_count = -1
g.slot_cd_end = 0.0
g.cur_hp = -1
g.cur_sp = -1
func _run() -> void:
var assets := AssetRoot.path()
var st := SkillTable.new()
var loaded := false
for lang in ["en", "common"]:
if st.load_file(assets.path_join("locale/locale/%s/skilldesc.txt" % lang)):
loaded = true
break
if not loaded:
print(" (skip: no skilldesc.txt)")
return
var fc := FakeClient.new()
get_root().add_child(fc)
var g := PlayerSkill.new()
g.setup(fc, st)
# 常量对齐 §3.8 修改 1 硬编码
_ck(g.SKILL_MAX_NUM == 255, "SKILL_MAX_NUM == 255")
_ck(g.SKILL_TYPE_GUILD == 3, "SKILL_TYPE_GUILD == 3")
_ck(g.RIDING_SKILL_INDEX == 109, "RIDING_SKILL_INDEX == 109")
_ck(g.ITEM_EMPTY_BOTTLE == 27995 and g.ITEM_POISON_BOTTLE == 27996, "空瓶/毒瓶 vnum")
_ck(g.SKILL_INDEX_FISHING == 123 and g.SKILL_INDEX_COMBO == 122, "特殊技 vnum")
# ---------- (a) ClickSkillSlot ----------
_reset(g)
_ck(g.click_skill_slot(-1).code == "BAD_SLOT", "(a) 槽越界 -> BAD_SLOT")
_ck(g.click_skill_slot(999).code == "BAD_SLOT", "(a) 槽 >= SKILL_MAX_NUM -> BAD_SLOT")
# 表里查不到的 id
var missing := 0
for cand in range(240, 255):
if not st.has(cand):
missing = cand
break
if missing != 0:
_ck(g.click_skill_slot(missing).code == "NO_SKILL_DATA", "(a) 无技能数据 -> NO_SKILL_DATA")
# 公会技 151 -> 路由
if st.has(151) and st.is_guild_skill(151):
_ck(g.click_skill_slot(151).code == "GUILD_ROUTED", "(a) 公会技 -> GUILD_ROUTED")
# 被动技 121 -> 静默挡下
if st.has(121) and st.is_passive(121):
var r := g.click_skill_slot(121)
_ck(not r.ok and r.code == "PASSIVE", "(a) 被动技 -> PASSIVE")
# 站立 + 开关技,已激活 -> TOGGLE_OFF
if st.has(63) and st.is_standing(63) and st.is_toggle(63):
_ck(g.click_skill_slot(63, true).code == "TOGGLE_OFF", "(a) 开关技已激活 -> TOGGLE_OFF")
# ---------- (b) __CanUseSkill ----------
_reset(g)
_ck(g.can_use_skill().ok, "(b) 缺省上下文 -> ok")
_reset(g); g.observer_mode = true
_ck(g.can_use_skill().code == "OBSERVER", "(b) 观战 -> OBSERVER")
_reset(g); g.can_act = false
_ck(g.can_use_skill().code == "CANNOT_ACT", "(b) 死亡/晕眩/击退 -> CANNOT_ACT")
_reset(g); g.mounting = true # 无骑乘术(skills 里 109 只有 level 1 / grade 0
_ck(g.can_use_skill().code == "NO_RIDING_SKILL", "(b) 骑乘缺骑乘术 -> NO_RIDING_SKILL")
_reset(g); g.mounting = true
fc.skills[2]["level"] = 20 # 骑乘术 109 练满
_ck(g.can_use_skill().ok, "(b) 骑乘术 lv20 -> ok")
fc.skills[2]["level"] = 1
# 无主角
_reset(g); fc.main = 0
_ck(g.can_use_skill().code == "CANNOT_ACT", "(b) 无主角 -> CANNOT_ACT")
fc.main = 1000
# ---------- (c) __CheckSkillUsable ----------
# skill 1 Three-Way CutATTACK_SKILL|NEED_TARGET,非骑乘技,需武器 SWORD|TWO_HANDED
_reset(g)
_ck(g.check_skill_usable(1).ok, "(c) skill 1 缺省 -> ok")
_reset(g); g.mounting = true
_ck(g.check_skill_usable(1).code == "NOT_HORSE_SKILL", "(c) 骑乘中放非骑乘技 -> NOT_HORSE_SKILL")
# skill 138 Horse StumpHORSE_SKILL
if st.has(138) and st.is_horse_skill(138):
_reset(g); g.level_limit = false
_ck(g.check_skill_usable(138).code == "HAVE_TO_RIDE", "(c) 未骑乘放骑乘技 -> HAVE_TO_RIDE")
_reset(g); g.mounting = true; g.level_limit = false
var r138 := g.check_skill_usable(138)
_ck(r138.ok, "(c) 骑乘放骑乘技 -> 放行")
_reset(g); g.in_safe = true
_ck(g.check_skill_usable(1).code == "IN_SAFE", "(c) 安全区放攻击技 -> IN_SAFE")
_reset(g); g.is_fishing = true
_ck(g.check_skill_usable(1).code == "REMOVE_FISHING_ROD", "(c) 钓鱼态 -> REMOVE_FISHING_ROD")
# 未学:表里有但 get_skills 没有
var unlearned := 0
for cand in [2, 3, 4, 5, 6, 7, 8, 9]:
if st.has(cand) and g._skill_level(cand) == 0 and not st.is_horse_skill(cand) \
and not st.is_passive(cand):
unlearned = cand
break
if unlearned != 0:
_reset(g)
_ck(g.check_skill_usable(unlearned).code == "NOT_YET_LEARN", "(c) 未学技能 -> NOT_YET_LEARN")
_reset(g); g.level_limit = false
_ck(g.check_skill_usable(unlearned).code != "NOT_YET_LEARN", "(c) 关等级限制 -> 不再挡")
# 武器种类不匹配:skill 46 需 BOW
if st.has(46) and st.has_weapon_limitation(46) and st.is_need_bow(46):
_reset(g); g.weapon_sub_type = st.WEAPON_SWORD
_ck(g.check_skill_usable(46).code == "NOT_MATCHABLE_WEAPON", "(c) 拿剑放弓技 -> NOT_MATCHABLE_WEAPON")
_reset(g); g.weapon_sub_type = st.WEAPON_BOW
_ck(g.check_skill_usable(46).ok, "(c) 拿弓放弓技 -> ok")
_reset(g); g.weapon_sub_type = st.WEAPON_BOW; g.arrow_count = 0
_ck(g.check_skill_usable(46).code == "EMPTY_ARROW", "(c) 弓技无箭 -> EMPTY_ARROW")
# 冷却
_reset(g); g.slot_cd_end = g._now() + 5.0
_ck(g.check_skill_usable(1).code == "WAIT_COOLTIME", "(c) 冷却中 -> WAIT_COOLTIME")
_reset(g); g.slot_cd_end = g._now() + 5.0; g.cooltime_check = false
_ck(g.check_skill_usable(1).ok, "(c) m_sysIsCoolTime 关 -> 不查冷却")
# 冲刺 affect:非蓄力技仍查冷却
_reset(g); g.slot_cd_end = g._now() + 5.0; g.dash_active = true
_ck(g.check_skill_usable(1).code == "WAIT_COOLTIME", "(c) dash + 非蓄力 -> 仍查冷却")
# 蓄力技 5 Dash:冲刺 affect 下跳过冷却
if st.has(5) and st.is_charge_skill(5):
_reset(g); g.slot_cd_end = g._now() + 5.0; g.dash_active = true; g.level_limit = false
var r5 := g.check_skill_usable(5)
_ck(r5.ok or r5.code == "NOT_MATCHABLE_WEAPON", "(c) dash + 蓄力技 -> 跳过冷却")
# ---------- (c) §3.8 修改 3NeedSP / __CheckShortMana / __CheckShortLife ----------
# skill 1 samyeon.msk: NeedSPFormula "40 + (100 * SkillPoint)"_skill_point(5) = 12/100。
_ck(is_equal_approx(st._skill_point(20), 0.5), "_skill_point(20) = master 50/100")
_ck(is_equal_approx(st._skill_point(0), 0.0) and is_equal_approx(st._skill_point(50), 0.0),
"_skill_point 越界 -> 0")
if st.has(1) and String(st.entry(1).get("need_sp_formula", "")) != "":
_ck(st.need_sp(1, 5) == 52, "need_sp(skill 1, lv5) = 40 + 100*0.12 = 52")
_ck(st.need_hp(1, 5) == st.need_sp(1, 5), "need_hp 复用 GetNeedSP")
# __CheckShortManaSP 不足 -> NOT_ENOUGH_SP
_reset(g); g.cur_sp = 10
_ck(g.check_skill_usable(1).code == "NOT_ENOUGH_SP", "(c) SP 10 < 52 -> NOT_ENOUGH_SP")
_reset(g); g.cur_sp = 200
_ck(g.check_skill_usable(1).ok, "(c) SP 200 >= 52 -> ok")
_reset(g); g.cur_sp = -1
_ck(g.check_skill_usable(1).ok, "(c) SP 未知 -> 不判 NOT_ENOUGH_SP")
# 未知公式 -> need_sp = -1,不拦截
var no_sp := 0
for cand in st.for_job("WARRIOR"):
if st.has(cand) and String(st.entry(cand).get("need_sp_formula", "")) == "" \
and g._skill_level(cand) > 0 and not st.is_passive(cand) \
and not st.is_horse_skill(cand) and not st.is_toggle(cand):
no_sp = cand
break
if no_sp != 0:
_reset(g); g.cur_sp = 0
_ck(g.check_skill_usable(no_sp).code != "NOT_ENOUGH_SP",
"(c) 无 NeedSPFormula(%d) + SP 0 -> 不误挡" % no_sp)
# __CheckShortLifeskill 1 非 USE_HP -> 即便 HP 极低也不挡
_reset(g); g.cur_hp = 1
_ck(g.check_skill_usable(1).code != "NOT_ENOUGH_HP", "(c) 非 USE_HP 技 + HP 1 -> 不判 NOT_ENOUGH_HP")
# ---------- (d) __UseSkill ----------
_reset(g); g.private_shop_open = true
_ck(g.use_skill(1).code == "PRIVATE_SHOP", "(d) 私人商店 -> PRIVATE_SHOP")
_reset(g); g.is_using_skill = true
_ck(g.use_skill(1).code == "ALREADY_CASTING", "(d) 施法中 -> ALREADY_CASTING")
# 特殊技
_reset(g)
_ck(g.use_skill(g.SKILL_INDEX_FISHING).code == "SPECIAL_SKILL", "(d) 钓鱼技 -> SPECIAL_SKILL")
_ck(g.use_skill(g.SKILL_INDEX_COMBO).code == "SPECIAL_SKILL", "(d) 连击技 -> SPECIAL_SKILL")
# 目标解析:skill 1 需要目标
_reset(g)
if st.is_need_target(1):
fc.target = {"vid": 2000}; fc.entities[2000] = {"kind": 2}
var rt := g.use_skill(1)
_ck(rt.ok and rt.target_vid == 2000, "(d) 有可攻击目标 -> ok, target=2000")
fc.target = {"vid": 0}
_ck(g.use_skill(1).code == "NEED_TARGET", "(d) 无目标 -> NEED_TARGET")
fc.target = {"vid": 2000}; fc.entities[2000] = {"kind": 2, "in_safe": true}
_ck(g.use_skill(1).code == "CANNOT_ATTACK_ENEMY_IN_SAFE_AREA",
"(d) 目标在安全区 -> CANNOT_ATTACK_ENEMY_IN_SAFE_AREA")
fc.target = {"vid": 2000}; fc.entities[2000] = {"kind": 1} # NPC
_ck(g.use_skill(1).code == "CANNOT_ATTACK", "(d) 目标不可攻击 -> CANNOT_ATTACK")
fc.entities[2000] = {"kind": 2}
# 结盟技 109 CureCAN_USE_FOR_ME|ONLY_FOR_ALLIANCE
if st.has(109) and st.is_only_for_alliance(109) and st.can_use_for_me(109):
_reset(g)
var needs_tgt: bool = st.is_need_target(109) or st.can_change_direction(109) \
or st.is_auto_search_target(109)
if needs_tgt:
fc.target = {"vid": 0}
var rself := g.use_skill(109)
_ck(rself.ok and rself.target_vid == fc.main,
"(d) 结盟技无目标 -> 回落自身 target=main")
fc.target = {"vid": 1000} # 自己
_ck(g.use_skill(109).ok, "(d) 结盟技对自己 -> ok")
fc.target = {"vid": 2000}
# 非目标类技能 -> 直接 ok, target 0
var no_target_skill := 0
for id in st.for_job("WARRIOR"):
if g._skill_level(id) > 0 and not st.is_need_target(id) \
and not st.can_change_direction(id) and not st.is_auto_search_target(id) \
and not st.is_passive(id) and not st.is_horse_skill(id):
no_target_skill = id
break
if no_target_skill != 0:
_reset(g)
var rnt := g.use_skill(no_target_skill)
_ck(rnt.ok and rnt.target_vid == 0 or rnt.code == "NOT_MATCHABLE_WEAPON",
"(d) 非目标技 %d -> ok/target 0" % no_target_skill)
# is_silent_code
_ck(PlayerSkill.is_silent_code("PASSIVE") and PlayerSkill.is_silent_code("TOGGLE_OFF")
and not PlayerSkill.is_silent_code("IN_SAFE"),
"is_silent_code 分类正确")
+14 -7
View File
@@ -55,7 +55,8 @@ func _run() -> void:
var fp := FakeProto.new()
get_root().add_child(fc)
get_root().add_child(fp)
fc.inventory = [{"cell": 4, "vnum": 11901, "count": 1}, {"cell": 5, "vnum": 27993, "count": 3}]
fc.inventory = [{"cell": 4, "vnum": 11901, "count": 1},
{"cell": 5, "vnum": 27993, "count": 3}, {"cell": 6, "vnum": 30001, "count": 2}]
var pu: Node = PrivateShopUI.new()
get_root().add_child(pu)
@@ -66,9 +67,13 @@ func _run() -> void:
return
_ck(pu._cells.size() == 40, "ItemSlot = 40-slot grid (5x8, shop.SHOP_SLOT_COUNT)")
_ck(is_instance_valid(pu._sign), "NameLine overlaid with a LineEdit")
_ck(pu._inv_list.get_child_count() == 2, "2 inventory candidates")
_ck(pu._inv_list.get_child_count() == 3, "3 inventory candidates")
_ck((pu._inv_list.get_child(0) as Button).has_method("_get_drag_data"),
"inventory candidate supports drag data")
_ck(not pu._drop_mouse_item({"window": 1, "cell": 99, "vnum": 300, "count": 1,
"anti_flags": (1 << 13)}, 2), "ANTIFLAG_GIVE item cannot enter private shop")
_ck(not pu._drop_mouse_item({"window": 1, "cell": 99, "vnum": 301, "count": 1,
"anti_flags": (1 << 16)}, 2), "ANTIFLAG_MYSHOP item cannot enter private shop")
# 未上货 → 개설 무동작
pu._ok()
@@ -82,13 +87,14 @@ func _run() -> void:
pu._place(3, 250000)
_ck(pu._stock.has(3) and int(pu._stock[3]["price"]) == 250000, "stock[3] placed")
_ck(pu._picked == null, "pick cleared after place")
_ck(pu._inv_list.get_child_count() == 1, "placed item leaves candidate list")
_ck(pu._inv_list.get_child_count() == 2, "placed item leaves candidate list")
# 标准 Godot 拖放入口与点击选取共用同一个落位逻辑。
pu._drop_slot(Vector2.ZERO, {"private_shop_entry": {"cell": 5, "vnum": 27993, "count": 3}}, 6)
_ck(is_instance_valid(pu._price_dialog), "dragging candidate onto empty slot opens price dialog")
pu._place(6, 100)
_ck(pu._stock.has(6), "dragged item is retained in stock")
# 拿起第 2 件(cell 5)→ 落到格 0(更小格号 → 排前)
# 拿起第 3 件(cell 6)→ 落到格 0(更小格号 → 排前)
(pu._inv_list.get_child(0) as Button).pressed.emit()
pu._place(0, 99)
pu._sign.text = "Cheap swords"
@@ -96,10 +102,11 @@ func _run() -> void:
_ck(_has_call(fc, "open_shop", func(c):
if String(c[1]) != "Cheap swords": return false
var items: Array = c[2]
if items.size() != 2: return false
return int(items[0]["vnum"]) == 27993 and int(items[0]["display_pos"]) == 0 \
if items.size() != 3: return false
return int(items[0]["vnum"]) == 30001 and int(items[0]["display_pos"]) == 0 \
and int(items[1]["vnum"]) == 11901 and int(items[1]["price"]) == 250000 \
and int(items[1]["inv_cell"]) == 4 and int(items[1]["display_pos"]) == 3),
and int(items[1]["inv_cell"]) == 4 and int(items[1]["display_pos"]) == 3 \
and int(items[2]["vnum"]) == 27993 and int(items[2]["display_pos"]) == 6),
"개설 → open_private_shop(sign, stock 按格号排序 + display_pos)")
_ck(not pu.is_open(), "closes after 개설")
+167
View File
@@ -0,0 +1,167 @@
# quest_event_test —— §5.1 Quest EventManager 1:1CPythonEventManager):
# EVENT_TYPE_MAP42 tag+ GetScriptEventIndex(前导 '/'+ ProcessEventSet 分发 +
# SEventSet 状态(字段名与 REF/UserInterface/PythonEventManager.h 一致)。
# godot --headless --path project --script quest_event_test.gd
# 退出码 0 = 全过。
extends SceneTree
const QuestDialog = preload("res://ui/quest_dialog.gd")
var _fail := 0
func _ck(c: bool, m: String) -> void:
if not c:
_fail += 1
printerr("FAIL: " + m)
func _init() -> void:
_run()
if _fail == 0:
print("PASS: quest_event_test (§5.1 EventTypeMap + ProcessEventSet 1:1)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
func _run() -> void:
var qd := QuestDialog.new()
# --- 1. EventTypeMap 完整性(PythonEventManager.cpp:1148-120742 条)---
var tags := ["LETTER", "COLOR", "DELAY", "ENTER", "WAIT", "CLEAR", "QUESTION", "NEXT",
"DONE", "LEFTIMAGE", "TOPIMAGE", "BGIMAGE", "IMAGE", "ADDMAPSIGNAL", "CLEARMAPSIGNAL",
"SETMSGPOS", "ADJMSGPOS", "SETCMAPPOS", "QUESTBUTTON", "QUESTBUTTON_CLOSE", "SLEEP",
"SET_CAMERA", "BLEND_CAMERA", "RESTORE_CAMERA", "FADE_OUT", "FADE_IN", "WHITE_OUT",
"WHITE_IN", "CLEAR_TEXT", "TEXT_HORIZONTAL_ALIGN_CENTER", "TITLE_IMAGE", "RUN_CINEMA",
"DUNGEON_RESULT", "ITEM", "MOB", "COLOR256", "WINDOW_SIZE", "INPUT", "CONFIRM_WAIT",
"END_CONFIRM_WAIT", "INSERT_IMAGE", "SELECT_ITEM"]
_ck(tags.size() == 42, "EventTypeMap: 42 tags enumerated (%d)" % tags.size())
var missing := 0
for t in tags:
if not qd.EVENT_TYPE_MAP.has(t):
missing += 1
printerr(" missing EVENT_TYPE_MAP[%s]" % t)
_ck(missing == 0, "EventTypeMap: every tag present")
_ck(qd.EVENT_TYPE_MAP.size() == 42, "EventTypeMap: no extra keys (%d)" % qd.EVENT_TYPE_MAP.size())
# --- 2. GetScriptEventIndex:前导 '/' = EVENT_POSITION_END ---
var s := qd._get_script_event_index("DELAY")
_ck(s.ok and s.position == qd.EVENT_POSITION_START and s.type == qd.EVT_DELAY, "GSEI: DELAY -> START/EVT_DELAY")
var e := qd._get_script_event_index("/DELAY")
_ck(e.ok and e.position == qd.EVENT_POSITION_END and e.type == qd.EVT_DELAY, "GSEI: /DELAY -> END/EVT_DELAY")
_ck(qd._get_script_event_index("nope").ok == false, "GSEI: unknown -> ok=false")
_ck(qd._get_script_event_index("letter").type == qd.EVT_LETTER, "GSEI: case-insensitive")
# --- 3. DELAY START/ENDlWaitingTime ---
var d := qd.parse_script("[DELAY value(40)]hi")
_ck(d.waiting_time == 40, "DELAY START -> lWaitingTime=40 (%d)" % d.waiting_time)
var d2 := qd.parse_script("[DELAY value(40)]hi[/DELAY]")
_ck(d2.waiting_time == 10, "/DELAY -> lWaitingTime=c_lNormal_Waiting_Time(10) (%d)" % d2.waiting_time)
# --- 4. LETTER / 裸文本 / iRestrictedCharacterCount(30) 断行 ---
var lt := qd.parse_script("[LETTER value(\"0123456789012345678901234567890123\")]tail")
# 显式 [LETTER] 34 字 >= 30 -> __InsertLine"tail" 落到下一行。
_ck(lt.body.split("\n").size() == 2, "LETTER: >=iRestrictedCharacterCount wraps (%d lines)" % lt.body.split("\n").size())
var raw := qd.parse_script("这是一段没有任何 ENTER 的很长的网络文本用来验证裸文本不会被 30 字断行处理掉哦哦哦哦哦哦哦")
_ck(raw.body.split("\n").size() == 1, "raw text: no 30-char guillotine (%d lines)" % raw.body.split("\n").size())
# --- 5. ENTER -> lineCLEAR / CLEAR_TEXT -> reset ---
_ck(qd.parse_script("a[ENTER]b").body == "a\nb", "ENTER -> line break")
_ck(qd.parse_script("gone[CLEAR]kept").body == "kept", "CLEAR -> body reset")
_ck(qd.parse_script("gone[CLEAR_TEXT]kept").body == "kept", "CLEAR_TEXT -> body reset")
# --- 6. QUESTION -> choices + nAnswer ---
var q := qd.parse_script("Pick[QUESTION arg(\"yes\") arg(\"no\") arg(\"maybe\")]")
_ck(q.choices == ["yes", "no", "maybe"] and q.answer_count == 3, "QUESTION -> 3 choices + nAnswer=3")
# --- 7. NEXT / DONE -> has_next + iAdjustLine += 2 ---
var nx := qd.parse_script("x[NEXT]")
_ck(nx.has_next and nx.adjust_line == 2, "NEXT -> has_next + adjust_line=2 (%d)" % nx.adjust_line)
var dn := qd.parse_script("x[NEXT][DONE]")
_ck(dn.has_next and dn.adjust_line == 4, "NEXT+DONE -> adjust_line=4 (%d)" % dn.adjust_line)
# --- 8. WAIT -> isLock ---
var w := qd.parse_script("x[WAIT]y")
_ck(w.is_locked, "WAIT -> isLock true")
# --- 9. COLOR / COLOR256 START(r/g/b & 位置) + END ---
var c1 := qd.parse_script("[COLOR r(0) g(1) b(0)]G[/COLOR]")
_ck(c1.body.contains("[color=#00ff00]") and c1.body.contains("[/color]"), "COLOR r/g/b named -> span")
var c2 := qd.parse_script("[COLOR256 value;255 value;128 value;0]O")
_ck(c2.body.contains("[color=#ff8000]"), "COLOR256 positional -> /255 span")
# --- 10. INPUT / SELECT_ITEM ---
_ck(qd.parse_script("name?[INPUT]").has_input, "INPUT -> has_input")
_ck(qd.parse_script("[SELECT_ITEM]").has_select_item, "SELECT_ITEM -> has_select_item")
# --- 11. CONFIRM_WAIT + END_CONFIRM_WAIT ---
var cw := qd.parse_script("sure?[CONFIRM_WAIT timeout(30)]")
_ck(cw.confirm_wait and cw.confirm_timeout == 30, "CONFIRM_WAIT -> isConfirmWait + timeout=30")
var ce := qd.parse_script("sure?[CONFIRM_WAIT timeout(30)][END_CONFIRM_WAIT]")
_ck(ce.confirm_wait == false and ce.end_confirm, "END_CONFIRM_WAIT -> isConfirmWait cleared + CloseSelf")
# --- 12. DUNGEON_RESULT -> 9 ints ---
var dr := qd.parse_script("[DUNGEON_RESULT killstone_count(2) killmob_count(40) find_hidden(1) hidden_total(3) use_potion(5) is_revived(0) killallmob(1) total_time(600) bonus_exp(1234)]")
_ck(dr.dungeon_result.get("killmob_count", -1) == 40 and dr.dungeon_result.get("bonus_exp", -1) == 1234
and dr.dungeon_result.size() == 9, "DUNGEON_RESULT -> 9 named ints")
# --- 13. INSERT_IMAGE(有 image_name / 无 -> item icon+ iAdjustLine += 2 ---
var ii := qd.parse_script("[INSERT_IMAGE image_name(\"a.tga\") title(\"T\") desc(\"D\") index(1) total(3)]")
_ck(ii.insert_images.size() == 1 and ii.insert_images[0].image_name == "a.tga"
and ii.insert_images[0].is_item_icon == false and ii.adjust_line == 2, "INSERT_IMAGE with image_name")
var ic := qd.parse_script("[INSERT_IMAGE image_type(\"item\") idx(19) title(\"T\") desc(\"D\") index(0) total(1)]")
_ck(ic.insert_images.size() == 1 and ic.insert_images[0].is_item_icon
and ic.insert_images[0].idx == 19, "INSERT_IMAGE empty image_name -> item icon")
# --- 14. IMAGE 家族 ---
var im := qd.parse_script("[IMAGE x(10) y(20) src(\"pic.tga\")][LEFTIMAGE src(\"L.tga\")][TOPIMAGE src(\"T.tga\")][BGIMAGE src(\"B.tga\")][TITLE_IMAGE src(\"TT.tga\")]")
_ck(im.images.size() == 4, "IMAGE/LEFT/TOP/BG -> 4 image entries (%d)" % im.images.size())
_ck(im.images[0].get("path", "") == "pic.tga" and im.images[0].get("x", 0) == 10, "IMAGE x/y/src")
_ck(im.images[1].get("panel", "") == "LEFTIMAGE" and im.images[2].get("panel", "") == "TOPIMAGE", "LEFT/TOP panels")
_ck(im.title_image == "TT.tga", "TITLE_IMAGE -> title_image")
# --- 15. map signals ---
var ms := qd.parse_script("[ADDMAPSIGNAL x(1200) y(3400)][SETCMAPPOS x(8) y(-4)][CLEARMAPSIGNAL]")
_ck(ms.map_signals == [Vector2(1200, 3400)] and ms.atlas_center == [8, -4] and ms.clear_map_signals,
"ADDMAPSIGNAL / SETCMAPPOS / CLEARMAPSIGNAL")
# --- 16. QUESTBUTTON / QUESTBUTTON_CLOSE ---
var qb := qd.parse_script("[QUESTBUTTON idx(12) name(\"Read me\") icon_type(\"file\") icon_name(\"q.sub\")][QUESTBUTTON_CLOSE idx(8)]")
_ck(qb.quest_buttons.size() == 1 and qb.quest_buttons[0].index == 12
and qb.quest_buttons[0].title == "Read me" and qb.quest_buttons[0].icon_name == "q.sub", "QUESTBUTTON keyed")
_ck(qb.quest_button_closes == [8], "QUESTBUTTON_CLOSE idx -> close list")
# --- 17. camera SET/BLEND/RESTORE ---
var cam := qd.parse_script("[SET_CAMERA x(1) y(2) z(3) distance(50)][BLEND_CAMERA x(4) blendtime(1500)][RESTORE_CAMERA]")
_ck(cam.camera_events.size() == 3, "camera: 3 events")
_ck(cam.camera_events[0].get("setting", {}).get("distance", 0) == 50, "SET_CAMERA setting.distance")
_ck(cam.camera_events[1].get("blendtime", 0) == 1500 and cam.camera_events[2].kind == "RESTORE_CAMERA", "BLEND blendtime + RESTORE")
# --- 18. FADE_* / WHITE_* -> fade_events + isWaitFlag ---
var fx := qd.parse_script("[FADE_OUT speed(0.25)]")
_ck(fx.fade_events.size() == 1 and fx.fade_events[0].kind == "FADE_OUT"
and is_equal_approx(fx.fade_events[0].speed, 0.25) and fx.wait_flag, "FADE_OUT -> event + wait_flag")
var wx := qd.parse_script("[WHITE_IN speed(1)]")
_ck(wx.fade_events[0].kind == "WHITE_IN" and wx.wait_flag, "WHITE_IN -> event + wait_flag")
# --- 19. WINDOW_SIZE / TEXT_HORIZONTAL_ALIGN_CENTER ---
var ws := qd.parse_script("[WINDOW_SIZE width(640) height(360)][TEXT_HORIZONTAL_ALIGN_CENTER]")
_ck(ws.window_size == [640, 360] and ws.text_centered, "WINDOW_SIZE keyed + TEXT_CENTER")
# --- 20. ITEM / MOB 占位(无 proto/entity 时回退命名)---
var nm := qd.parse_script("kill [MOB value(77)] get [ITEM value(19)]")
_ck(nm.body.contains("怪物 #77") and nm.body.contains("物品 #19"), "ITEM/MOB -> named placeholders")
# --- 21. SETMSGPOS / ADJMSGPOS 故意留空(PythonEventManager.cpp:562/566---
var mp := qd.parse_script("body[SETMSGPOS x(1) y(2)][ADJMSGPOS x(3) y(4)]end")
_ck(mp.body == "bodyend", "SETMSGPOS/ADJMSGPOS -> no-op, no body change")
# --- 22. 未知 tag 优雅跳过 ---
var uk := qd.parse_script("keep[TOTALLY_MADE_UP arg(1)]this")
_ck(uk.body == "keepthis", "unknown tag -> skipped, text preserved")
# --- 23. RUN_CINEMA 首命令特例 -> cinema_file seam ---
var rc := qd.parse_script("[RUN_CINEMA value(\"intro.msc\")]")
_ck(rc.cinema_file == "intro.msc", "RUN_CINEMA (first cmd) -> cinema_file")
var rc2 := qd.parse_script("hi[RUN_CINEMA value(\"x.msc\")]")
_ck(rc2.cinema_file == "" and rc2.body == "hi", "RUN_CINEMA (non-first) -> silent no-op")
qd.free()

Some files were not shown because too many files have changed in this diff Show More