完善客户端功能并同步差距文档
This commit is contained in:
+31
-9
@@ -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-hoc;CODESIGN_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,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
File diff suppressed because it is too large
Load Diff
+1482
-127
File diff suppressed because one or more lines are too long
@@ -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 ---
|
||||
|
||||
@@ -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 是独立六面 quad;Godot 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;
|
||||
}
|
||||
|
||||
@@ -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 cover;texture-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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: Metin2↔Godot 坐标 / 单位 / 区块编号(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)
|
||||
|
||||
@@ -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
|
||||
@@ -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 / RegisterComboAttack,GameLib/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::EMode(chr.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_* 段号,
|
||||
// 下标即 dwComboArrayIndex(m_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] = motion(key 未 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
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 / AttackingType(SAttackData::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; // HitLimitCount(0 = 不限)
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -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 的重复 Reserve:map::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 / 20(COMBO_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;
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# app_flow_lifecycle_test —— §9.3 生命周期单一属主回归。
|
||||
# AppFlow 跨登录 / 选人 / 游戏持有唯一 AppLifecycle;GameScene 不重复创建。
|
||||
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
@@ -1,9 +1,19 @@
|
||||
# Audio —— BGM 交叉淡入淡出 + UI 音效池 + 3D 定位音效。
|
||||
# 对标客户端 AudioLib:BGM(`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_fBackupSoundVolume(REF/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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# chat_tail —— §8.8 头顶「聊天 / 信息」尾标 1:1:CPythonTextTail::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_ChatTailMap,LivingTime < now 即 DeleteTextTail + erase
|
||||
# (每 vid 只有一条 chat/info 尾标,chat 与 info 共用同一张 m_ChatTailMap)
|
||||
# :74 UpdateAllTextTail:chat 尾标若 bNameFlag → ShowCharacterTextTail(vid)(名字也一起顶出来)
|
||||
# :105 UpdateShowingTextTail:chat 尾标若 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/WHISPER:line = strchr(buf,':') ? p + 2 : buf(去掉 "名字: " 前缀)
|
||||
# :1363 INFO/NOTICE/BIG_NOTICE/default:line = 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 # ms(gs_TextTail_LivingTime)
|
||||
const LIVING_TIME_MIN := 1000 # assert 下界(TextTail_GetLivingTime)
|
||||
const HEIGHT_ADD := 10.0 # 角色高 + 10(RegisterChatTail 新建分支)
|
||||
|
||||
# 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_GetLivingTime:assert(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 的 switch:TALKING/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:
|
||||
# RecvChatPacket:COMMAND 提前 return;SHOUT 显式排除;其余带 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:
|
||||
# UpdateAllTextTail:LivingTime < 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
|
||||
@@ -0,0 +1,106 @@
|
||||
# chat_tail_test —— §8.8 头顶「聊天 / 信息」尾标 1:1:
|
||||
# CPythonTextTail::RegisterChatTail / RegisterInfoTail 的白 / 淡红色、bNameFlag、
|
||||
# gs_TextTail_LivingTime(5000,assert >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_prefix:strchr(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_tail:switch 里 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_tail:RecvChatPacket 除 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. expire:UpdateAllTextTail 的 LivingTime < now 删除 -----------------
|
||||
# vid 42 LivingTime = t0+2000+5000 = t0+7000;vid 7 = t0+5000
|
||||
var dead := ChatTail.expire(tails, t0 + 6000)
|
||||
_ck(dead == [7], "expire(now=t0+6000) 清掉 vid 7(LivingTime 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
@@ -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
@@ -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.6:CG_FLY_TARGETING
|
||||
var shots := [] # §3.6:CG_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")
|
||||
|
||||
# ComboProcess:elapsed 过了 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)")
|
||||
|
||||
# 动作回到 Wait(elapsed > 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 m(yaw 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_mode(RefreshState 的 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-XY(OnGetFlyTargetPosition 平面分量)")
|
||||
_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}]
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,86 @@
|
||||
# DungeonState —— GC_DUNGEON 目的地状态(40250/ClientVS22)。
|
||||
#
|
||||
# 参考端 PythonNetworkStreamPhaseGame.cpp:3875-3901 收到目的地后调用
|
||||
# CPythonPlayer::SetDungeonDestinationPosition;PythonPlayer.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()
|
||||
@@ -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()
|
||||
@@ -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 IsWoodenDoor(vnum 13000 / 30111..30119)
|
||||
#
|
||||
# 纯静态谓词。调用方(net_play.gd)负责把网络快照拼成 self_e / victim_e / ctx;
|
||||
# 部分依赖在本 40250 客户端的发布源码里没有 setter(同 §3.4/§3.6/§3.8 的处理),
|
||||
# 在下方逐条标注为 seam。
|
||||
extends RefCounted
|
||||
|
||||
# EPKModes(REF/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 type(REF/UserInterface/InstanceBase.h:249)
|
||||
const ALIGNMENT_TYPE_WHITE := 0
|
||||
const ALIGNMENT_TYPE_NORMAL := 1
|
||||
const ALIGNMENT_TYPE_DARK := 2
|
||||
|
||||
# duel mode(REF/UserInterface/InstanceBase.h:378,GetDuelMode 返回 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 / party(InstanceBase.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::GetAlignmentGrade(InstanceBase.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::GetAlignmentType(InstanceBase.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::IsWoodenDoor(InstanceBase.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::IsSameEmpire(InstanceBase.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::__FindDUELKey(InstanceBaseEffect.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::IsPVPInstance(InstanceBaseEffect.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::IsConflictAlignmentInstance(InstanceBase.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::IsAttackableInstance(InstanceBase.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
|
||||
@@ -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 异帝国 PC(else 分支)
|
||||
_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_FREE:victim 非 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_FREE:victim 是 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 victim(state_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 alignment(self 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_instance:victim 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")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
# --- EItemAntiFlag(ItemData.h,classic 排列:帝国位在 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
|
||||
|
||||
# --- EWearPositions(0..10)+ 新装备系统槽(GameType.h,19..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
|
||||
|
||||
# --- 职业 / 性别 <- race(common 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/3(GC_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))
|
||||
@@ -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")
|
||||
@@ -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 连射按序 pop(AutoClear)")
|
||||
gs._on_local_motion_event(6, "", "", Vector3.ZERO)
|
||||
_ck(fake_client.shots == [77, 78], "motion_event: FLY 队列空 → 不再发")
|
||||
|
||||
gs.queue_free()
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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)/ CreateIndexedFly(NORMAL /
|
||||
# FIRE_CRACKER / AUTO_FIRE 三种服务端索引飞行)
|
||||
# REF/GameLib/FlyingInstance.cpp CFlyingInstance::Create → __SetDataPointer(m_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_v3Position;m_v3Velocity+=m_v3Accel*dt;m_v3Velocity.z+=m_fGravity*dt;
|
||||
# v3Movement=m_v3Velocity*dt;_fMoveDistance=|v3Movement|;m_fRemainRange-=_fMoveDistance;
|
||||
# m_v3Position+=v3Movement;m_fRemainRange<0 → OnExplodingOutOfRange + __Explode(false);
|
||||
# 对象目标且未命中 && square_distance_between_linesegment_and_point(pos,last,targetPos) <
|
||||
# m_fBombRange² → m_bTargetHitted=TRUE;m_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 return;m_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 IFlyEventHandler:OnExplodingOutOfRange / OnExplodingAtBackground /
|
||||
# OnExplodingAtAnotherTarget(skill,vid) / OnExplodingAtTarget(skill)
|
||||
extends Node
|
||||
|
||||
# CFlyingManager::EIndexFlyType(REF/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=200(cm/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=500(cm)→ 5 m
|
||||
var bomb_range := 0.1 # m_fBombRange=10(cm)→ 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)
|
||||
@@ -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(本 POC:world.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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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 地图 BGM:M2Client.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_world(CPythonSystem::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:
|
||||
# 参考端 __PressNumKey(game.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:
|
||||
# ESC:ui_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 -> MobView(race = 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"
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
@@ -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 格 wear(BODY..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 窗全局 cell(GameType.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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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.__RegisterColor(RGB 注册)
|
||||
# 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
|
||||
# 同款 ctx:main_vid / duel_mode / duel_opponents / pvp_pairs / gvg_pairs / party_vids)。
|
||||
extends RefCounted
|
||||
|
||||
const EntityRules = preload("res://entity_rules.gd")
|
||||
|
||||
# InstanceBase.h 的匿名枚举,EMPIRE_NUM = 4(EMPIRE_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::SetEmpireNameMode(InstanceBaseEffect.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::GetIndexedNameColor(InstanceBaseEffect.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_poly;NPC 这里补) ---------
|
||||
|
||||
static func _is_npc(e: Dictionary) -> bool:
|
||||
return EntityRules._kind(e) == EntityRules.KIND_NPC
|
||||
|
||||
static func _empire_id(e: Dictionary) -> int:
|
||||
# GetEmpireID() → m_dwEmpireID(0 = 无,1..3)。防御性 clamp 到合法槽。
|
||||
return clampi(int(e.get("empire", 0)), 0, EMPIRE_NUM - 1)
|
||||
|
||||
# --- CInstanceBase::GetNameColorIndex(InstanceBaseEffect.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))
|
||||
# __FindGVGKey:guild 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::GetNameColor(InstanceBaseEffect.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))
|
||||
@@ -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.h,EMPIRE_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)")
|
||||
@@ -0,0 +1,77 @@
|
||||
# name_show —— §8.4 / §8.6 头顶名字「是否显示」1:1:CPythonSystem::IsAlwaysShowName /
|
||||
# SetAlwaysShowNameFlag + game.py::__IsShowName + CPythonTextTail::ShowAllTextTail 的
|
||||
# 3500 距离裁剪 + ShowCharacterTextTail 的资格判定 + UpdateAllTextTail 的 bNameFlag 强显。
|
||||
# (与 name_color.gd / text_tail.gd / chat_tail.gd 同一套纯静态镜像套路。)
|
||||
#
|
||||
# REF(40250 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 OnRender:chr.Pick() 命中 -> textTail.ShowCharacterTextTail(idx)
|
||||
# root/game.py:1535 OnRender:targetBoard.GetTargetVID() != 0 -> textTail.ShowCharacterTextTail(vid)
|
||||
# root/game.py:1550 OnRender:if __IsShowName(): textTail.ShowAllTextTail()
|
||||
# UserInterface/PythonTextTail.cpp:416 UpdateDistance:fDistanceFromPlayer = |(ownerX-centerX, -ownerY-centerY)|
|
||||
# UserInterface/PythonTextTail.cpp:422 ShowAllTextTail:m_CharacterTextTailMap 里 fDistanceFromPlayer < 3500.0f 才 ShowCharacterTextTail
|
||||
# UserInterface/PythonTextTail.cpp:440 ShowCharacterTextTail:不在图 / 已入列 / !pOwner->isShow() / 无实例 / IsGuildWall() -> return;CanPickInstance() 才入列
|
||||
# UserInterface/PythonTextTail.cpp:74 UpdateAllTextTail:chat 尾标 bNameFlag -> ShowCharacterTextTail(不受 3500 限制)
|
||||
#
|
||||
# seam ⑩:参考端「名字显示」是每帧重建 m_CharacterTextTailList(Pick / 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 < 3500(px==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 ShowNameFlag(LALT 按住)
|
||||
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)
|
||||
@@ -0,0 +1,79 @@
|
||||
# name_show_test —— §8.4 / §8.6 头顶名字「是否显示」1:1:
|
||||
# CPythonSystem::IsAlwaysShowName / SetAlwaysShowNameFlag(flag / 配置行 == 1)、
|
||||
# game.py::__IsShowName(IsAlwaysShowName or ShowNameFlag)、
|
||||
# CPythonTextTail::ShowAllTextTail 的 fDistanceFromPlayer < 3500、
|
||||
# ShowCharacterTextTail 的 isShow / !IsGuildWall / CanPickInstance 资格判定,
|
||||
# 以及 forced(Pick / 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. ShowAllTextTail:fDistanceFromPlayer < 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 也不显示")
|
||||
# forced(Pick / 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
File diff suppressed because it is too large
Load Diff
+405
-59
@@ -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) -> int(0 PC/1 NPC/2 怪/3 石/4 warp;net_play._entity_kind)
|
||||
var title_name_resolver: Callable = Callable() # §8.8 func(grade:int) -> String(PVP_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「总显示名字」radio(CPythonSystem::IsAlwaysShowName)
|
||||
# _alt_held —— LALT 按住(game.py ShowName/HideName 的 ShowNameFlag)
|
||||
# 另加 forced 路径:_target_vid(targetBoard.GetTargetVID())+ chat 尾标 bNameFlag。
|
||||
var _always_show_name := NameShow.ALWAYS_SHOW_NAME_DEFAULT
|
||||
var _alt_held := false
|
||||
var _target_vid := 0
|
||||
var _fly_mgr: Node # FlyManager(fly_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 侧组装:forced(target / 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::EnablePKTitle(game.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)
|
||||
# UpdateAllTextTail:chat 尾标 bNameFlag TRUE 时强制显示该角色名字(不受 3500 限制)。
|
||||
_set_name_visibility(n)
|
||||
|
||||
func _expire_chat_tails() -> void:
|
||||
# UpdateAllTextTail:LivingTime < 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_TARGETING(target VID=0 时是坐标),所以
|
||||
# 这里也消费 shooter 的目标队列,避免远端技能没有终点而被静默丢弃。
|
||||
# P6:飞行道具(GC_CREATE_FLY)—— 从 start 实体飞向 end 实体 / 坐标。原客户端在
|
||||
# GC_CREATE_FLY 之前可能只收到 FLY_TARGETING(target 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.6:ShowAllTextTail 的 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:
|
||||
|
||||
@@ -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
@@ -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.3:attack_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.3:SHORSE::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.3:PythonPlayerInputKeyboard::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_SKILL:range>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")
|
||||
|
||||
# __ChangeTargetToPickedInstance:NPC(非战斗目标)-> 清 _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
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:变身强制 0(SetShape(0)),否则原样(动物时装 100-103 保留,用来出时装体)。
|
||||
static func body_shape(shape: int, is_poly: bool) -> int:
|
||||
return POLY_SHAPE if is_poly else shape
|
||||
|
||||
# 有效武器 vnum:被隐藏则 0(SetWeapon 提前 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
|
||||
@@ -0,0 +1,76 @@
|
||||
# part_hiding_test —— §4.1 修改 4:变身 / 时装部位遮挡(PartHiding)1: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
@@ -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_fReservedDelayTime(PythonPlayerInput.cpp:834)。
|
||||
const RESERVED_GROUND_DELAY := 0.1
|
||||
# Actor-Actor 碰撞(对齐 CInstanceBase::CheckAdvancing,InstanceBaseBattle.cpp:469 +
|
||||
# CActorInstance::TestActorCollision,ActorInstanceCollisionDetection.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 300(InstanceBase.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_Goto(InstanceBaseMovement.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::TestActorCollision(ActorInstanceCollisionDetection.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
|
||||
|
||||
# SetAdvancingRotation(InstanceBaseMovement.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_MoveToDirection:m_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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
# player_move_test —— §3.1 Src/Dst 移动模型(player_controller.gd)headless 自检。
|
||||
# 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) CanSkipCollision(skip_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
|
||||
@@ -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++ 技能 proto,TSkillData 判定位全部落在 `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..3,master 即 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_ARROW;POC 用 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 修改 3:NeedSP 公式经 skill_table.need_sp()(.msk NeedSPFormula)解析。
|
||||
#
|
||||
# __CheckShortLife(PythonPlayerSkill.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
|
||||
|
||||
# __CheckShortMana(PythonPlayerSkill.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
|
||||
@@ -0,0 +1,261 @@
|
||||
# player_skill_test —— §3.8 修改 1:player_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 Cut:ATTACK_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 Stump:HORSE_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 修改 3:NeedSP / __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")
|
||||
# __CheckShortMana:SP 不足 -> 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)
|
||||
# __CheckShortLife:skill 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 Cure:CAN_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 分类正确")
|
||||
@@ -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 개설")
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
# quest_event_test —— §5.1 Quest EventManager 1:1(CPythonEventManager):
|
||||
# EVENT_TYPE_MAP(42 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-1207,42 条)---
|
||||
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/END:lWaitingTime ---
|
||||
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 -> line;CLEAR / 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
Reference in New Issue
Block a user