Adapt Android mobile controls and native client UI
This commit is contained in:
@@ -1,242 +0,0 @@
|
||||
# mtgodot GDExtension — registers Metin2Model / Metin2AnimPlayer, links libgr2.
|
||||
# libgr2 is added at the top-level CMakeLists (vendored under ../libgr2), so here
|
||||
# we just consume the xrender::libgr2 target.
|
||||
|
||||
# --- godot-cpp (submodule pinned at 101ae38034304346a46ea9ea84ae156d3e860496) ---
|
||||
# The parent gitlink is the source-of-truth; .gitmodules intentionally has no
|
||||
# moving branch. The selected bundle targets Godot 4.7's extension API.
|
||||
set(GODOTCPP_API_VERSION "4.7" CACHE STRING "Target Godot API version" FORCE)
|
||||
add_subdirectory(godot-cpp)
|
||||
|
||||
# --- vendored native deps: libsodium / libzstd / miniLZO ---
|
||||
# Built from source (pinned submodules + vendored miniLZO) so the same tree
|
||||
# compiles on the Android NDK and iOS SDK, which have no Homebrew. Exposes the
|
||||
# aliases mt3p::sodium / mt3p::zstd / mt3p::minilzo. See docs/THIRD-PARTY.md.
|
||||
add_subdirectory(third_party)
|
||||
|
||||
add_subdirectory(src/codepage)
|
||||
|
||||
# --- mtnet: Metin2 net protocol core (no godot-cpp dep; libsodium only) ---
|
||||
add_library(mtnet STATIC
|
||||
src/net/text_codec.cpp
|
||||
src/net/secure_cipher.cpp
|
||||
src/net/net_stream.cpp
|
||||
src/net/entity_store.cpp
|
||||
src/net/mark_image.cpp
|
||||
src/net/classic/classic_stream.cpp # MT_PROTOCOL=classic (40250)
|
||||
src/net/classic/classic_parser.cpp
|
||||
src/net/classic/classic_session.cpp
|
||||
src/net/classic/classic_cipher.cpp
|
||||
)
|
||||
target_include_directories(mtnet PUBLIC src/net)
|
||||
target_link_libraries(mtnet PUBLIC mt3p::sodium mt3p::minilzo mt3p::cryptopp mt_codepage)
|
||||
target_compile_features(mtnet PUBLIC cxx_std_20)
|
||||
# §1.5: non-EUROPE CG_CLIENT_VERSION timestamp, in C __TIMESTAMP__ form
|
||||
# ("Www Mmm dd hh:mm:ss yyyy"); regenerated on each CMake configure.
|
||||
string(TIMESTAMP MT_BUILD_TS "%a %b %d %H:%M:%S %Y")
|
||||
target_compile_definitions(mtnet PRIVATE MT_BUILD_TIMESTAMP="${MT_BUILD_TS}")
|
||||
|
||||
# --- mtpack: m2dev-fork asset pack reader/writer (libsodium + zstd) ---
|
||||
add_library(mtpack STATIC
|
||||
src/pack/eterpack.cpp
|
||||
src/pack/pack_mount.cpp
|
||||
src/pack/asset_source.cpp
|
||||
)
|
||||
target_include_directories(mtpack PUBLIC src/pack)
|
||||
target_link_libraries(mtpack PUBLIC mt3p::sodium mt3p::zstd xrender::formats)
|
||||
target_compile_features(mtpack PUBLIC cxx_std_20)
|
||||
|
||||
# --- mtproto: item_proto / mob_proto reader, 40250 format (CLZO/TEA from port_platform) ---
|
||||
add_library(mtproto STATIC src/proto/proto.cpp)
|
||||
target_include_directories(mtproto PUBLIC src/proto)
|
||||
target_link_libraries(mtproto PUBLIC port_platform)
|
||||
target_compile_features(mtproto PUBLIC cxx_std_20)
|
||||
|
||||
# --- port_logic + port_platform: 40250 mirror (extension/src/port) and its platform layer
|
||||
# (extension/src/platform), docs/PORT-PLAN.md ---
|
||||
add_subdirectory(src/port)
|
||||
|
||||
# Host-only CLIs + tests: they run on the build machine, so skip them entirely
|
||||
# when cross-compiling the extension for a device.
|
||||
set(MT_HOST_BUILD FALSE)
|
||||
if(CMAKE_SYSTEM_NAME STREQUAL CMAKE_HOST_SYSTEM_NAME)
|
||||
set(MT_HOST_BUILD TRUE)
|
||||
endif()
|
||||
|
||||
if(MT_HOST_BUILD)
|
||||
add_executable(packtool tools/packtool.cpp)
|
||||
target_link_libraries(packtool PRIVATE mtpack)
|
||||
|
||||
# net_probe: connect to an auth server, run the handshake + CG_LOGIN3, report.
|
||||
add_executable(net_probe tools/net_probe.cpp)
|
||||
target_link_libraries(net_probe PRIVATE mtnet)
|
||||
|
||||
# net_e2e: full auth -> game -> char list -> select -> PHASE_GAME against a
|
||||
# real server; dumps entities / points / inventory. Live integration check.
|
||||
add_executable(net_e2e tools/net_e2e.cpp)
|
||||
target_link_libraries(net_e2e PRIVATE mtnet)
|
||||
|
||||
# net_classic_e2e: live 40250 classic handshake -> login -> char select -> PHASE_GAME probe.
|
||||
add_executable(net_classic_e2e tools/net_classic_e2e.cpp)
|
||||
target_link_libraries(net_classic_e2e PRIVATE mtnet)
|
||||
endif()
|
||||
|
||||
if(BUILD_TESTING AND MT_HOST_BUILD)
|
||||
add_executable(dxt_decode_test tests/dxt_decode_test.cpp src/dxt.cpp)
|
||||
target_include_directories(dxt_decode_test PRIVATE src)
|
||||
add_test(NAME dxt.decode COMMAND $<TARGET_FILE:dxt_decode_test>)
|
||||
|
||||
add_executable(net_cipher_test tests/net_cipher_test.cpp)
|
||||
target_link_libraries(net_cipher_test PRIVATE mtnet)
|
||||
add_test(NAME net.cipher_roundtrip COMMAND $<TARGET_FILE:net_cipher_test>)
|
||||
|
||||
add_executable(net_loopback_test tests/net_loopback_test.cpp)
|
||||
target_link_libraries(net_loopback_test PRIVATE mtnet)
|
||||
add_test(NAME net.loopback_flow COMMAND $<TARGET_FILE:net_loopback_test>)
|
||||
|
||||
add_executable(net_entity_test tests/net_entity_test.cpp)
|
||||
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)
|
||||
target_compile_features(net_classic_wire_test PRIVATE cxx_std_20)
|
||||
add_test(NAME net.classic_wire COMMAND $<TARGET_FILE:net_classic_wire_test>)
|
||||
|
||||
add_executable(net_classic_stream_test tests/net_classic_stream_test.cpp)
|
||||
target_link_libraries(net_classic_stream_test PRIVATE mtnet)
|
||||
add_test(NAME net.classic_stream COMMAND $<TARGET_FILE:net_classic_stream_test>)
|
||||
|
||||
add_executable(net_classic_session_test tests/net_classic_session_test.cpp)
|
||||
target_link_libraries(net_classic_session_test PRIVATE mtnet)
|
||||
add_test(NAME net.classic_session COMMAND $<TARGET_FILE:net_classic_session_test>)
|
||||
|
||||
add_executable(net_classic_cipher_test tests/net_classic_cipher_test.cpp)
|
||||
target_link_libraries(net_classic_cipher_test PRIVATE mtnet)
|
||||
add_test(NAME net.classic_cipher COMMAND $<TARGET_FILE:net_classic_cipher_test>)
|
||||
|
||||
add_executable(net_classic_encstream_test tests/net_classic_encstream_test.cpp)
|
||||
target_link_libraries(net_classic_encstream_test PRIVATE mtnet)
|
||||
add_test(NAME net.classic_encstream COMMAND $<TARGET_FILE:net_classic_encstream_test>)
|
||||
|
||||
add_executable(net_classic_mark_test tests/net_classic_mark_test.cpp)
|
||||
target_link_libraries(net_classic_mark_test PRIVATE mtnet)
|
||||
add_test(NAME net.classic_mark COMMAND $<TARGET_FILE:net_classic_mark_test>)
|
||||
|
||||
add_executable(net_text_codec_test tests/net_text_codec_test.cpp)
|
||||
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>)
|
||||
|
||||
add_executable(pack_roundtrip_test tests/pack_roundtrip_test.cpp)
|
||||
target_link_libraries(pack_roundtrip_test PRIVATE mtpack)
|
||||
add_test(NAME pack.roundtrip COMMAND $<TARGET_FILE:pack_roundtrip_test>)
|
||||
|
||||
add_executable(proto_test tests/proto_test.cpp)
|
||||
target_link_libraries(proto_test PRIVATE mtproto)
|
||||
# MT_40250_CLIENT comes from src/port/CMakeLists.txt; skipped (77) without the client, failed under
|
||||
# MT_ASSETS_STRICT=1.
|
||||
add_test(NAME proto.item_mob COMMAND $<TARGET_FILE:proto_test> ${MT_40250_CLIENT})
|
||||
set_tests_properties(proto.item_mob PROPERTIES SKIP_RETURN_CODE 77)
|
||||
|
||||
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>)
|
||||
|
||||
# The embedded interpreter, when third_party built it (-DMTGODOT_EMBED_PYTHON=ON).
|
||||
if(TARGET mtpython)
|
||||
add_executable(py_embed_test tests/py_embed_test.cpp)
|
||||
target_link_libraries(py_embed_test PRIVATE mtpython)
|
||||
add_dependencies(py_embed_test mtpython_stdlib)
|
||||
# The stdlib exactly as the host ships it: python27.zip, read through zipimport (批次 2P step 3).
|
||||
add_test(NAME python.embed COMMAND $<TARGET_FILE:py_embed_test> "${MT_PYTHON_STDLIB_ZIP}")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# --- the extension library ---
|
||||
# macOS/Android: SHARED (Godot dlopen()s it). iOS: STATIC — the platform forbids
|
||||
# loading dynamic libraries, so the archive is linked into the app at export time
|
||||
# (see docs/PLATFORMS.md for the remaining F1 export/sign steps).
|
||||
if(CMAKE_SYSTEM_NAME STREQUAL "iOS")
|
||||
set(MT_LIB_KIND STATIC)
|
||||
set(MT_PLAT_TAG "ios")
|
||||
elseif(CMAKE_SYSTEM_NAME STREQUAL "Android")
|
||||
set(MT_LIB_KIND SHARED)
|
||||
set(MT_PLAT_TAG "android")
|
||||
else()
|
||||
set(MT_LIB_KIND SHARED)
|
||||
set(MT_PLAT_TAG "macos")
|
||||
endif()
|
||||
|
||||
add_library(mtgodot ${MT_LIB_KIND}
|
||||
src/register_types.cpp
|
||||
src/metin2_model.cpp
|
||||
src/metin2_anim.cpp
|
||||
src/metin2_world.cpp
|
||||
src/terrain_splat.cpp
|
||||
src/static_object.cpp
|
||||
src/tree_placeholder.cpp
|
||||
src/environment_builder.cpp
|
||||
src/water_builder.cpp
|
||||
src/gr2_bridge.cpp
|
||||
src/m2_material.cpp
|
||||
src/dxt.cpp
|
||||
src/asset_io.cpp
|
||||
src/texture_util.cpp
|
||||
src/net/m2_client.cpp
|
||||
src/proto/proto_node.cpp
|
||||
src/pack40250_node.cpp
|
||||
src/python_stdlib.cpp
|
||||
src/python_host_node.cpp
|
||||
)
|
||||
target_compile_features(mtgodot PRIVATE cxx_std_20)
|
||||
target_link_libraries(mtgodot PRIVATE godot::cpp xrender::libgr2 xrender::formats mtnet mtproto port_platform)
|
||||
|
||||
# The embedded interpreter's standard library (批次 2P step 3c). python27.zip and its digest are
|
||||
# copied next to project.godot so the exporter packs them into the APK/IPA (the export presets'
|
||||
# include_filter lists them); src/python_stdlib.cpp stages them out of the PCK at runtime.
|
||||
if(TARGET mtpython_stdlib)
|
||||
target_compile_definitions(mtgodot PRIVATE MTGODOT_HAVE_PYTHON)
|
||||
# A target rather than a custom command on the two files: the command that produces them lives in
|
||||
# third_party/, and a Makefile generator only resolves a generated-file dependency inside the
|
||||
# directory that declared it. copy_if_different keeps the no-op build a compare.
|
||||
add_custom_target(mtpython_stdlib_project ALL
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||
"${MT_PYTHON_STDLIB_ZIP}" "${CMAKE_SOURCE_DIR}/project/python27.zip"
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||
"${MT_PYTHON_STDLIB_SHA}" "${CMAKE_SOURCE_DIR}/project/python27.zip.sha256"
|
||||
COMMENT "Copying python27.zip into project/ (res://)")
|
||||
add_dependencies(mtpython_stdlib_project mtpython_stdlib)
|
||||
add_dependencies(mtgodot mtpython_stdlib_project)
|
||||
endif()
|
||||
|
||||
# Godot's .gdextension expects, per platform:
|
||||
# project/bin/libmtgodot.macos.template_{debug,release}.dylib
|
||||
# project/bin/libmtgodot.ios.template_{debug,release}.a
|
||||
# project/bin/libmtgodot.android.template_{debug,release}.<arch>.so
|
||||
set(MT_CFG_TAG "$<IF:$<CONFIG:Release>,template_release,template_debug>")
|
||||
if(CMAKE_SYSTEM_NAME STREQUAL "Android")
|
||||
# godot_arch_name-style suffix; OnePlus 13 = arm64.
|
||||
set(MT_ARCH_SUFFIX ".${CMAKE_ANDROID_ARCH_ABI}")
|
||||
string(REPLACE "arm64-v8a" "arm64" MT_ARCH_SUFFIX "${MT_ARCH_SUFFIX}")
|
||||
else()
|
||||
set(MT_ARCH_SUFFIX "")
|
||||
endif()
|
||||
set_target_properties(mtgodot PROPERTIES
|
||||
PREFIX "lib"
|
||||
OUTPUT_NAME "mtgodot.${MT_PLAT_TAG}.${MT_CFG_TAG}${MT_ARCH_SUFFIX}"
|
||||
ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/project/bin"
|
||||
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/project/bin"
|
||||
)
|
||||
Submodule extension/godot-cpp deleted from 101ae38034
@@ -1,72 +0,0 @@
|
||||
#include "asset_io.h"
|
||||
|
||||
#include <godot_cpp/classes/file_access.hpp>
|
||||
|
||||
#include "platform/PackBackend.h"
|
||||
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace godot;
|
||||
|
||||
namespace mtgodot {
|
||||
|
||||
namespace {
|
||||
bool pack_name(const String &path, std::string &name) {
|
||||
if (!path.begins_with(PACK_SCHEME)) {
|
||||
return false;
|
||||
}
|
||||
name = path.substr(String(PACK_SCHEME).length()).utf8().get_data();
|
||||
return true;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
PackedByteArray read_file(const String &path) {
|
||||
if (path.is_empty()) {
|
||||
return PackedByteArray();
|
||||
}
|
||||
std::string name;
|
||||
if (pack_name(path, name)) {
|
||||
std::vector<uint8_t> bytes;
|
||||
PackedByteArray out;
|
||||
if (mtpack40250::read(name, bytes)) {
|
||||
out.resize((int64_t)bytes.size());
|
||||
if (!bytes.empty()) {
|
||||
std::memcpy(out.ptrw(), bytes.data(), bytes.size());
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// FileAccess::get_file_as_bytes handles res:// / user:// / absolute OS paths.
|
||||
return FileAccess::get_file_as_bytes(path);
|
||||
}
|
||||
|
||||
bool file_exists(const String &path) {
|
||||
std::string name;
|
||||
if (pack_name(path, name)) {
|
||||
return mtpack40250::exists(name);
|
||||
}
|
||||
return !path.is_empty() && FileAccess::file_exists(path);
|
||||
}
|
||||
|
||||
Image dds_from_file(const String &path) {
|
||||
PackedByteArray b = read_file(path);
|
||||
if (b.is_empty()) {
|
||||
return Image{};
|
||||
}
|
||||
return load_dds(b.ptr(), (size_t)b.size());
|
||||
}
|
||||
|
||||
std::optional<gr2::File> gr2_from_file(const String &path, gr2::LoadError *err) {
|
||||
PackedByteArray b = read_file(path);
|
||||
if (b.is_empty()) {
|
||||
if (err) {
|
||||
*err = {"open", std::string("cannot read ") + path.utf8().get_data()};
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
return gr2::File::load(b.ptr(), (size_t)b.size(), err);
|
||||
}
|
||||
|
||||
} // namespace mtgodot
|
||||
@@ -1,38 +0,0 @@
|
||||
// asset_io — the one portable asset-read point.
|
||||
//
|
||||
// The standalone libs (libgr2, formats, mtproto, mtpack) keep their raw
|
||||
// fopen/ifstream `*_path()` entry points for the non-Godot CTests. Every read
|
||||
// from the *running extension* goes through here instead, so it works for:
|
||||
// - res:// (loose files in dev, PCK on iOS/Android read-only bundles)
|
||||
// - user:// (extracted cache)
|
||||
// - absolute OS paths (dev: AssetRoot points at mtgodot-poc/assets/)
|
||||
// godot::FileAccess handles all three transparently.
|
||||
// - pack://<40250 path> (e.g. pack://locale/en/item_proto, pack://d:/ymir work/pc/...): the 40250 packs,
|
||||
// read through the ported CEterPackManager once Metin2Pack.initialize(<Client dir>) has run
|
||||
// (platform/PackBackend.h). The path after pack:// is passed as UTF-8.
|
||||
#pragma once
|
||||
|
||||
#include <godot_cpp/variant/packed_byte_array.hpp>
|
||||
#include <godot_cpp/variant/string.hpp>
|
||||
|
||||
#include <gr2/gr2.h>
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include "dxt.h"
|
||||
|
||||
namespace mtgodot {
|
||||
|
||||
inline constexpr const char *PACK_SCHEME = "pack://";
|
||||
|
||||
// Whole-file read. Empty PackedByteArray on failure (path missing / unreadable).
|
||||
godot::PackedByteArray read_file(const godot::String &path);
|
||||
bool file_exists(const godot::String &path); // FileAccess::file_exists wrapper
|
||||
|
||||
// .dds -> RGBA8 level 0 via read_file. !ok() on failure.
|
||||
Image dds_from_file(const godot::String &path);
|
||||
|
||||
// .gr2 parse via read_file. nullopt on read or parse failure.
|
||||
std::optional<gr2::File> gr2_from_file(const godot::String &path, gr2::LoadError *err = nullptr);
|
||||
|
||||
} // namespace mtgodot
|
||||
@@ -4,7 +4,7 @@
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
namespace mtgodot {
|
||||
namespace mtimage {
|
||||
namespace {
|
||||
|
||||
inline uint32_t rd_le32(const uint8_t* p) {
|
||||
@@ -209,4 +209,4 @@ Image load_dds_path(const char* path) {
|
||||
return decode(buf.data(), buf.size());
|
||||
}
|
||||
|
||||
} // namespace mtgodot
|
||||
} // namespace mtimage
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
// dxt.{h,cpp} — ported verbatim from xrender-poc/engine/dxt.{h,cpp}
|
||||
// (namespace engine -> mtgodot). DDS DXT1/3/5 + masked 16/24/32-bit RGB -> RGBA8, level 0.
|
||||
// (namespace engine -> mtimage). DDS DXT1/3/5 + masked 16/24/32-bit RGB -> RGBA8, level 0.
|
||||
// engine/dxt —— DDS(DXT1/3/5) → RGBA8 软解。见 docs/steps/M1-static-render.md T3
|
||||
// 自己写 ~150 行绕开 reuse/EterImageLib 的 windows.h 依赖(M1 风险表允许)。
|
||||
// 对拍时两侧都喂软解 RGBA,不被 GPU S3TC 的 bit 级差异污染。
|
||||
@@ -7,7 +7,7 @@
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
namespace mtgodot {
|
||||
namespace mtimage {
|
||||
|
||||
struct Image {
|
||||
uint16_t w = 0, h = 0;
|
||||
@@ -24,4 +24,4 @@ struct Image {
|
||||
Image load_dds(const uint8_t* bytes, size_t len);
|
||||
Image load_dds_path(const char* path);
|
||||
|
||||
} // namespace mtgodot
|
||||
} // namespace mtimage
|
||||
|
||||
@@ -1,451 +0,0 @@
|
||||
#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>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
using namespace godot;
|
||||
|
||||
namespace mtgodot {
|
||||
|
||||
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::load_from_file cannot open the pack:// virtual filesystem.
|
||||
if (godot_path.begins_with("pack://")) {
|
||||
PackedByteArray bytes = mtgodot::read_file(godot_path);
|
||||
image.instantiate();
|
||||
if (godot_path.get_extension().to_lower() == "tga")
|
||||
image->load_tga_from_buffer(bytes);
|
||||
else if (godot_path.get_extension().to_lower() == "png")
|
||||
image->load_png_from_buffer(bytes);
|
||||
else if (godot_path.get_extension().to_lower() == "jpg")
|
||||
image->load_jpg_from_buffer(bytes);
|
||||
} 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,
|
||||
const fmt::AssetResolver *resolver) {
|
||||
EnvNodes out;
|
||||
|
||||
// --- DirectionalLight (Background) ---
|
||||
out.sun = Object::cast_to<DirectionalLight3D>(parent->get_node_or_null(NodePath("Sun")));
|
||||
if (!out.sun) {
|
||||
out.sun = memnew(DirectionalLight3D);
|
||||
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]);
|
||||
Vector3 fwd(d.x, d.y, d.z);
|
||||
if (fwd.length() < 1e-4f)
|
||||
fwd = Vector3(-0.4f, -0.7f, -0.55f);
|
||||
fwd.normalize();
|
||||
// Godot 光沿自身 -Z 照射 -> basis 的 -Z = fwd
|
||||
Basis b = Basis::looking_at(fwd, Vector3(0, 1, 0));
|
||||
out.sun->set_transform(Transform3D(b, Vector3(0, 0, 0)));
|
||||
if (env.dir_light.bg_enable || luma(env.dir_light.bg_diffuse) > 0.01f) {
|
||||
out.sun->set_color(rgba(env.dir_light.bg_diffuse));
|
||||
out.sun->set_param(Light3D::PARAM_ENERGY,
|
||||
std::clamp(0.9f + luma(env.dir_light.bg_diffuse) * 0.4f, 0.7f, 1.6f));
|
||||
}
|
||||
out.sun->set_shadow(true);
|
||||
out.sun->set_param(Light3D::PARAM_SHADOW_NORMAL_BIAS, 2.0f);
|
||||
out.sun->set_param(Light3D::PARAM_SHADOW_BIAS, 0.06f);
|
||||
out.sun->set_param(Light3D::PARAM_SHADOW_MAX_DISTANCE, 500.0f);
|
||||
out.sun->set_param(Light3D::PARAM_SHADOW_SPLIT_1_OFFSET, 0.08f);
|
||||
out.sun->set_param(Light3D::PARAM_SHADOW_SPLIT_2_OFFSET, 0.22f);
|
||||
out.sun->set_param(Light3D::PARAM_SHADOW_SPLIT_3_OFFSET, 0.5f);
|
||||
out.sun->set_shadow_mode(DirectionalLight3D::SHADOW_PARALLEL_4_SPLITS);
|
||||
// 角色/物体的间接补光:暖色,能量取自 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")));
|
||||
if (!out.world_env) {
|
||||
out.world_env = memnew(WorldEnvironment);
|
||||
out.world_env->set_name("WorldEnv");
|
||||
parent->add_child(out.world_env);
|
||||
}
|
||||
Ref<godot::Environment> e = out.world_env->get_environment();
|
||||
if (e.is_null())
|
||||
e.instantiate();
|
||||
|
||||
// 天空: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();
|
||||
const auto &g = env.sky.gradient;
|
||||
if (g.size() >= 2) {
|
||||
psm->set_sky_top_color(rgba(g.front()));
|
||||
psm->set_sky_horizon_color(rgba(g[g.size() / 2]));
|
||||
psm->set_ground_horizon_color(rgba(g.back()));
|
||||
Color gb = rgba(g.back());
|
||||
psm->set_ground_bottom_color(Color(gb.r * 0.6f, gb.g * 0.6f, gb.b * 0.65f));
|
||||
}
|
||||
psm->set_sun_angle_max(6.0f);
|
||||
Ref<Sky> sky;
|
||||
sky.instantiate();
|
||||
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);
|
||||
}
|
||||
|
||||
// 环境光:Material.Ambient 定色 + 亮度(Metin2 用暖色环境光提亮阴影面)
|
||||
e->set_ambient_source(godot::Environment::AMBIENT_SOURCE_COLOR);
|
||||
Color amb = rgba(env.material.ambient);
|
||||
e->set_ambient_light_color(amb);
|
||||
e->set_ambient_light_energy(std::clamp(0.5f + luma(env.material.ambient) * 0.4f, 0.35f, 0.95f));
|
||||
e->set_ambient_light_sky_contribution(0.35f);
|
||||
// Emissive 当作全局轻微自发光提亮(避免死黑)
|
||||
e->set_bg_energy_multiplier(1.0f);
|
||||
|
||||
// 雾:优先用 .msenv 的 NearDistance/FarDistance(cm,A1 = 5000/20000 -> 50/200m
|
||||
// 太近,客户端 D3DFOG 实际按更大的世界尺度;乘一个系数放到远景轻霭区)。没给
|
||||
// 距离就退回 foglevel 启发式。参考端是线性远景雾 + 天空同色,不是浓雾。
|
||||
if (env.fog.enable) {
|
||||
e->set_fog_enabled(true);
|
||||
e->set_fog_light_color(rgba(env.fog.color));
|
||||
e->set_fog_mode(godot::Environment::FOG_MODE_DEPTH);
|
||||
float begin_m, end_m;
|
||||
if (env.fog.near_distance > 1.0f && env.fog.far_distance > env.fog.near_distance) {
|
||||
begin_m = env.fog.near_distance * (float)fmt::m2coord::CM_TO_M;
|
||||
end_m = env.fog.far_distance * (float)fmt::m2coord::CM_TO_M;
|
||||
// 客户端摄距比我们远:把近雾往后推一截,别糊住中景
|
||||
begin_m = std::max(begin_m, 120.0f);
|
||||
end_m = std::max(end_m, begin_m + 400.0f);
|
||||
} else {
|
||||
float fl = env.fog.fog_level > 0 ? float(env.fog.fog_level) : 4.0f;
|
||||
begin_m = std::clamp((11.0f - fl) * 45.0f, 60.0f, 500.0f);
|
||||
end_m = begin_m + 700.0f;
|
||||
}
|
||||
e->set_fog_depth_begin(begin_m);
|
||||
e->set_fog_depth_end(end_m);
|
||||
e->set_fog_depth_curve(0.5f);
|
||||
e->set_fog_density(0.0f);
|
||||
e->set_fog_sky_affect(0.0f); // 天空自己是渐变,不要被雾再洗一层
|
||||
e->set_fog_sun_scatter(0.05f);
|
||||
} else {
|
||||
e->set_fog_enabled(false);
|
||||
}
|
||||
|
||||
// 色调:参考端是 DX9 定功能、LDR、无 tonemap/HDR。用 LINEAR + 曝光 1 最贴近,
|
||||
// Filmic 会抬黑、降饱和 -> 画面发灰。轻微加饱和/对比补回胶片感。
|
||||
e->set_tonemapper(godot::Environment::TONE_MAPPER_LINEAR);
|
||||
e->set_tonemap_exposure(1.0f);
|
||||
e->set_adjustment_enabled(true);
|
||||
e->set_adjustment_saturation(1.12f);
|
||||
e->set_adjustment_contrast(1.05f);
|
||||
e->set_adjustment_brightness(1.0f);
|
||||
// glow:参考端没有 bloom。留极轻的,只有真过曝才溢。
|
||||
e->set_glow_enabled(true);
|
||||
e->set_glow_intensity(0.06f);
|
||||
e->set_glow_strength(0.7f);
|
||||
e->set_glow_bloom(0.0f);
|
||||
e->set_glow_hdr_bleed_threshold(1.6f);
|
||||
// SSAO:参考端把接触阴影烘进地形阴影贴图。这里用一点实时 SSAO 代替。
|
||||
e->set_ssao_enabled(true);
|
||||
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;
|
||||
}
|
||||
|
||||
} // namespace mtgodot
|
||||
@@ -1,26 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#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。背景 / 角色方向光按参考端分成两个可见性层;云贴图接入
|
||||
// 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,
|
||||
const fmt::AssetResolver *resolver = nullptr);
|
||||
|
||||
} // namespace mtgodot
|
||||
@@ -1,244 +0,0 @@
|
||||
#include "gr2_bridge.h"
|
||||
|
||||
#include <godot_cpp/classes/array_mesh.hpp>
|
||||
#include <godot_cpp/classes/mesh.hpp>
|
||||
#include <godot_cpp/classes/skeleton3d.hpp>
|
||||
#include <godot_cpp/classes/skin.hpp>
|
||||
#include <godot_cpp/core/math.hpp>
|
||||
#include <godot_cpp/core/memory.hpp>
|
||||
#include <godot_cpp/variant/packed_float32_array.hpp>
|
||||
#include <godot_cpp/variant/packed_int32_array.hpp>
|
||||
#include <godot_cpp/variant/packed_vector2_array.hpp>
|
||||
#include <godot_cpp/variant/packed_vector3_array.hpp>
|
||||
#include <godot_cpp/variant/utility_functions.hpp>
|
||||
|
||||
#include <vector>
|
||||
|
||||
using namespace godot;
|
||||
|
||||
namespace mtgodot {
|
||||
|
||||
gr2::Mat4 mul4x3(const gr2::Mat4 &A, const gr2::Mat4 &B) {
|
||||
gr2::Mat4 R{};
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
for (int k = 0; k < 3; ++k)
|
||||
R[i * 4 + k] = A[i * 4 + 0] * B[0 * 4 + k] + A[i * 4 + 1] * B[1 * 4 + k] +
|
||||
A[i * 4 + 2] * B[2 * 4 + k];
|
||||
}
|
||||
for (int k = 0; k < 3; ++k)
|
||||
R[12 + k] = A[12] * B[k] + A[13] * B[4 + k] + A[14] * B[8 + k] + B[12 + k];
|
||||
R[15] = 1.0f;
|
||||
return R;
|
||||
}
|
||||
|
||||
Transform3D gr2_to_godot(const gr2::Mat4 &m) {
|
||||
// gr2 row-major, row-vector: basis columns are (m0,m1,m2),(m4,m5,m6),(m8,m9,m10);
|
||||
// translation is the 4th row (m12,m13,m14).
|
||||
Basis b(
|
||||
Vector3(m[0], m[1], m[2]),
|
||||
Vector3(m[4], m[5], m[6]),
|
||||
Vector3(m[8], m[9], m[10]));
|
||||
return Transform3D(b, Vector3(m[12], m[13], m[14]));
|
||||
}
|
||||
|
||||
Transform3D make_conv(float unit_scale, bool flip_z) {
|
||||
// Z-up -> Y-up: rotate -90 deg about X. Then uniform scale. Optional Z flip.
|
||||
Basis b;
|
||||
b = b.rotated(Vector3(1, 0, 0), Math::deg_to_rad(-90.0));
|
||||
b = b.scaled(Vector3(unit_scale, unit_scale, flip_z ? -unit_scale : unit_scale));
|
||||
return Transform3D(b, Vector3());
|
||||
}
|
||||
|
||||
Skeleton3D *build_skeleton(const gr2::Skeleton &sk) {
|
||||
if (sk.bones.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
Skeleton3D *skel = memnew(Skeleton3D);
|
||||
skel->set_name("Skeleton3D");
|
||||
|
||||
const Transform3D ip = gr2_to_godot(sk.initial_placement);
|
||||
|
||||
for (size_t i = 0; i < sk.bones.size(); ++i) {
|
||||
skel->add_bone(String(sk.bones[i].name.c_str()));
|
||||
}
|
||||
for (size_t i = 0; i < sk.bones.size(); ++i) {
|
||||
const gr2::Bone &bn = sk.bones[i];
|
||||
skel->set_bone_parent((int)i, bn.parent);
|
||||
Transform3D rest = gr2_to_godot(bn.local_transform);
|
||||
if (bn.parent < 0) {
|
||||
rest = ip * rest; // fold initial_placement into root bones
|
||||
}
|
||||
skel->set_bone_rest((int)i, rest);
|
||||
skel->reset_bone_pose((int)i);
|
||||
}
|
||||
return skel;
|
||||
}
|
||||
|
||||
Ref<Skin> build_skin(const gr2::Skeleton &sk) {
|
||||
Ref<Skin> skin;
|
||||
skin.instantiate();
|
||||
for (size_t i = 0; i < sk.bones.size(); ++i) {
|
||||
skin->add_bind((int)i, gr2_to_godot(sk.bones[i].inverse_world));
|
||||
skin->set_bind_name((int)i, StringName(sk.bones[i].name.c_str()));
|
||||
}
|
||||
return skin;
|
||||
}
|
||||
|
||||
std::vector<RenderPart> build_parts(const gr2::FileInfo &fi) {
|
||||
std::vector<RenderPart> parts;
|
||||
for (int mi = 0; mi < (int)fi.meshes.size(); ++mi) {
|
||||
const gr2::Mesh &m = fi.meshes[mi];
|
||||
if (m.vertices.empty() || m.indices.empty()) {
|
||||
continue;
|
||||
}
|
||||
const uint32_t total = (uint32_t)m.indices.size();
|
||||
if (m.tri_groups.size() <= 1) {
|
||||
RenderPart p;
|
||||
p.mesh = mi;
|
||||
p.mat_index = m.tri_groups.empty() ? -1 : m.tri_groups[0].material_index;
|
||||
p.idx_first = 0;
|
||||
p.idx_count = total;
|
||||
parts.push_back(p);
|
||||
continue;
|
||||
}
|
||||
for (int g = 0; g < (int)m.tri_groups.size(); ++g) {
|
||||
const gr2::TriGroup &tg = m.tri_groups[g];
|
||||
if (tg.tri_count <= 0) {
|
||||
continue;
|
||||
}
|
||||
uint32_t first = (uint32_t)(tg.tri_first < 0 ? 0 : tg.tri_first) * 3u;
|
||||
uint32_t count = (uint32_t)tg.tri_count * 3u;
|
||||
if (first >= total) {
|
||||
continue;
|
||||
}
|
||||
if (first + count > total) {
|
||||
count = total - first;
|
||||
}
|
||||
RenderPart p;
|
||||
p.mesh = mi;
|
||||
p.group = g;
|
||||
p.mat_index = tg.material_index;
|
||||
p.idx_first = first;
|
||||
p.idx_count = count;
|
||||
parts.push_back(p);
|
||||
}
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
Ref<ArrayMesh> build_mesh(const gr2::FileInfo &fi, const std::vector<RenderPart> &parts,
|
||||
bool flip_winding, AABB &out_bounds) {
|
||||
Ref<ArrayMesh> am;
|
||||
am.instantiate();
|
||||
am->set_name("ArrayMesh");
|
||||
|
||||
bool have_bounds = false;
|
||||
|
||||
// vertex arrays are per gr2 mesh; multiple parts of one mesh reuse them.
|
||||
for (const RenderPart &part : parts) {
|
||||
const gr2::Mesh &m = fi.meshes[part.mesh];
|
||||
const int vcount = (int)m.vertices.size();
|
||||
|
||||
PackedVector3Array pos;
|
||||
PackedVector3Array nrm;
|
||||
PackedVector2Array uv;
|
||||
PackedInt32Array bones;
|
||||
PackedFloat32Array weights;
|
||||
pos.resize(vcount);
|
||||
nrm.resize(vcount);
|
||||
uv.resize(vcount);
|
||||
bones.resize(vcount * 4);
|
||||
weights.resize(vcount * 4);
|
||||
|
||||
Vector3 *pos_w = pos.ptrw();
|
||||
Vector3 *nrm_w = nrm.ptrw();
|
||||
Vector2 *uv_w = uv.ptrw();
|
||||
int32_t *bn_w = bones.ptrw();
|
||||
float *wt_w = weights.ptrw();
|
||||
|
||||
auto slot_to_bone = [&](int slot) -> int {
|
||||
if (slot < 0 || slot >= (int)m.bone_bindings.size()) {
|
||||
return 0;
|
||||
}
|
||||
int b = m.bone_bindings[slot];
|
||||
return (b < 0) ? 0 : b;
|
||||
};
|
||||
|
||||
const int rigid_bone = m.rigid ? slot_to_bone(m.bone_bindings.empty() ? -1 : 0) : 0;
|
||||
|
||||
for (int i = 0; i < vcount; ++i) {
|
||||
const gr2::Vertex &v = m.vertices[i];
|
||||
pos_w[i] = Vector3(v.pos[0], v.pos[1], v.pos[2]);
|
||||
nrm_w[i] = Vector3(v.normal[0], v.normal[1], v.normal[2]);
|
||||
uv_w[i] = Vector2(v.uv0[0], v.uv0[1]);
|
||||
|
||||
if (!have_bounds) {
|
||||
out_bounds.position = pos_w[i];
|
||||
out_bounds.size = Vector3();
|
||||
have_bounds = true;
|
||||
} else {
|
||||
out_bounds = out_bounds.expand(pos_w[i]);
|
||||
}
|
||||
|
||||
if (m.rigid) {
|
||||
bn_w[i * 4 + 0] = rigid_bone;
|
||||
bn_w[i * 4 + 1] = bn_w[i * 4 + 2] = bn_w[i * 4 + 3] = 0;
|
||||
wt_w[i * 4 + 0] = 1.0f;
|
||||
wt_w[i * 4 + 1] = wt_w[i * 4 + 2] = wt_w[i * 4 + 3] = 0.0f;
|
||||
continue;
|
||||
}
|
||||
|
||||
float wsum = 0.0f;
|
||||
for (int k = 0; k < 4; ++k) {
|
||||
wsum += v.bone_weight[k];
|
||||
}
|
||||
for (int k = 0; k < 4; ++k) {
|
||||
bn_w[i * 4 + k] = slot_to_bone(v.bone_index[k]);
|
||||
wt_w[i * 4 + k] = (wsum > 0.0f) ? (v.bone_weight[k] / (float)wsum) : (k == 0 ? 1.0f : 0.0f);
|
||||
}
|
||||
}
|
||||
|
||||
// indices: this part's sub-range of mesh.indices only
|
||||
const uint32_t ib = part.idx_first;
|
||||
const uint32_t ic = (part.idx_count && part.idx_first + part.idx_count <= m.indices.size())
|
||||
? part.idx_count
|
||||
: (uint32_t)m.indices.size() - ib;
|
||||
PackedInt32Array idx;
|
||||
idx.resize((int)ic);
|
||||
int32_t *idx_w = idx.ptrw();
|
||||
if (flip_winding) {
|
||||
for (uint32_t t = 0; t + 2 < ic; t += 3) {
|
||||
idx_w[t + 0] = (int)m.indices[ib + t + 0];
|
||||
idx_w[t + 1] = (int)m.indices[ib + t + 2];
|
||||
idx_w[t + 2] = (int)m.indices[ib + t + 1];
|
||||
}
|
||||
} else {
|
||||
for (uint32_t t = 0; t < ic; ++t) {
|
||||
idx_w[t] = (int)m.indices[ib + t];
|
||||
}
|
||||
}
|
||||
|
||||
Array arrays;
|
||||
arrays.resize(Mesh::ARRAY_MAX);
|
||||
arrays[Mesh::ARRAY_VERTEX] = pos;
|
||||
arrays[Mesh::ARRAY_NORMAL] = nrm;
|
||||
arrays[Mesh::ARRAY_TEX_UV] = uv;
|
||||
arrays[Mesh::ARRAY_BONES] = bones;
|
||||
arrays[Mesh::ARRAY_WEIGHTS] = weights;
|
||||
arrays[Mesh::ARRAY_INDEX] = idx;
|
||||
|
||||
am->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, arrays);
|
||||
String nm = m.name.empty() ? String("surf_") + itos(part.mesh) : String(m.name.c_str());
|
||||
if (part.group >= 0) {
|
||||
nm += String("#") + itos(part.group);
|
||||
}
|
||||
am->surface_set_name(am->get_surface_count() - 1, nm);
|
||||
}
|
||||
|
||||
if (!have_bounds) {
|
||||
out_bounds = AABB();
|
||||
}
|
||||
return am;
|
||||
}
|
||||
|
||||
} // namespace mtgodot
|
||||
@@ -1,65 +0,0 @@
|
||||
#pragma once
|
||||
// gr2_bridge — libgr2 POD views -> Godot scene objects.
|
||||
//
|
||||
// Coordinate handling (the one place it lives; see docs/GODOT-POC-PLAN.md §02):
|
||||
// - gr2 Mat4 is row-major, row-vector math, translation in elements 12..14.
|
||||
// - A plain 4x4 transpose maps it to a Godot Transform3D (column-vector,
|
||||
// translation in .origin). gr2_to_godot() does exactly that.
|
||||
// - Z-up(cm) -> Y-up(m) and optional handedness flip are NOT applied here;
|
||||
// they go on the Metin2Model node transform (make_conv()).
|
||||
|
||||
#include <godot_cpp/classes/ref.hpp>
|
||||
#include <godot_cpp/variant/aabb.hpp>
|
||||
#include <godot_cpp/variant/transform3d.hpp>
|
||||
|
||||
#include <gr2/gr2.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
namespace godot {
|
||||
class Skeleton3D;
|
||||
class Skin;
|
||||
class ArrayMesh;
|
||||
} // namespace godot
|
||||
|
||||
namespace mtgodot {
|
||||
|
||||
// 4x4 transpose: gr2 row-major/row-vector -> Godot Transform3D.
|
||||
godot::Transform3D gr2_to_godot(const gr2::Mat4 &m);
|
||||
|
||||
// gr2 affine compose (row-vector: result applies A then B), same as libgr2's
|
||||
// internal mul4x3. R = A · B with the 4th row treated as translation.
|
||||
gr2::Mat4 mul4x3(const gr2::Mat4 &A, const gr2::Mat4 &B);
|
||||
|
||||
// Z-up cm -> Y-up m (+ optional Z flip for LH->RH content).
|
||||
godot::Transform3D make_conv(float unit_scale, bool flip_z);
|
||||
|
||||
// Build a Skeleton3D (rest pose from bone local transforms; initial_placement
|
||||
// folded into root bones). Returns nullptr if the skeleton has no bones.
|
||||
godot::Skeleton3D *build_skeleton(const gr2::Skeleton &sk);
|
||||
|
||||
// One render part = one Godot surface. A gr2 mesh with N>1 tri_groups splits into
|
||||
// N parts (each its own material); a mesh with 0/1 groups is one whole-mesh part.
|
||||
struct RenderPart {
|
||||
int mesh = -1; // gr2::FileInfo::meshes index
|
||||
int group = -1; // gr2::Mesh::tri_groups index, or -1 = whole mesh
|
||||
int mat_index = -1; // tri_groups[group].material_index (mesh-local), or -1
|
||||
uint32_t idx_first = 0; // start into mesh.indices (= tri_first * 3)
|
||||
uint32_t idx_count = 0; // length into mesh.indices (= tri_count * 3)
|
||||
};
|
||||
std::vector<RenderPart> build_parts(const gr2::FileInfo &fi);
|
||||
|
||||
// Skin whose bind list is parallel to the skeleton bones:
|
||||
// bind i -> bone i, pose = gr2_to_godot(bone[i].inverse_world)
|
||||
godot::Ref<godot::Skin> build_skin(const gr2::Skeleton &sk);
|
||||
|
||||
// One ArrayMesh with a surface per RenderPart (see build_parts).
|
||||
// ARRAY_BONES values are skeleton bone indices (mesh slot -> bone via
|
||||
// mesh.bone_bindings). Rigid meshes are bound rigidly to bone_bindings[0].
|
||||
// Fills out_bounds with the untransformed vertex AABB.
|
||||
godot::Ref<godot::ArrayMesh> build_mesh(const gr2::FileInfo &fi,
|
||||
const std::vector<RenderPart> &parts, bool flip_winding,
|
||||
godot::AABB &out_bounds);
|
||||
|
||||
} // namespace mtgodot
|
||||
@@ -1,276 +0,0 @@
|
||||
#include "m2_material.h"
|
||||
|
||||
#include <godot_cpp/classes/image_texture.hpp>
|
||||
#include <godot_cpp/classes/shader.hpp>
|
||||
#include <godot_cpp/classes/shader_material.hpp>
|
||||
#include <godot_cpp/classes/texture2d.hpp>
|
||||
#include <godot_cpp/variant/color.hpp>
|
||||
|
||||
using namespace godot;
|
||||
|
||||
namespace mtgodot {
|
||||
namespace {
|
||||
|
||||
// blend_mix shader: covers opaque (mode 0, alpha-scissor) and alpha (mode 1).
|
||||
const char *SRC_MIX = R"(shader_type spatial;
|
||||
render_mode blend_mix, depth_draw_opaque, cull_back, diffuse_lambert, specular_disabled;
|
||||
|
||||
uniform sampler2D albedo_tex : source_color, filter_linear_mipmap, repeat_enable;
|
||||
uniform bool use_texture = true;
|
||||
uniform vec4 modulate : source_color = vec4(1.0);
|
||||
uniform float alpha_scissor : hint_range(0.0, 1.0) = 0.5;
|
||||
uniform int mode = 0; // 0 opaque | 1 alpha-blend | 2 alpha-test (cutout)
|
||||
// sphere-map specular (EterGrnLib/Material.cpp:305 __ApplySpecularRenderState)
|
||||
uniform sampler2D spec_map : source_color, filter_linear_mipmap, repeat_enable;
|
||||
uniform float spec_power = 0.0;
|
||||
uniform bool spec_enable = false;
|
||||
uniform float lod_fade = 1.0; // LOD crossfade multiplier (1 = fully shown)
|
||||
|
||||
void fragment() {
|
||||
vec4 c = use_texture ? texture(albedo_tex, UV) : vec4(1.0);
|
||||
float spec_mask = c.a; // tex.a before modulate == D3DTA_TEXTURE alpha
|
||||
c *= modulate; // no vertex color: Metin2 PC meshes carry none (ARRAY_COLOR absent)
|
||||
if (mode == 2 && c.a < alpha_scissor) {
|
||||
discard;
|
||||
}
|
||||
ALBEDO = c.rgb;
|
||||
ALPHA = ((mode == 1) ? c.a : 1.0) * lod_fade;
|
||||
// Opaque only: client early-outs to plain diffuse when D3DRS_ALPHABLENDENABLE.
|
||||
// stage1 COLOROP MODULATEALPHA_ADDCOLOR = CURRENT.rgb + CURRENT.a*sphere.rgb,
|
||||
// CURRENT.a = tex.a * D3DRS_TEXTUREFACTOR.a (= spec_power). texcoord =
|
||||
// D3DTSS_TCI_CAMERASPACEREFLECTIONVECTOR -> view-space reflect(); .xy as UV.
|
||||
if (spec_enable && spec_power > 0.0 && mode == 0) {
|
||||
// Sphere-map metallic sheen (EterGrnLib/Material.cpp:305). The client masks
|
||||
// this with the armor texture's alpha (metal=1, cloth=0); our loose-file
|
||||
// texture resolution can't be trusted for that alpha, so bias it to
|
||||
// grazing angles (Fresnel) and knock the level down — reads as an edge
|
||||
// sheen instead of a full-body chrome mirror.
|
||||
vec3 vdir = normalize(VERTEX);
|
||||
vec3 ndir = normalize(NORMAL);
|
||||
float fres = pow(clamp(1.0 - abs(dot(ndir, vdir)), 0.0, 1.0), 3.0);
|
||||
vec3 refl = reflect(vdir, ndir);
|
||||
EMISSION = texture(spec_map, refl.xy * 0.5 + 0.5).rgb
|
||||
* (spec_mask * spec_power * fres * 0.5);
|
||||
}
|
||||
}
|
||||
)";
|
||||
|
||||
// Skinned variant of SRC_MIX: LBS in vertex() with FULL per-bone matrices from a
|
||||
// float texture (no Skeleton3D). bones_tex is RGBAF, 3 x bone_count; row = bone,
|
||||
// texel j = column j of the row-vector skin matrix (skinned = [pos 1] * M).
|
||||
const char *SRC_SKIN = R"(shader_type spatial;
|
||||
render_mode blend_mix, depth_draw_opaque, cull_back, diffuse_lambert, specular_disabled;
|
||||
|
||||
uniform sampler2D albedo_tex : source_color, filter_linear_mipmap, repeat_enable;
|
||||
uniform bool use_texture = true;
|
||||
uniform vec4 modulate : source_color = vec4(1.0);
|
||||
uniform float alpha_scissor : hint_range(0.0, 1.0) = 0.5;
|
||||
uniform int mode = 0;
|
||||
uniform sampler2D bones_tex : filter_nearest; // RGBAF, 3 x bone_count
|
||||
uniform sampler2D spec_map : source_color, filter_linear_mipmap, repeat_enable;
|
||||
uniform float spec_power = 0.0;
|
||||
uniform bool spec_enable = false;
|
||||
uniform float lod_fade = 1.0; // LOD crossfade multiplier (1 = fully shown)
|
||||
|
||||
void vertex() {
|
||||
vec4 p = vec4(VERTEX, 1.0);
|
||||
vec3 sp = vec3(0.0);
|
||||
vec3 sn = vec3(0.0);
|
||||
ivec4 bi = ivec4(BONE_INDICES);
|
||||
vec4 bw = BONE_WEIGHTS;
|
||||
float wsum = bw.x + bw.y + bw.z + bw.w;
|
||||
if (wsum <= 0.0) { bw = vec4(1.0, 0.0, 0.0, 0.0); wsum = 1.0; }
|
||||
for (int k = 0; k < 4; k++) {
|
||||
float w = bw[k] / wsum;
|
||||
if (w <= 0.0) { continue; }
|
||||
int r = bi[k];
|
||||
vec4 c0 = texelFetch(bones_tex, ivec2(0, r), 0);
|
||||
vec4 c1 = texelFetch(bones_tex, ivec2(1, r), 0);
|
||||
vec4 c2 = texelFetch(bones_tex, ivec2(2, r), 0);
|
||||
sp += w * vec3(dot(p, c0), dot(p, c1), dot(p, c2));
|
||||
sn += w * vec3(dot(NORMAL, c0.xyz), dot(NORMAL, c1.xyz), dot(NORMAL, c2.xyz));
|
||||
}
|
||||
VERTEX = sp;
|
||||
NORMAL = normalize(sn);
|
||||
}
|
||||
|
||||
void fragment() {
|
||||
vec4 c = use_texture ? texture(albedo_tex, UV) : vec4(1.0);
|
||||
float spec_mask = c.a;
|
||||
c *= modulate;
|
||||
if (mode == 2 && c.a < alpha_scissor) { discard; }
|
||||
ALBEDO = c.rgb;
|
||||
ALPHA = ((mode == 1) ? c.a : 1.0) * lod_fade;
|
||||
if (spec_enable && spec_power > 0.0 && mode == 0) {
|
||||
// Sphere-map metallic sheen (EterGrnLib/Material.cpp:305). The client masks
|
||||
// this with the armor texture's alpha (metal=1, cloth=0); our loose-file
|
||||
// texture resolution can't be trusted for that alpha, so bias it to
|
||||
// grazing angles (Fresnel) and knock the level down — reads as an edge
|
||||
// sheen instead of a full-body chrome mirror.
|
||||
vec3 vdir = normalize(VERTEX);
|
||||
vec3 ndir = normalize(NORMAL);
|
||||
float fres = pow(clamp(1.0 - abs(dot(ndir, vdir)), 0.0, 1.0), 3.0);
|
||||
vec3 refl = reflect(vdir, ndir);
|
||||
EMISSION = texture(spec_map, refl.xy * 0.5 + 0.5).rgb
|
||||
* (spec_mask * spec_power * fres * 0.5);
|
||||
}
|
||||
}
|
||||
)";
|
||||
|
||||
// blend_add shader: additive glow (effects, enchant overlays).
|
||||
const char *SRC_ADD = R"(shader_type spatial;
|
||||
render_mode blend_add, depth_draw_opaque, depth_test_disabled, cull_back, unshaded;
|
||||
|
||||
uniform sampler2D albedo_tex : source_color, filter_linear_mipmap, repeat_enable;
|
||||
uniform bool use_texture = true;
|
||||
uniform vec4 modulate : source_color = vec4(1.0);
|
||||
|
||||
void fragment() {
|
||||
vec4 c = use_texture ? texture(albedo_tex, UV) : vec4(1.0);
|
||||
c *= modulate; // no vertex color: Metin2 PC meshes carry none (ARRAY_COLOR absent)
|
||||
ALBEDO = c.rgb * c.a;
|
||||
ALPHA = 1.0;
|
||||
}
|
||||
)";
|
||||
|
||||
// Additive surfaces need the same vertex deformation as opaque/alpha surfaces.
|
||||
// Keeping this as a separate shader preserves blend_add/unshaded render modes.
|
||||
const char *SRC_ADD_SKIN = R"(shader_type spatial;
|
||||
render_mode blend_add, depth_draw_opaque, depth_test_disabled, cull_back, unshaded;
|
||||
|
||||
uniform sampler2D albedo_tex : source_color, filter_linear_mipmap, repeat_enable;
|
||||
uniform bool use_texture = true;
|
||||
uniform vec4 modulate : source_color = vec4(1.0);
|
||||
uniform sampler2D bones_tex : filter_nearest;
|
||||
|
||||
void vertex() {
|
||||
vec4 p = vec4(VERTEX, 1.0);
|
||||
vec3 sp = vec3(0.0);
|
||||
vec3 sn = vec3(0.0);
|
||||
ivec4 bi = ivec4(BONE_INDICES);
|
||||
vec4 bw = BONE_WEIGHTS;
|
||||
float wsum = bw.x + bw.y + bw.z + bw.w;
|
||||
if (wsum <= 0.0) { bw = vec4(1.0, 0.0, 0.0, 0.0); wsum = 1.0; }
|
||||
for (int k = 0; k < 4; k++) {
|
||||
float w = bw[k] / wsum;
|
||||
if (w <= 0.0) { continue; }
|
||||
int r = bi[k];
|
||||
vec4 c0 = texelFetch(bones_tex, ivec2(0, r), 0);
|
||||
vec4 c1 = texelFetch(bones_tex, ivec2(1, r), 0);
|
||||
vec4 c2 = texelFetch(bones_tex, ivec2(2, r), 0);
|
||||
sp += w * vec3(dot(p, c0), dot(p, c1), dot(p, c2));
|
||||
sn += w * vec3(dot(NORMAL, c0.xyz), dot(NORMAL, c1.xyz), dot(NORMAL, c2.xyz));
|
||||
}
|
||||
VERTEX = sp;
|
||||
NORMAL = normalize(sn);
|
||||
}
|
||||
|
||||
void fragment() {
|
||||
vec4 c = use_texture ? texture(albedo_tex, UV) : vec4(1.0);
|
||||
c *= modulate;
|
||||
ALBEDO = c.rgb * c.a;
|
||||
ALPHA = 1.0;
|
||||
}
|
||||
)";
|
||||
|
||||
Ref<Shader> s_mix;
|
||||
Ref<Shader> s_add;
|
||||
Ref<Shader> s_skin;
|
||||
Ref<Shader> s_add_skin;
|
||||
// cull_disabled variants for two-sided parts (hair / cloth / foliage; client
|
||||
// ExtendedData "Two-sided" -> D3DRS_CULLMODE = D3DCULL_NONE).
|
||||
Ref<Shader> s_mix_2s;
|
||||
Ref<Shader> s_add_2s;
|
||||
Ref<Shader> s_skin_2s;
|
||||
Ref<Shader> s_add_skin_2s;
|
||||
Ref<Shader> s_opaque[4];
|
||||
|
||||
Ref<Shader> shader_for(bool additive, bool skinned, bool two_sided, bool opaque) {
|
||||
if (opaque) {
|
||||
Ref<Shader> &solid = s_opaque[(skinned ? 2 : 0) + (two_sided ? 1 : 0)];
|
||||
if (solid.is_null()) {
|
||||
String code = skinned ? SRC_SKIN : SRC_MIX;
|
||||
// Writing ALPHA puts even alpha=1 into Godot's transparent pass.
|
||||
// Solid faces must write depth before the hair cutout is drawn.
|
||||
code = code.replace("ALPHA = ((mode == 1) ? c.a : 1.0) * lod_fade;",
|
||||
"if (lod_fade < fract(sin(dot(FRAGCOORD.xy, vec2(12.9898, 78.233))) * 43758.5453)) { discard; }");
|
||||
if (two_sided)
|
||||
code = code.replace("cull_back", "cull_disabled");
|
||||
solid.instantiate();
|
||||
solid->set_code(code);
|
||||
}
|
||||
return solid;
|
||||
}
|
||||
Ref<Shader> &slot = two_sided
|
||||
? (additive ? (skinned ? s_add_skin_2s : s_add_2s) : (skinned ? s_skin_2s : s_mix_2s))
|
||||
: (additive ? (skinned ? s_add_skin : s_add) : (skinned ? s_skin : s_mix));
|
||||
if (slot.is_null()) {
|
||||
slot.instantiate();
|
||||
const char *base =
|
||||
additive ? (skinned ? SRC_ADD_SKIN : SRC_ADD) : (skinned ? SRC_SKIN : SRC_MIX);
|
||||
slot->set_code(two_sided ? String(base).replace("cull_back", "cull_disabled")
|
||||
: String(base));
|
||||
}
|
||||
return slot;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Ref<ShaderMaterial> make_material(const MaterialDesc &d) {
|
||||
Ref<ShaderMaterial> m;
|
||||
m.instantiate();
|
||||
|
||||
const bool additive = (d.blend == BlendMode::Add);
|
||||
const bool skinned = d.skinned;
|
||||
m->set_shader(shader_for(additive, skinned, d.two_sided,
|
||||
d.blend == BlendMode::Opaque || d.blend == BlendMode::AlphaTest));
|
||||
if (skinned && d.bones_tex.is_valid()) {
|
||||
m->set_shader_parameter("bones_tex", d.bones_tex);
|
||||
}
|
||||
|
||||
const bool has_tex = d.albedo.is_valid();
|
||||
m->set_shader_parameter("use_texture", has_tex);
|
||||
if (has_tex) {
|
||||
m->set_shader_parameter("albedo_tex", d.albedo);
|
||||
}
|
||||
m->set_shader_parameter("modulate", has_tex ? Color(1, 1, 1, 1) : Color(0.8, 0.8, 0.82, 1));
|
||||
|
||||
if (!additive) {
|
||||
// sphere-map specular (dormant unless spec_power > 0 and a map is bound)
|
||||
const bool spec = d.spec_power > 0.0f && d.spec_map.is_valid();
|
||||
m->set_shader_parameter("spec_enable", spec);
|
||||
m->set_shader_parameter("spec_power", d.spec_power);
|
||||
if (spec) {
|
||||
m->set_shader_parameter("spec_map", d.spec_map);
|
||||
}
|
||||
m->set_shader_parameter("alpha_scissor", d.alpha_scissor);
|
||||
int mode = 0;
|
||||
if (d.blend == BlendMode::Alpha) {
|
||||
mode = 1;
|
||||
} else if (d.blend == BlendMode::AlphaTest) {
|
||||
mode = 2;
|
||||
}
|
||||
m->set_shader_parameter("mode", mode);
|
||||
}
|
||||
if (d.blend == BlendMode::Alpha) {
|
||||
m->set_render_priority(1);
|
||||
} else if (additive) {
|
||||
m->set_render_priority(2);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
void cleanup_material_shaders() {
|
||||
for (auto &shader : s_opaque)
|
||||
shader.unref();
|
||||
s_mix.unref();
|
||||
s_add.unref();
|
||||
s_skin.unref();
|
||||
s_add_skin.unref();
|
||||
s_mix_2s.unref();
|
||||
s_add_2s.unref();
|
||||
s_skin_2s.unref();
|
||||
s_add_skin_2s.unref();
|
||||
}
|
||||
|
||||
} // namespace mtgodot
|
||||
@@ -1,59 +0,0 @@
|
||||
#pragma once
|
||||
// m2_material — Metin2-style forward material as a Godot ShaderMaterial.
|
||||
//
|
||||
// M2.5 infrastructure. The gr2 fixed-function texture-stage / blend data is not
|
||||
// yet exposed by libgr2's POD API (see docs/GODOT-POC-PLAN.md §M2.5 gap list),
|
||||
// so callers pick the blend mode heuristically for now. The shader itself
|
||||
// already covers the common Metin2 cases: modulate(tex, vertex_color) + light,
|
||||
// alpha-test, alpha-blend, additive.
|
||||
|
||||
#include <godot_cpp/classes/ref.hpp>
|
||||
|
||||
namespace godot {
|
||||
class ShaderMaterial;
|
||||
class Texture2D;
|
||||
class ImageTexture;
|
||||
} // namespace godot
|
||||
|
||||
namespace mtgodot {
|
||||
|
||||
enum class BlendMode {
|
||||
Opaque, // blend_mix, ALPHA=1, no discard
|
||||
Alpha, // blend_mix, sorted, uses texture alpha
|
||||
AlphaTest, // blend_mix, discard below alpha_scissor (cutout)
|
||||
Add, // blend_add (glow / effects)
|
||||
};
|
||||
|
||||
struct MaterialDesc {
|
||||
godot::Ref<godot::Texture2D> albedo; // null -> flat white
|
||||
BlendMode blend = BlendMode::Opaque;
|
||||
float alpha_scissor = 0.5f;
|
||||
bool two_sided = true; // winding not yet verified per-model
|
||||
// GPU skinning: LBS with FULL per-bone affine matrices in the vertex shader
|
||||
// (no Skeleton3D -> no quaternion orthonormalization -> shear preserved).
|
||||
// `bones_tex` is RGBAF, size 3 x bone_count; columns 0..2 = the 3 columns of
|
||||
// the row-vector skin matrix (see metin2_model.cpp::gpu_skin).
|
||||
bool skinned = false;
|
||||
godot::Ref<godot::ImageTexture> bones_tex;
|
||||
|
||||
// Sphere-map specular — port of CGrannyMaterial::__ApplySpecularRenderState
|
||||
// (EterGrnLib/Material.cpp:305). Opaque armor gets a metallic highlight from a
|
||||
// camera-space reflection-vector lookup into a shared sphere map, added on top
|
||||
// of the lit diffuse:
|
||||
// out.rgb = tex.rgb*modulate + (tex.a * spec_power) * sphere(reflect_uv)
|
||||
// Client enables this per skin-part only when the equipped body-armor item's
|
||||
// item_proto `bSpecular > 0` (power = bSpecular/100); the base body is flat.
|
||||
// Dormant here until an equipment layer feeds a power: spec_power <= 0 ->
|
||||
// the shader branch is skipped and output is byte-identical to before.
|
||||
godot::Ref<godot::Texture2D> spec_map; // shared sphere map; null -> disabled
|
||||
float spec_power = 0.0f; // fSpecularPower (D3DRS_TEXTUREFACTOR.a); 0 = off
|
||||
};
|
||||
|
||||
godot::Ref<godot::ShaderMaterial> make_material(const MaterialDesc &d);
|
||||
|
||||
// Drop the extension-owned shader cache before GDExtension teardown. Materials
|
||||
// still alive in the scene keep their own Ref, so this only removes the static
|
||||
// lifetime that otherwise survives Godot's leak check.
|
||||
void cleanup_material_shaders();
|
||||
|
||||
} // namespace mtgodot
|
||||
@@ -1,856 +0,0 @@
|
||||
#include "metin2_anim.h"
|
||||
|
||||
#include "asset_io.h"
|
||||
#include "gr2_bridge.h"
|
||||
#include "metin2_model.h"
|
||||
|
||||
#include <godot_cpp/classes/skeleton3d.hpp>
|
||||
#include <godot_cpp/core/class_db.hpp>
|
||||
#include <godot_cpp/variant/utility_functions.hpp>
|
||||
|
||||
#include <cmath>
|
||||
#include <string>
|
||||
#include <list>
|
||||
|
||||
#include <godot_cpp/classes/mesh_instance3d.hpp>
|
||||
#include <godot_cpp/classes/os.hpp>
|
||||
#include <godot_cpp/classes/skin.hpp>
|
||||
#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;
|
||||
|
||||
namespace mtgodot {
|
||||
|
||||
namespace {
|
||||
struct ClipEntry {
|
||||
String path;
|
||||
uint64_t modified;
|
||||
size_t bytes;
|
||||
std::shared_ptr<const gr2::File> file;
|
||||
};
|
||||
// Immutable decoded clips are shared, not playback time/pose/event cursors.
|
||||
// Main-thread animation loading only; no Godot objects survive in this cache.
|
||||
std::list<ClipEntry> clip_cache;
|
||||
size_t clip_bytes = 0;
|
||||
uint64_t clip_hits = 0, clip_misses = 0;
|
||||
constexpr size_t CLIP_LIMIT = 32, CLIP_BYTE_LIMIT = 64 * 1024 * 1024;
|
||||
struct MsaEntry {
|
||||
String path;
|
||||
uint64_t modified;
|
||||
fmt::Msa metadata;
|
||||
};
|
||||
std::list<MsaEntry> msa_cache;
|
||||
uint64_t msa_hits = 0, msa_misses = 0;
|
||||
// Keep events immutable in the cache; reload copies them into each actor.
|
||||
bool cached_msa(const String &path, fmt::Msa &metadata, std::string *error) {
|
||||
const uint64_t modified = FileAccess::get_modified_time(path);
|
||||
for (auto it = msa_cache.begin(); it != msa_cache.end(); ++it) {
|
||||
if (it->path != path) continue;
|
||||
if (it->modified == modified) {
|
||||
++msa_hits;
|
||||
metadata = it->metadata;
|
||||
msa_cache.splice(msa_cache.begin(), msa_cache, it);
|
||||
return true;
|
||||
}
|
||||
msa_cache.erase(it);
|
||||
break;
|
||||
}
|
||||
++msa_misses;
|
||||
if (!fmt::parse_msa_file(std::string(path.utf8().get_data()), metadata, error)) return false;
|
||||
if (msa_cache.size() >= 128) msa_cache.pop_back();
|
||||
msa_cache.push_front({path, modified, metadata});
|
||||
return true;
|
||||
}
|
||||
std::shared_ptr<const gr2::File> cached_clip(const String &path, gr2::LoadError *err) {
|
||||
uint64_t modified = FileAccess::get_modified_time(path);
|
||||
for (auto it = clip_cache.begin(); it != clip_cache.end(); ++it) {
|
||||
if (it->path != path) continue;
|
||||
if (it->modified == modified) {
|
||||
++clip_hits;
|
||||
clip_cache.splice(clip_cache.begin(), clip_cache, it);
|
||||
return clip_cache.front().file;
|
||||
}
|
||||
clip_bytes -= it->bytes;
|
||||
clip_cache.erase(it);
|
||||
break;
|
||||
}
|
||||
++clip_misses;
|
||||
auto loaded = gr2_from_file(path, err);
|
||||
if (!loaded) return {};
|
||||
size_t bytes = 0;
|
||||
for (size_t i = 0; i < loaded->sections().size(); ++i) bytes += loaded->section_bytes(i).size();
|
||||
auto file = std::make_shared<const gr2::File>(std::move(*loaded));
|
||||
if (bytes <= CLIP_BYTE_LIMIT) {
|
||||
while (!clip_cache.empty() && (clip_cache.size() >= CLIP_LIMIT || clip_bytes + bytes > CLIP_BYTE_LIMIT)) {
|
||||
clip_bytes -= clip_cache.back().bytes;
|
||||
clip_cache.pop_back();
|
||||
}
|
||||
clip_cache.push_front({path, modified, bytes, file});
|
||||
clip_bytes += bytes;
|
||||
}
|
||||
return file;
|
||||
}
|
||||
}
|
||||
|
||||
Dictionary Metin2AnimPlayer::get_clip_cache_stats() {
|
||||
Dictionary d;
|
||||
d["hits"] = clip_hits;
|
||||
d["misses"] = clip_misses;
|
||||
d["entries"] = static_cast<int64_t>(clip_cache.size());
|
||||
d["section_bytes"] = static_cast<int64_t>(clip_bytes);
|
||||
d["msa_hits"] = msa_hits;
|
||||
d["msa_misses"] = msa_misses;
|
||||
d["msa_entries"] = static_cast<int64_t>(msa_cache.size());
|
||||
return d;
|
||||
}
|
||||
void Metin2AnimPlayer::clear_clip_cache() {
|
||||
clip_cache.clear();
|
||||
clip_bytes = 0;
|
||||
clip_hits = clip_misses = 0;
|
||||
msa_cache.clear();
|
||||
msa_hits = msa_misses = 0;
|
||||
}
|
||||
|
||||
Metin2AnimPlayer::Metin2AnimPlayer() {}
|
||||
Metin2AnimPlayer::~Metin2AnimPlayer() = default;
|
||||
|
||||
void Metin2AnimPlayer::_bind_methods() {
|
||||
ClassDB::bind_static_method("Metin2AnimPlayer", D_METHOD("get_clip_cache_stats"), &Metin2AnimPlayer::get_clip_cache_stats);
|
||||
ClassDB::bind_static_method("Metin2AnimPlayer", D_METHOD("clear_clip_cache"), &Metin2AnimPlayer::clear_clip_cache);
|
||||
ClassDB::bind_method(D_METHOD("set_anim_path", "path"), &Metin2AnimPlayer::set_anim_path);
|
||||
ClassDB::bind_method(D_METHOD("get_anim_path"), &Metin2AnimPlayer::get_anim_path);
|
||||
ClassDB::bind_method(D_METHOD("set_model_path", "path"), &Metin2AnimPlayer::set_model_path);
|
||||
ClassDB::bind_method(D_METHOD("get_model_path"), &Metin2AnimPlayer::get_model_path);
|
||||
ClassDB::bind_method(D_METHOD("set_playing", "v"), &Metin2AnimPlayer::set_playing);
|
||||
ClassDB::bind_method(D_METHOD("get_playing"), &Metin2AnimPlayer::get_playing);
|
||||
ClassDB::bind_method(D_METHOD("set_loop", "v"), &Metin2AnimPlayer::set_loop);
|
||||
ClassDB::bind_method(D_METHOD("get_loop"), &Metin2AnimPlayer::get_loop);
|
||||
ClassDB::bind_method(D_METHOD("set_time_scale", "s"), &Metin2AnimPlayer::set_time_scale);
|
||||
ClassDB::bind_method(D_METHOD("get_time_scale"), &Metin2AnimPlayer::get_time_scale);
|
||||
ClassDB::bind_method(D_METHOD("set_motion_loop_count", "count"), &Metin2AnimPlayer::set_motion_loop_count);
|
||||
ClassDB::bind_method(D_METHOD("get_motion_loop_count"), &Metin2AnimPlayer::get_motion_loop_count);
|
||||
ClassDB::bind_method(D_METHOD("set_blend_time", "s"), &Metin2AnimPlayer::set_blend_time);
|
||||
ClassDB::bind_method(D_METHOD("get_blend_time"), &Metin2AnimPlayer::get_blend_time);
|
||||
ClassDB::bind_method(D_METHOD("set_time", "t"), &Metin2AnimPlayer::set_time);
|
||||
ClassDB::bind_method(D_METHOD("get_time"), &Metin2AnimPlayer::get_time);
|
||||
ClassDB::bind_method(D_METHOD("get_duration"), &Metin2AnimPlayer::get_duration);
|
||||
ClassDB::bind_method(D_METHOD("reload"), &Metin2AnimPlayer::reload);
|
||||
ClassDB::bind_method(D_METHOD("get_info"), &Metin2AnimPlayer::get_info);
|
||||
ClassDB::bind_method(D_METHOD("selfcheck", "samples"), &Metin2AnimPlayer::selfcheck, DEFVAL(24));
|
||||
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_effect_bone_pose", "bone"), &Metin2AnimPlayer::get_effect_bone_pose);
|
||||
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",
|
||||
PropertyInfo(Variant::INT, "type"),
|
||||
PropertyInfo(Variant::STRING, "effect"),
|
||||
PropertyInfo(Variant::STRING, "sound"),
|
||||
PropertyInfo(Variant::VECTOR3, "pos")));
|
||||
ADD_SIGNAL(MethodInfo("playback_finished"));
|
||||
ADD_SIGNAL(MethodInfo("motion_event_detailed", PropertyInfo(Variant::DICTIONARY, "event")));
|
||||
|
||||
ADD_PROPERTY(PropertyInfo(Variant::STRING, "anim_path", PROPERTY_HINT_GLOBAL_FILE, "*.gr2,*.msa"),
|
||||
"set_anim_path", "get_anim_path");
|
||||
ADD_PROPERTY(PropertyInfo(Variant::NODE_PATH, "model_path", PROPERTY_HINT_NODE_PATH_VALID_TYPES, "Node3D"),
|
||||
"set_model_path", "get_model_path");
|
||||
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "playing"), "set_playing", "get_playing");
|
||||
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "loop"), "set_loop", "get_loop");
|
||||
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "time_scale", PROPERTY_HINT_RANGE, "0,4,0.01"),
|
||||
"set_time_scale", "get_time_scale");
|
||||
ADD_PROPERTY(PropertyInfo(Variant::INT, "motion_loop_count"),
|
||||
"set_motion_loop_count", "get_motion_loop_count");
|
||||
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "blend_time", PROPERTY_HINT_RANGE, "0,1,0.01"),
|
||||
"set_blend_time", "get_blend_time");
|
||||
}
|
||||
|
||||
void Metin2AnimPlayer::set_anim_path(const String &p) {
|
||||
if (p == anim_path) {
|
||||
if (!playing) {
|
||||
time = 0.0;
|
||||
prev_time = 0.0;
|
||||
playing = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// SetMotionLoopCount belongs to the newly bound motion. Do not leak a
|
||||
// previous skill's override into a normal walk/attack/action clip.
|
||||
motion_loop_override = false;
|
||||
motion_loop_count = 0;
|
||||
// Start a crossfade from the clip that is currently playing.
|
||||
if (is_inside_tree() && anim_file && blend_time > 0.0) {
|
||||
prev_anim_file = std::move(anim_file); // anim_file now empty; reload() refills it
|
||||
prev_anim_duration = duration;
|
||||
prev_anim_loop = loop;
|
||||
double pt = time;
|
||||
if (prev_anim_duration > 0.0) {
|
||||
if (prev_anim_loop) {
|
||||
pt = std::fmod(pt, prev_anim_duration);
|
||||
if (pt < 0.0) {
|
||||
pt += prev_anim_duration;
|
||||
}
|
||||
} else {
|
||||
pt = std::fmax(0.0, std::fmin(pt, prev_anim_duration));
|
||||
}
|
||||
}
|
||||
prev_anim_time = pt;
|
||||
blend_elapsed = 0.0;
|
||||
}
|
||||
anim_path = p;
|
||||
if (is_inside_tree()) {
|
||||
reload();
|
||||
}
|
||||
}
|
||||
void Metin2AnimPlayer::set_motion_loop_count(int count) {
|
||||
if (count > 0) {
|
||||
motion_loop_override = true;
|
||||
motion_loop_count = count;
|
||||
} else {
|
||||
motion_loop_override = false;
|
||||
motion_loop_count = msa_metadata.has_loop_data ? msa_metadata.motion_loop_count : 0;
|
||||
}
|
||||
}
|
||||
void Metin2AnimPlayer::set_model_path(const NodePath &p) {
|
||||
model_path = p;
|
||||
}
|
||||
void Metin2AnimPlayer::set_time(double t) {
|
||||
time = (!loop && duration > 0.0) ? std::fmax(0.0, std::fmin(t, duration)) : t;
|
||||
prev_time = time; // scrubbing must not replay every event since the old cursor
|
||||
if (is_inside_tree()) {
|
||||
apply_pose(time);
|
||||
}
|
||||
}
|
||||
|
||||
Metin2Model *Metin2AnimPlayer::resolve_model() const {
|
||||
if (model_path.is_empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
Node *n = get_node_or_null(model_path);
|
||||
return Object::cast_to<Metin2Model>(n);
|
||||
}
|
||||
|
||||
// "d:\Ymir Work\pc\warrior\action\dance.gr2" -> "<root>/PC/ymir work/pc/warrior/action/dance.gr2"
|
||||
// where <root> is found by walking up from the .msa's own dir past "ymir work".
|
||||
// Falls back to <msa_dir>/<basename> (the anim gr2 is usually right next to it).
|
||||
static String strip_dpath(const String &raw) {
|
||||
String p = raw.replace("\\", "/");
|
||||
int k = p.to_lower().find("ymir work");
|
||||
return k >= 0 ? p.substr(k) : p; // "ymir work/pc/.../x.gr2"
|
||||
}
|
||||
|
||||
String Metin2AnimPlayer::resolve_anim_gr2(const String &spec, const fmt::Msa &m) {
|
||||
String low = spec.to_lower();
|
||||
if (!low.ends_with(".msa")) {
|
||||
return spec; // already a .gr2 path
|
||||
}
|
||||
const String motion = String(m.motion_gr2.c_str());
|
||||
const String base = motion.replace("\\", "/").get_file();
|
||||
const String msa_dir = spec.get_base_dir();
|
||||
|
||||
// 1. sibling of the .msa
|
||||
String cand = msa_dir.path_join(base);
|
||||
if (FileAccess::file_exists(cand)) {
|
||||
return cand;
|
||||
}
|
||||
// 2. <root>/PC/<ymir-work tail>, root = ancestor of the .msa above "ymir work"
|
||||
String tail = strip_dpath(motion); // "ymir work/pc/.../x.gr2"
|
||||
int ky = msa_dir.to_lower().find("ymir work");
|
||||
if (ky > 0) {
|
||||
String root = spec.substr(0, ky); // ".../assets/PC/"
|
||||
cand = root.path_join(tail.substr(String("ymir work/").length()));
|
||||
if (FileAccess::file_exists(cand)) {
|
||||
return cand;
|
||||
}
|
||||
cand = root.get_base_dir().path_join(tail); // ".../assets/" + "ymir work/..."
|
||||
if (FileAccess::file_exists(cand)) {
|
||||
return cand;
|
||||
}
|
||||
}
|
||||
UtilityFunctions::push_warning(String("[Metin2AnimPlayer] .msa motion gr2 not found: ") + motion +
|
||||
" (tried " + msa_dir.path_join(base) + ")");
|
||||
return cand;
|
||||
}
|
||||
|
||||
static Dictionary motion_event_dict(const fmt::MotionEvent &ev) {
|
||||
Dictionary d;
|
||||
d["type"] = ev.type;
|
||||
d["start_time"] = ev.start_time;
|
||||
d["duration_time"] = ev.duration_time;
|
||||
d["effect"] = String(ev.effect_file.c_str());
|
||||
d["sound"] = String(ev.sound_file.c_str());
|
||||
d["pos"] = Vector3(ev.position[0], ev.position[1], ev.position[2]);
|
||||
d["bone"] = String(ev.attaching_bone.c_str());
|
||||
d["attaching"] = ev.attaching;
|
||||
d["following"] = ev.following;
|
||||
d["independent"] = ev.independent;
|
||||
d["fishing_effect"] = ev.fishing_effect;
|
||||
d["fly_file"] = String(ev.fly_file.c_str());
|
||||
d["fly_bone"] = String(ev.fly_attaching_bone.c_str());
|
||||
d["fly_attaching"] = ev.fly_attaching;
|
||||
d["fly_pos"] = Vector3(ev.fly_position[0], ev.fly_position[1], ev.fly_position[2]);
|
||||
d["power"] = ev.power;
|
||||
d["affecting_range"] = ev.affecting_range;
|
||||
d["enable_hit_process"] = ev.enable_hit_process;
|
||||
d["attack_type"] = ev.attack_type;
|
||||
d["hitting_type"] = ev.hitting_type;
|
||||
d["stiffen_time"] = ev.stiffen_time;
|
||||
d["invisible_time"] = ev.invisible_time;
|
||||
d["external_force"] = ev.external_force;
|
||||
d["hit_limit_count"] = ev.hit_limit_count;
|
||||
d["collision_type"] = ev.collision_type;
|
||||
Array spheres;
|
||||
for (const fmt::MotionEvent::CollisionSphere &sphere : ev.collision_spheres) {
|
||||
Dictionary sd;
|
||||
sd["radius"] = sphere.radius;
|
||||
sd["pos"] = Vector3(sphere.position[0], sphere.position[1], sphere.position[2]);
|
||||
spheres.push_back(sd);
|
||||
}
|
||||
d["collision_spheres"] = spheres;
|
||||
return d;
|
||||
}
|
||||
|
||||
Dictionary Metin2AnimPlayer::get_effect_bone_pose(const String &bone) {
|
||||
Dictionary result;
|
||||
Metin2Model *model = resolve_model();
|
||||
const gr2::Skeleton *sk = model ? model->gr2_skeleton() : nullptr;
|
||||
if (!sk || !anim_file) return result;
|
||||
for (size_t i = 0; i < sk->bones.size() && i < world_buf.size(); ++i) {
|
||||
if (String(sk->bones[i].name.c_str()) == bone) {
|
||||
result["transform"] = gr2_to_godot(world_buf[i]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void Metin2AnimPlayer::emit_motion_event(const fmt::MotionEvent &ev) {
|
||||
emit_signal("motion_event", ev.type, String(ev.effect_file.c_str()),
|
||||
String(ev.sound_file.c_str()), Vector3(ev.position[0], ev.position[1], ev.position[2]));
|
||||
emit_signal("motion_event_detailed", motion_event_dict(ev));
|
||||
}
|
||||
|
||||
Array Metin2AnimPlayer::get_events() const {
|
||||
Array a;
|
||||
for (const fmt::MotionEvent &ev : events) {
|
||||
a.push_back(motion_event_dict(ev));
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
Dictionary Metin2AnimPlayer::get_loop_data() const {
|
||||
Dictionary d;
|
||||
d["present"] = msa_metadata.has_loop_data;
|
||||
d["count"] = msa_metadata.motion_loop_count;
|
||||
d["cancel_enable"] = msa_metadata.loop_cancel_enable;
|
||||
d["start_time"] = msa_metadata.loop_start_time;
|
||||
d["end_time"] = msa_metadata.loop_end_time;
|
||||
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();
|
||||
}
|
||||
|
||||
void Metin2AnimPlayer::reload() {
|
||||
anim_file.reset();
|
||||
duration = 0.0;
|
||||
time = 0.0;
|
||||
prev_time = 0.0;
|
||||
next_event = 0;
|
||||
accumulation = Vector3();
|
||||
events.clear();
|
||||
msa_metadata = fmt::Msa{};
|
||||
last_info = "";
|
||||
playing = true;
|
||||
if (anim_path.is_empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// .msa -> resolve the real motion .gr2 + pull accumulation / events.
|
||||
const bool diagnostics = OS::get_singleton()->get_environment("MTGODOT_ANIM_DIAGNOSTICS") == "1";
|
||||
String gr2_spec = anim_path;
|
||||
if (anim_path.to_lower().ends_with(".msa")) {
|
||||
fmt::Msa m;
|
||||
std::string e;
|
||||
if (cached_msa(anim_path, m, &e)) {
|
||||
msa_metadata = m;
|
||||
accumulation = Vector3(m.accumulation[0], m.accumulation[1], m.accumulation[2]);
|
||||
events = m.events;
|
||||
// Reuse the metadata just parsed; path resolution must not parse it again.
|
||||
gr2_spec = resolve_anim_gr2(anim_path, m);
|
||||
if (diagnostics) UtilityFunctions::print(vformat("[Metin2AnimPlayer] .msa -> %s accum=(%.2f %.2f %.2f) events=%d loopdata=%s",
|
||||
gr2_spec, accumulation.x, accumulation.y, accumulation.z, (int)events.size(),
|
||||
m.has_loop_data ? vformat("%d x [%.3f,%.3f]", m.motion_loop_count,
|
||||
m.loop_start_time, m.loop_end_time) : String("none")));
|
||||
} else {
|
||||
UtilityFunctions::push_error(String("[Metin2AnimPlayer] .msa: ") + String(e.c_str()));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!motion_loop_override)
|
||||
motion_loop_count = msa_metadata.has_loop_data ? msa_metadata.motion_loop_count : 0;
|
||||
|
||||
gr2::LoadError err;
|
||||
auto f = cached_clip(gr2_spec, &err);
|
||||
if (!f) {
|
||||
UtilityFunctions::push_error(String("[Metin2AnimPlayer] anim load failed [") +
|
||||
String(err.stage.c_str()) + "]: " + String(err.message.c_str()));
|
||||
return;
|
||||
}
|
||||
const gr2::FileInfo &fi = f->file_info();
|
||||
if (fi.animations.empty()) {
|
||||
UtilityFunctions::push_error("[Metin2AnimPlayer] no animations in " + anim_path);
|
||||
return;
|
||||
}
|
||||
anim_file = std::move(f);
|
||||
const gr2::Animation &an = anim_file->file_info().animations[0];
|
||||
// 40250 CRaceMotionData::GetMotionDuration() is sourced from the MSA
|
||||
// MotionDuration field. GR2 contains sampled tracks, but its clip length
|
||||
// is not the motion-data authority used by actor timing, LoopData and
|
||||
// MotionEventData. Keep GR2 duration only for raw .gr2 paths or malformed
|
||||
// MSA files without a positive MotionDuration.
|
||||
duration = msa_metadata.duration > 0.0 ? msa_metadata.duration : an.duration;
|
||||
|
||||
// Diagnostic: how many anim tracks map to a model-skeleton bone by name?
|
||||
if (Metin2Model *model = diagnostics ? resolve_model() : nullptr) {
|
||||
if (const gr2::Skeleton *sk = model->gr2_skeleton()) {
|
||||
int matched = 0;
|
||||
godot::String unmatched;
|
||||
for (const auto &tr : an.tracks) {
|
||||
bool hit = false;
|
||||
for (const auto &b : sk->bones) {
|
||||
if (b.name == tr.bone_name) {
|
||||
hit = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hit) {
|
||||
++matched;
|
||||
} else if (unmatched.length() < 300) {
|
||||
unmatched += String(tr.bone_name.c_str()) + " ";
|
||||
}
|
||||
}
|
||||
if (diagnostics) {
|
||||
UtilityFunctions::print(vformat(
|
||||
"[Metin2AnimPlayer] track->bone match: %d/%d (skel bones=%d) unmatched: %s",
|
||||
matched, (int)an.tracks.size(), (int)sk->bones.size(), unmatched));
|
||||
}
|
||||
}
|
||||
}
|
||||
last_info = vformat("anim '%s' dur=%.3f tracks=%d anims=%d",
|
||||
String(an.name.c_str()), duration, (int)an.tracks.size(),
|
||||
(int)anim_file->file_info().animations.size());
|
||||
if (diagnostics) UtilityFunctions::print("[Metin2AnimPlayer] ", last_info);
|
||||
apply_pose(0.0);
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// mul4x3 lives in gr2_bridge.h now (shared with the weapon-attach grip compose).
|
||||
|
||||
// Blend two local bone transforms: slerp rotation, lerp translation and the
|
||||
// complete scale/shear basis (PARITY §2.9). Several warrior/sura armour bones
|
||||
// depend on authored shear, so it must survive the transition as well as steady
|
||||
// animation sampling.
|
||||
gr2::Mat4 blend_trs(const gr2::Mat4 &a, const gr2::Mat4 &b, float w) {
|
||||
godot::Basis ba(godot::Vector3(a[0], a[1], a[2]), godot::Vector3(a[4], a[5], a[6]),
|
||||
godot::Vector3(a[8], a[9], a[10]));
|
||||
godot::Basis bb(godot::Vector3(b[0], b[1], b[2]), godot::Vector3(b[4], b[5], b[6]),
|
||||
godot::Vector3(b[8], b[9], b[10]));
|
||||
const godot::Quaternion qa = ba.get_rotation_quaternion();
|
||||
const godot::Quaternion qb = bb.get_rotation_quaternion();
|
||||
godot::Quaternion q = qa.slerp(qb, w);
|
||||
// Preserve Granny's complete scale/shear matrix. Keeping only get_scale()
|
||||
// changes authored joints even at the endpoints and is especially visible in
|
||||
// hands and shoulders. For B = R*S, extract S and interpolate all 3x3 terms.
|
||||
const godot::Basis sa = godot::Basis(qa).inverse() * ba;
|
||||
const godot::Basis sb = godot::Basis(qb).inverse() * bb;
|
||||
godot::Basis ss;
|
||||
for (int row = 0; row < 3; ++row) {
|
||||
ss.rows[row] = sa.rows[row].lerp(sb.rows[row], w);
|
||||
}
|
||||
godot::Vector3 tt =
|
||||
godot::Vector3(a[12], a[13], a[14]).lerp(godot::Vector3(b[12], b[13], b[14]), w);
|
||||
const godot::Basis rb = godot::Basis(q) * ss;
|
||||
gr2::Mat4 o{};
|
||||
o[0] = rb.rows[0].x; o[1] = rb.rows[0].y; o[2] = rb.rows[0].z;
|
||||
o[4] = rb.rows[1].x; o[5] = rb.rows[1].y; o[6] = rb.rows[1].z;
|
||||
o[8] = rb.rows[2].x; o[9] = rb.rows[2].y; o[10] = rb.rows[2].z;
|
||||
o[12] = tt.x; o[13] = tt.y; o[14] = tt.z; o[15] = 1.0f;
|
||||
return o;
|
||||
}
|
||||
|
||||
// Sample animation tracks into the model skeleton's LOCAL bone domain. Granny
|
||||
// blends controls before BuildWorldPose; doing the crossfade after world-pose
|
||||
// accumulation independently interpolates every joint endpoint and temporarily
|
||||
// changes parent/child distances (visible as stretched or collapsed limbs when
|
||||
// wait/walk/run switches).
|
||||
void sample_local_pose(const gr2::Skeleton &sk, const gr2::Animation &an, float t,
|
||||
std::vector<gr2::Mat4> &local) {
|
||||
std::vector<gr2::Mat4> track_local;
|
||||
an.sample_local(t, track_local);
|
||||
local.resize(sk.bones.size());
|
||||
for (size_t i = 0; i < sk.bones.size(); ++i) {
|
||||
local[i] = sk.bones[i].local_transform;
|
||||
}
|
||||
for (size_t ti = 0; ti < an.tracks.size() && ti < track_local.size(); ++ti) {
|
||||
for (size_t bi = 0; bi < sk.bones.size(); ++bi) {
|
||||
if (sk.bones[bi].name == an.tracks[ti].bone_name) {
|
||||
local[bi] = track_local[ti];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void accumulate_local_pose(const gr2::Skeleton &sk, const std::vector<gr2::Mat4> &local,
|
||||
const gr2::Mat4 &root_offset, std::vector<gr2::Mat4> &world,
|
||||
std::vector<gr2::Mat4> &skin) {
|
||||
static const gr2::Mat4 I{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };
|
||||
const size_t n = sk.bones.size();
|
||||
world.assign(n, I);
|
||||
skin.assign(n, I);
|
||||
for (size_t i = 0; i < n; ++i) {
|
||||
const int parent = sk.bones[i].parent;
|
||||
const gr2::Mat4 &parent_world =
|
||||
(parent < 0 || static_cast<size_t>(parent) >= i) ? root_offset : world[parent];
|
||||
world[i] = mul4x3(i < local.size() ? local[i] : sk.bones[i].local_transform,
|
||||
parent_world);
|
||||
skin[i] = mul4x3(sk.bones[i].inverse_world, world[i]);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void Metin2AnimPlayer::apply_pose(double t) {
|
||||
if (!anim_file) {
|
||||
return;
|
||||
}
|
||||
Metin2Model *model = resolve_model();
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
const gr2::Skeleton *sk = model->gr2_skeleton();
|
||||
Skeleton3D *skel = model->skeleton_node();
|
||||
if (!sk || !skel) {
|
||||
return;
|
||||
}
|
||||
const gr2::Animation &an = anim_file->file_info().animations[0];
|
||||
|
||||
double tt = t;
|
||||
if (duration > 0.0 && loop) {
|
||||
tt = std::fmod(t, duration);
|
||||
if (tt < 0.0) {
|
||||
tt += duration;
|
||||
}
|
||||
} else if (duration > 0.0) {
|
||||
tt = std::fmax(0.0, std::fmin(t, duration));
|
||||
}
|
||||
|
||||
// Offset4x4 = identity, NOT the model's exported InitialPlacement.
|
||||
//
|
||||
// gr2::sample_pose defaults to sk.initial_placement because that is what the
|
||||
// oracle passes (GrannyGetModelInitialPlacement4x4) and what the bind pose /
|
||||
// InverseWorldTransform pair is self-consistent with. Metin2's animation .gr2
|
||||
// carries the root bone's pelvis height inside its own track, so composing it
|
||||
// onto InitialPlacement counts that placement twice and lifts the whole actor
|
||||
// by one pelvis height (warrior 99.4 cm, assassin 92.6, sura 103.7, shaman
|
||||
// 95.5 — exactly each model's IP.z). That was the "人物悬空" report.
|
||||
// Measured with tools/rendering foot probe: with identity the lowest skinned
|
||||
// vertex sits within a few mm of z=0 for wait/walk on all four classes, and
|
||||
// only leaves the ground during run's airborne frames.
|
||||
static const gr2::Mat4 kNoOffset{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };
|
||||
bool pose_ready = false;
|
||||
|
||||
// Crossfade in LOCAL bone space, then accumulate the hierarchy and rebuild
|
||||
// skin_buf = invWorld · world. Blending world transforms made bone endpoints
|
||||
// take independent straight-line paths, shortening limbs around the midpoint.
|
||||
if (prev_anim_file && blend_time > 0.0 && blend_elapsed < blend_time) {
|
||||
const gr2::FileInfo &pfi = prev_anim_file->file_info();
|
||||
if (!pfi.animations.empty()) {
|
||||
sample_local_pose(*sk, an, (float)tt, local_buf);
|
||||
sample_local_pose(*sk, pfi.animations[0], (float)prev_anim_time, prev_local_buf);
|
||||
double w = blend_elapsed / blend_time;
|
||||
// ease-in on the incoming clip, matching the client's
|
||||
// GrannySetControlEaseInCurve(t0,t1, 0,0,1,1) Hermite (p0=0,m0=0,
|
||||
// p1=1,m1=1) -> h(w) = 2w^2 - w^3. Flat start, slope-1 finish.
|
||||
w = w * w * (2.0 - w);
|
||||
const size_t n = std::min(local_buf.size(), prev_local_buf.size());
|
||||
blend_local_buf.resize(local_buf.size());
|
||||
for (size_t i = 0; i < n; ++i) {
|
||||
blend_local_buf[i] = blend_trs(prev_local_buf[i], local_buf[i], (float)w);
|
||||
}
|
||||
for (size_t i = n; i < local_buf.size(); ++i) {
|
||||
blend_local_buf[i] = local_buf[i];
|
||||
}
|
||||
accumulate_local_pose(*sk, blend_local_buf, kNoOffset, world_buf, skin_buf);
|
||||
pose_ready = true;
|
||||
}
|
||||
}
|
||||
if (!pose_ready) {
|
||||
gr2::sample_pose(*sk, an, (float)tt, world_buf, skin_buf, &kNoOffset);
|
||||
}
|
||||
|
||||
// Both skinning paths apply libgr2's per-bone deformer matrices
|
||||
// (skin_buf = Σ w · invWorld · world) with the FULL affine — shear kept.
|
||||
// default : CPU LBS, ArrayMesh rebuilt each frame.
|
||||
// MTGODOT_GPUSKIN=1 : LBS in a custom vertex shader (bone matrices in a
|
||||
// float texture, no Skeleton3D). See m2_material SRC_SKIN.
|
||||
// The old Skeleton3D::set_bone_pose route is gone — Godot's built-in skinning
|
||||
// orthonormalizes the bone matrix and drops the shear (docs/MIDREVIEW.md §4).
|
||||
static const String kZero("0");
|
||||
static const String kEnvName("MTGODOT_GPUSKIN");
|
||||
String env = godot::OS::get_singleton()->get_environment(kEnvName);
|
||||
// Default to GPU skinning (true) unless explicitly set to "0"
|
||||
if (env != kZero) {
|
||||
model->enable_gpu_skin(true);
|
||||
model->gpu_skin(skin_buf);
|
||||
} else {
|
||||
model->enable_cpu_skin(true);
|
||||
model->cpu_skin(skin_buf);
|
||||
}
|
||||
// Rigid weapon follows equip_right_hand's animated world transform (PARITY §2.1).
|
||||
model->update_weapon_pose(world_buf);
|
||||
}
|
||||
|
||||
void Metin2AnimPlayer::_process(double delta) {
|
||||
if (!playing || !anim_file) {
|
||||
return;
|
||||
}
|
||||
if (prev_anim_file) {
|
||||
blend_elapsed += delta; // real seconds, independent of time_scale
|
||||
if (blend_elapsed >= blend_time) {
|
||||
prev_anim_file.reset();
|
||||
}
|
||||
}
|
||||
double next = time + delta * time_scale;
|
||||
// 40250 does not loop the whole clip. For a motion with LoopData it keeps
|
||||
// the lead-in and tail, and only rewinds LoopStartTime..LoopEndTime while
|
||||
// the actor's remaining loop count is > 1 (or -1 for infinite looping).
|
||||
// The legacy actor resets to LoopStartTime at the first frame after the
|
||||
// loop end, so the frame remainder is intentionally discarded here too.
|
||||
const bool has_segment_loop = !loop && msa_metadata.has_loop_data &&
|
||||
motion_loop_count != 0 &&
|
||||
(motion_loop_count == -1 || motion_loop_count > 1) &&
|
||||
msa_metadata.loop_end_time > msa_metadata.loop_start_time &&
|
||||
msa_metadata.loop_end_time <= duration;
|
||||
if (has_segment_loop && next > msa_metadata.loop_end_time) {
|
||||
dispatch_events(time, msa_metadata.loop_end_time);
|
||||
time = msa_metadata.loop_start_time;
|
||||
if (motion_loop_count > 0)
|
||||
--motion_loop_count;
|
||||
apply_pose(time);
|
||||
// ActorInstance resets dwcurFrame to LoopStartTime and processes
|
||||
// that frame immediately. Without an inclusive point dispatch,
|
||||
// events authored exactly at the loop boundary never fire.
|
||||
dispatch_events(time, time, true);
|
||||
prev_time = time;
|
||||
return;
|
||||
}
|
||||
if (!loop && duration > 0.0 && next >= duration) {
|
||||
next = duration;
|
||||
}
|
||||
time = next;
|
||||
apply_pose(time);
|
||||
dispatch_events(prev_time, time);
|
||||
prev_time = time;
|
||||
if (!loop && duration > 0.0 && time >= duration) {
|
||||
playing = false;
|
||||
emit_signal("playback_finished");
|
||||
}
|
||||
}
|
||||
|
||||
// Emit `motion_event` for every .msa event whose start_time falls in (from, to],
|
||||
// handling loop wrap-around within one clip.
|
||||
void Metin2AnimPlayer::dispatch_events(double from, double to, bool include_from) {
|
||||
if (events.empty() || duration <= 0.0) {
|
||||
return;
|
||||
}
|
||||
const double epsilon = 1e-9;
|
||||
if (to < from - epsilon) {
|
||||
return;
|
||||
}
|
||||
if (!loop) {
|
||||
double a = std::fmax(0.0, std::fmin(from, duration));
|
||||
double b = std::fmax(0.0, std::fmin(to, duration));
|
||||
for (const fmt::MotionEvent &ev : events) {
|
||||
const bool at_inclusive_start = include_from &&
|
||||
std::fabs(double(ev.start_time) - a) <= epsilon;
|
||||
// The first 40250 motion frame is frame 0, not frame 1. The
|
||||
// normal interval remains half-open on the left so a regular
|
||||
// frame cannot replay an event on every tick.
|
||||
const bool initial_zero = !include_from && from <= epsilon &&
|
||||
to > from + epsilon && std::fabs(double(ev.start_time)) <= epsilon;
|
||||
if (at_inclusive_start || initial_zero ||
|
||||
(a < ev.start_time && ev.start_time <= b)) {
|
||||
emit_motion_event(ev);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Walk each clip segment independently. The old single-wrap expression
|
||||
// could emit at most one copy of an event when time_scale/delta crossed
|
||||
// two or more full durations; ActorInstance processes every frame and
|
||||
// therefore reaches every crossed MotionEventData entry.
|
||||
double cursor = from;
|
||||
double remaining = to - from;
|
||||
if (remaining <= epsilon) {
|
||||
if (!include_from) {
|
||||
return;
|
||||
}
|
||||
double point = std::fmod(cursor, duration);
|
||||
if (point < 0) point += duration;
|
||||
for (const fmt::MotionEvent &ev : events) {
|
||||
if (std::fabs(double(ev.start_time) - point) <= epsilon) {
|
||||
emit_motion_event(ev);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
while (remaining > epsilon) {
|
||||
double a = std::fmod(cursor, duration);
|
||||
if (a < 0) a += duration;
|
||||
double segment = std::fmin(remaining, duration - a);
|
||||
if (segment <= epsilon) {
|
||||
cursor += duration;
|
||||
continue;
|
||||
}
|
||||
double b = a + segment;
|
||||
const bool include_segment_start = a <= epsilon;
|
||||
for (const fmt::MotionEvent &ev : events) {
|
||||
const bool at_segment_start = include_segment_start &&
|
||||
std::fabs(double(ev.start_time) - a) <= epsilon;
|
||||
const bool crossed = a < ev.start_time && ev.start_time <= b;
|
||||
if (at_segment_start || crossed) {
|
||||
emit_motion_event(ev);
|
||||
}
|
||||
}
|
||||
cursor += segment;
|
||||
remaining -= segment;
|
||||
}
|
||||
}
|
||||
|
||||
String Metin2AnimPlayer::selfcheck(int samples) {
|
||||
if (!anim_file) {
|
||||
return "no anim loaded";
|
||||
}
|
||||
Metin2Model *model = resolve_model();
|
||||
const gr2::Skeleton *sk = model ? model->gr2_skeleton() : nullptr;
|
||||
if (!sk) {
|
||||
return "no model/skeleton";
|
||||
}
|
||||
const auto &anims = anim_file->file_info().animations;
|
||||
int total = 0;
|
||||
int nan_hits = 0;
|
||||
std::vector<gr2::Mat4> w, s;
|
||||
for (const auto &an : anims) {
|
||||
double dur = an.duration > 0.0 ? an.duration : 1.0;
|
||||
for (int k = 0; k <= samples; ++k) {
|
||||
float tt = (float)(dur * k / samples);
|
||||
gr2::sample_pose(*sk, an, tt, w, s);
|
||||
for (const auto &mtx : w) {
|
||||
for (float v : mtx) {
|
||||
++total;
|
||||
if (std::isnan(v) || std::isinf(v)) {
|
||||
++nan_hits;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return vformat("selfcheck: anims=%d samples/anim=%d floats=%d NaN/Inf=%d",
|
||||
(int)anims.size(), samples + 1, total, nan_hits);
|
||||
}
|
||||
|
||||
} // namespace mtgodot
|
||||
@@ -1,140 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <godot_cpp/classes/node3d.hpp>
|
||||
#include <godot_cpp/variant/dictionary.hpp>
|
||||
#include <godot_cpp/variant/node_path.hpp>
|
||||
#include <godot_cpp/variant/string.hpp>
|
||||
#include <godot_cpp/variant/vector3.hpp>
|
||||
|
||||
#include <gr2/gr2.h>
|
||||
#include <msa.h>
|
||||
|
||||
#include <optional>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
namespace mtgodot {
|
||||
|
||||
class Metin2Model;
|
||||
|
||||
// Drives a Metin2Model each frame by sampling a (possibly separate) animation
|
||||
// .gr2 with libgr2, then skinning it with the resulting matrices:
|
||||
//
|
||||
// sample_pose(model.skeleton, anim, t) -> world[i], skin[i]
|
||||
// skin[i] = inverse_world[i] * world[i] (gr2 deformer matrix per bone)
|
||||
//
|
||||
// The skinning itself is done in Metin2Model (NOT via Skeleton3D — Godot's
|
||||
// built-in skinning orthonormalizes the bone matrix and drops the shear Granny
|
||||
// bakes onto some rig bones; see docs/MIDREVIEW.md §4):
|
||||
// - default : cpu_skin(skin[]) — CPU LBS, ArrayMesh rebuilt/frame
|
||||
// - MTGODOT_GPUSKIN=1 : gpu_skin(skin[]) — full 4x3 matrices in a float
|
||||
// texture, LBS in the SRC_SKIN vertex shader
|
||||
class Metin2AnimPlayer : public godot::Node3D {
|
||||
GDCLASS(Metin2AnimPlayer, godot::Node3D)
|
||||
|
||||
public:
|
||||
Metin2AnimPlayer();
|
||||
~Metin2AnimPlayer() override;
|
||||
|
||||
void _ready() override;
|
||||
void _process(double delta) override;
|
||||
|
||||
void set_anim_path(const godot::String &p);
|
||||
godot::String get_anim_path() const { return anim_path; }
|
||||
|
||||
void set_model_path(const godot::NodePath &p);
|
||||
godot::NodePath get_model_path() const { return model_path; }
|
||||
|
||||
void set_playing(bool v) { playing = v; }
|
||||
bool get_playing() const { return playing; }
|
||||
void set_loop(bool v) { loop = v; }
|
||||
bool get_loop() const { return loop; }
|
||||
|
||||
void set_time_scale(double s) { time_scale = s; }
|
||||
double get_time_scale() const { return time_scale; }
|
||||
|
||||
// 40250 CActorInstance::SetMotionLoopCount. A positive value overrides the
|
||||
// current .msa LoopData count; zero restores the resource-defined count.
|
||||
void set_motion_loop_count(int count);
|
||||
int get_motion_loop_count() const { return motion_loop_count; }
|
||||
|
||||
// Crossfade duration (s) applied when anim_path changes while a clip is
|
||||
// already playing (PARITY §2.9). 0 = hard cut (old behaviour).
|
||||
void set_blend_time(double s) { blend_time = s < 0.0 ? 0.0 : s; }
|
||||
double get_blend_time() const { return blend_time; }
|
||||
|
||||
void set_time(double t);
|
||||
double get_time() const { return time; }
|
||||
|
||||
double get_duration() const { return duration; }
|
||||
void reload();
|
||||
godot::String get_info() const { return last_info; }
|
||||
|
||||
// .msa metadata (empty / zero when anim_path is a raw .gr2).
|
||||
godot::Vector3 get_accumulation() const { return accumulation; }
|
||||
godot::Array get_events() const; // detailed MotionEvent dictionaries, including type-specific payloads
|
||||
// Full affine pose in model GR2 coordinates, shared by CPU/GPU and blending.
|
||||
godot::Dictionary get_effect_bone_pose(const godot::String &bone);
|
||||
godot::Dictionary get_loop_data() const;
|
||||
godot::Dictionary get_motion_data() const; // {duration,next_combo,attack_start_time,...}
|
||||
static godot::Dictionary get_clip_cache_stats();
|
||||
static void clear_clip_cache();
|
||||
|
||||
// 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);
|
||||
|
||||
protected:
|
||||
static void _bind_methods();
|
||||
|
||||
private:
|
||||
void emit_motion_event(const fmt::MotionEvent &ev);
|
||||
godot::String anim_path;
|
||||
godot::NodePath model_path;
|
||||
bool playing = true;
|
||||
bool loop = true; // whole-clip playback mode; independent from .msa LoopData
|
||||
double time_scale = 1.0;
|
||||
int motion_loop_count = 0;
|
||||
bool motion_loop_override = false;
|
||||
double time = 0.0;
|
||||
double duration = 0.0;
|
||||
godot::String last_info;
|
||||
|
||||
std::shared_ptr<const gr2::File> anim_file;
|
||||
std::vector<gr2::Mat4> world_buf;
|
||||
std::vector<gr2::Mat4> skin_buf;
|
||||
|
||||
// Crossfade state: the outgoing clip is kept and sampled at a frozen time,
|
||||
// its pose blended into the incoming clip for `blend_time` seconds.
|
||||
double blend_time = 0.15;
|
||||
std::shared_ptr<const gr2::File> prev_anim_file;
|
||||
double prev_anim_time = 0.0;
|
||||
double prev_anim_duration = 0.0;
|
||||
bool prev_anim_loop = true;
|
||||
double blend_elapsed = 0.0;
|
||||
// Crossfades operate before hierarchy accumulation so parent/child lengths
|
||||
// remain stable while switching locomotion clips.
|
||||
std::vector<gr2::Mat4> local_buf, prev_local_buf, blend_local_buf;
|
||||
|
||||
godot::Vector3 accumulation; // .msa Accumulation (root motion)
|
||||
std::vector<fmt::MotionEvent> events;
|
||||
fmt::Msa msa_metadata;
|
||||
int next_event = 0; // index into `events`, for _process dispatch
|
||||
double prev_time = 0.0;
|
||||
|
||||
Metin2Model *resolve_model() const;
|
||||
void apply_pose(double t);
|
||||
void dispatch_events(double from, double to, bool include_from = false);
|
||||
// .msa path -> real motion .gr2 path; passes plain .gr2 paths through.
|
||||
static godot::String resolve_anim_gr2(const godot::String &spec, const fmt::Msa &metadata);
|
||||
};
|
||||
|
||||
} // namespace mtgodot
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,277 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <godot_cpp/classes/array_mesh.hpp>
|
||||
#include <godot_cpp/classes/image.hpp>
|
||||
#include <godot_cpp/classes/image_texture.hpp>
|
||||
#include <godot_cpp/classes/node3d.hpp>
|
||||
#include <godot_cpp/classes/ref.hpp>
|
||||
#include <godot_cpp/templates/hash_map.hpp>
|
||||
#include <godot_cpp/variant/array.hpp>
|
||||
#include <godot_cpp/variant/aabb.hpp>
|
||||
#include <godot_cpp/variant/dictionary.hpp>
|
||||
#include <godot_cpp/variant/packed_string_array.hpp>
|
||||
#include <godot_cpp/variant/string.hpp>
|
||||
|
||||
#include <gr2/gr2.h>
|
||||
|
||||
#include "gr2_bridge.h"
|
||||
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
|
||||
namespace godot {
|
||||
class Skeleton3D;
|
||||
class MeshInstance3D;
|
||||
} // namespace godot
|
||||
|
||||
namespace mtgodot {
|
||||
|
||||
// Loads a Metin2 .gr2 and builds a Godot subtree:
|
||||
// Metin2Model (transform = Z-up/cm -> Y-up/m conversion)
|
||||
// └─ Skeleton3D (rest pose from gr2 bones)
|
||||
// └─ MeshInstance3D (ArrayMesh, one surface per gr2 mesh; skinned)
|
||||
//
|
||||
// M1: static bind-pose render. M2's Metin2AnimPlayer drives the Skeleton3D.
|
||||
class Metin2Model : public godot::Node3D {
|
||||
GDCLASS(Metin2Model, godot::Node3D)
|
||||
|
||||
public:
|
||||
Metin2Model();
|
||||
~Metin2Model() override;
|
||||
|
||||
void _ready() override;
|
||||
void _process(double delta) override;
|
||||
|
||||
// --- inspector properties ---
|
||||
// §2.10 LOD: loads <base>_lod_01/02/03.gr2 (same 75-bone skeleton, decimated
|
||||
// meshes) and swaps the rendered mesh by camera distance. `lod_distances` =
|
||||
// [d1,d2,d3]; >d3 -> LOD3. Skinning is unaffected (shared skeleton).
|
||||
void set_lod_enabled(bool v);
|
||||
bool get_lod_enabled() const { return lod_enabled; }
|
||||
void set_lod_distances(const godot::PackedFloat32Array &d);
|
||||
godot::PackedFloat32Array get_lod_distances() const { return lod_dist; }
|
||||
int get_lod_level() const { return lod_level; }
|
||||
void set_gr2_path(const godot::String &p);
|
||||
godot::String get_gr2_path() const { return gr2_path; }
|
||||
|
||||
void set_texture_dir(const godot::String &p);
|
||||
godot::String get_texture_dir() const { return texture_dir; }
|
||||
|
||||
void set_unit_scale(double s);
|
||||
double get_unit_scale() const { return unit_scale; }
|
||||
|
||||
void set_flip_z(bool v);
|
||||
bool get_flip_z() const { return flip_z; }
|
||||
|
||||
void set_flip_winding(bool v);
|
||||
bool get_flip_winding() const { return flip_winding; }
|
||||
|
||||
void set_material_mode(const godot::String &m);
|
||||
godot::String get_material_mode() const { return material_mode; }
|
||||
|
||||
void set_use_gr2_materials(bool v);
|
||||
bool get_use_gr2_materials() const { return use_gr2_materials; }
|
||||
|
||||
// Sphere-map specular power (PARITY §2.7 / BACKLOG B9). In the original client
|
||||
// this is per skin-part, driven by the equipped body-armor item_proto
|
||||
// `bSpecular / 100`; 0 = flat (the base body). No equipment layer here yet, so
|
||||
// this is a manual hook — 0 keeps output identical to before.
|
||||
void set_specular_power(double p);
|
||||
double get_specular_power() const { return specular_power; }
|
||||
|
||||
// Explicit per-surface texture path override (index -> absolute .dds path).
|
||||
void set_surface_texture(int surface, const godot::String &path);
|
||||
// Reference SetMaterialImagePointer(part, SourceSkin, TargetSkin): replace
|
||||
// every GR2 material whose texture basename matches source, rather than
|
||||
// assuming that an armour skin always belongs to surface 0.
|
||||
void set_skin_texture(const godot::String &source, const godot::String &target);
|
||||
void clear_skin_textures();
|
||||
|
||||
// When gr2_path is a .msm: [{index, model (abs .gr2), target_skin}, ...].
|
||||
godot::Array get_hair_options() const { return hair_options; }
|
||||
|
||||
// Attach a hair .gr2 whose skeleton bone names match the base. Its mesh is
|
||||
// merged into the CPU-skin path (skinned by the base skeleton's matrices,
|
||||
// bones remapped by name). "" detaches. GPU-skin path ignores hair for now.
|
||||
void set_hair_gr2(const godot::String &p);
|
||||
godot::String get_hair_gr2() const { return hair_gr2; }
|
||||
|
||||
// SourceSkin -> TargetSkin recolour (PARITY §2.4 / BACKLOG D3). The client's
|
||||
// `.msm` HairData ships one hair .gr2 with a `SourceSkin` (the texture baked
|
||||
// into the gr2 material) and a per-colour `TargetSkin` dds; `SetMaterialImage
|
||||
// Pointer(part, SourceSkin, load(TargetSkin))` swaps it. Here: when set, the
|
||||
// hair mesh uses this dds as its albedo instead of the gr2's sibling texture.
|
||||
// Absolute path (an entry's resolved `target_skin` from get_hair_options()).
|
||||
void set_hair_skin(const godot::String &p);
|
||||
godot::String get_hair_skin() const { return hair_skin; }
|
||||
|
||||
// Attach a rigid weapon .gr2 to a base-skeleton bone (PARITY §2.1 / BACKLOG
|
||||
// C5). The client links the weapon model instance to `equip_right_hand`
|
||||
// (`playersettingmodule.py`, warrior) and drives it with that bone's world
|
||||
// matrix (`ModelInstanceUpdate.cpp:148` GetBoneMatrixPointer). Here the weapon
|
||||
// mesh is a child MeshInstance3D whose transform = the bone's world pose from
|
||||
// gr2::sample_pose each frame. "" detaches. Path may be relative to the base
|
||||
// gr2 ("d:/ymir work/item/weapon/00040.gr2") or absolute.
|
||||
void set_weapon_gr2(const godot::String &p);
|
||||
godot::String get_weapon_gr2() const { return weapon_gr2; }
|
||||
void set_weapon_bone(const godot::String &b);
|
||||
godot::String get_weapon_bone() const { return weapon_bone; }
|
||||
// Off-hand shield (rigid, attaches to equip_left_hand like the weapon).
|
||||
void set_shield_gr2(const godot::String &p);
|
||||
godot::String get_shield_gr2() const { return shield_gr2; }
|
||||
void set_shield_bone(const godot::String &b);
|
||||
godot::String get_shield_bone() const { return shield_bone; }
|
||||
|
||||
// Fed by Metin2AnimPlayer after each gr2::sample_pose: base-skeleton world
|
||||
// matrices (world_pose output). Repositions attached rigid parts (weapon +
|
||||
// shield). No-op for a slot with no gr2 or an unresolved bone name.
|
||||
void update_weapon_pose(const std::vector<gr2::Mat4> &world_pose);
|
||||
|
||||
// Rebuild the subtree from the current properties.
|
||||
void reload();
|
||||
|
||||
// One-line summary (bones / meshes / verts / bounds).
|
||||
godot::String get_info() const;
|
||||
// Deformed mesh bounds in this node's local GR2 coordinate frame. Unlike
|
||||
// MeshInstance3D::get_aabb(), this follows the GPU shader pose as well.
|
||||
godot::AABB get_visual_aabb();
|
||||
godot::Dictionary get_fly_target_bounds();
|
||||
// Parent-space Y adjustment that places the bind-pose model's lowest point
|
||||
// on the actor origin. GR2 is Z-up/cm; this is converted to Godot Y/metres.
|
||||
double get_ground_offset() const { return ground_offset; }
|
||||
|
||||
// --- C++ accessors for Metin2AnimPlayer (same extension) ---
|
||||
const gr2::Skeleton *gr2_skeleton() const;
|
||||
godot::Skeleton3D *skeleton_node() const { return skel; }
|
||||
const gr2::FileInfo *gr2_fileinfo() const;
|
||||
// Latest full-affine deformer matrices. Initialized to bind pose on reload
|
||||
// and refreshed by either skinning path; attachments can share this pose.
|
||||
const std::vector<gr2::Mat4> ¤t_skin_matrices() const { return current_skin; }
|
||||
|
||||
// CPU linear-blend skinning: rewrite ArrayMesh vertex regions from the
|
||||
// per-bone deformer matrices (gr2::sample_pose's `skin` output). Debug/
|
||||
// reference path; bypasses Godot's Skeleton3D skinning entirely.
|
||||
void cpu_skin(const std::vector<gr2::Mat4> &skin);
|
||||
bool has_cpu_skin_mesh() const { return cpu_mesh.is_valid(); }
|
||||
void enable_cpu_skin(bool on);
|
||||
|
||||
// GPU linear-blend skinning: LBS in a custom vertex shader with the FULL
|
||||
// per-bone matrices uploaded as a float texture (no Skeleton3D -> shear kept).
|
||||
// Keeps the static build_mesh() output; just swaps materials + feeds the
|
||||
// bone texture each frame. MTGODOT_GPUSKIN=1 route.
|
||||
void enable_gpu_skin(bool on);
|
||||
void gpu_skin(const std::vector<gr2::Mat4> &skin);
|
||||
|
||||
// libgr2 sanity probe (kept from M0').
|
||||
godot::String probe_gr2(const godot::String &path) const;
|
||||
|
||||
static void clear_texture_cache();
|
||||
|
||||
protected:
|
||||
static void _bind_methods();
|
||||
|
||||
private:
|
||||
godot::String gr2_path;
|
||||
godot::String texture_dir;
|
||||
double unit_scale = 0.01;
|
||||
bool flip_z = false;
|
||||
bool flip_winding = false;
|
||||
|
||||
godot::String material_mode = "metin2"; // "metin2" (ShaderMaterial) | "standard"
|
||||
bool use_gr2_materials = true; // gr2 MaterialBindings -> texture (else: filename heuristic only)
|
||||
double specular_power = 0.0; // PARITY §2.7; 0 = disabled
|
||||
godot::Ref<godot::Texture2D> sphere_map; // shared sphere map, lazy-loaded
|
||||
godot::Ref<godot::Texture2D> _load_sphere_map(); // "ymir work/special/spheremap.jpg"
|
||||
godot::PackedStringArray surface_tex_override;
|
||||
godot::Dictionary skin_tex_override; // lowercase source basename -> target path
|
||||
godot::String resolved_gr2_dir; // dir of the actually-loaded .gr2 (for .msm)
|
||||
godot::Array hair_options; // from .msm HairData
|
||||
|
||||
godot::String hair_gr2; // attached hair .gr2 (or "")
|
||||
godot::String hair_skin; // TargetSkin dds override (or "")
|
||||
std::shared_ptr<std::optional<gr2::File>> hair_file; // loaded hair gr2
|
||||
std::vector<mtgodot::RenderPart> hair_parts;
|
||||
std::vector<int> hair_bone_remap; // hair skel idx -> base skel idx (by name)
|
||||
godot::Ref<godot::Material> hair_mat;
|
||||
godot::Ref<godot::ImageTexture> hair_tex; // resolved hair albedo (for GPU path)
|
||||
int gpu_base_surf = 0; // base surfaces before appended GPU hair
|
||||
void _load_hair();
|
||||
void _refresh_hair_mesh();
|
||||
void _build_gpu_mesh(); // base + remapped hair surfaces -> mi->mesh (GPU-skin path, §2.2)
|
||||
|
||||
godot::String weapon_gr2; // attached weapon .gr2 (or "")
|
||||
godot::String weapon_bone = "equip_right_hand"; // base-skeleton attach bone
|
||||
std::shared_ptr<std::optional<gr2::File>> weapon_file; // loaded weapon gr2
|
||||
godot::MeshInstance3D *weapon_mi = nullptr; // child of this node
|
||||
int weapon_bone_idx = -1; // base skeleton index of weapon_bone
|
||||
// Weapon root rigid-mesh composite (invWorld · local),
|
||||
// folded in before the parent hand matrix exactly as Granny does for an attached
|
||||
// model. This may contain a large translation which cancels an authored vertex
|
||||
// offset (notably 03150), so it must not be clamped.
|
||||
gr2::Mat4 weapon_pre{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };
|
||||
godot::String shield_gr2;
|
||||
godot::String shield_bone = "Bip01 L Hand"; // PC skeletons have no equip_left_hand
|
||||
std::shared_ptr<std::optional<gr2::File>> shield_file;
|
||||
godot::MeshInstance3D *shield_mi = nullptr;
|
||||
int shield_bone_idx = -1;
|
||||
gr2::Mat4 shield_pre{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };
|
||||
void _load_weapon();
|
||||
void _load_shield();
|
||||
// shared rigid-attach loader used by _load_weapon / _load_shield.
|
||||
void _load_attach(const godot::String &gr2_rel, const godot::String &bone_name,
|
||||
std::shared_ptr<std::optional<gr2::File>> &slot_file, godot::MeshInstance3D *&slot_mi,
|
||||
int &slot_bone_idx, gr2::Mat4 &slot_pre, const char *node_name, const char *label);
|
||||
|
||||
std::shared_ptr<std::optional<gr2::File>> file; // shared_ptr so accessor stays valid
|
||||
std::vector<gr2::MaterialInfo> materials;
|
||||
godot::Skeleton3D *skel = nullptr;
|
||||
godot::MeshInstance3D *mi = nullptr;
|
||||
godot::String last_info;
|
||||
double ground_offset = 0.0;
|
||||
|
||||
// §2.10 LOD
|
||||
bool lod_enabled = true;
|
||||
int lod_level = 0; // 0 = full model, 1..3 = _lod_0N
|
||||
godot::PackedFloat32Array lod_dist;
|
||||
std::vector<std::shared_ptr<std::optional<gr2::File>>> lod_files; // [0]=_lod_01 ...
|
||||
std::vector<std::vector<mtgodot::RenderPart>> lod_parts; // parallel to lod_files
|
||||
// LOD crossfade: a frozen ghost of the outgoing level fades out while the new
|
||||
// mesh fades in (LODController::BlendRenderWithOneTexture). ~0.18 s.
|
||||
godot::MeshInstance3D *lod_prev_mi = nullptr;
|
||||
double lod_fade_t = -1.0; // <0 = not fading
|
||||
std::vector<godot::Ref<godot::Material>> lod_ghost_mats;
|
||||
void _end_lod_fade();
|
||||
std::vector<mtgodot::RenderPart> base_parts;
|
||||
void _load_lods(const godot::String &loaded_spec);
|
||||
void _set_lod(int n);
|
||||
const gr2::FileInfo *active_fi() const; // base or current LOD (mesh/material data)
|
||||
|
||||
std::vector<mtgodot::RenderPart> parts; // surface s -> (gr2 mesh, tri_group, material)
|
||||
std::vector<godot::Ref<godot::Material>> surf_mats; // resolved once; re-assigned each cpu_skin frame
|
||||
godot::Ref<godot::ArrayMesh> cpu_mesh; // rebuilt each frame in cpu_skin mode
|
||||
bool gpu_skin_active = false;
|
||||
bool gpu_visual_bounds_ready = false;
|
||||
bool have_visual_bounds = false;
|
||||
godot::AABB visual_bounds;
|
||||
std::vector<gr2::Mat4> current_world_pose;
|
||||
godot::AABB fly_target_bounds;
|
||||
bool fly_target_bounds_valid = false;
|
||||
std::vector<gr2::Mat4> current_skin;
|
||||
godot::Ref<godot::Image> bones_img; // RGBAF 3 x bone_count
|
||||
godot::Ref<godot::ImageTexture> bones_tex;
|
||||
|
||||
void _clear_children();
|
||||
void _apply_materials();
|
||||
bool _update_skinned_bounds(const std::vector<gr2::Mat4> &skin);
|
||||
godot::String _guess_texture_dir() const;
|
||||
// Resolve a "d:/ymir work/..." path referenced from `base` (.msm/.msa) to an
|
||||
// absolute file. Passes existing absolute paths through.
|
||||
static godot::String resolve_rel_gr2(const godot::String &base, const godot::String &spec);
|
||||
godot::Ref<godot::ImageTexture> _load_dds(const godot::String &path);
|
||||
godot::Ref<godot::ImageTexture> _resolve_texture(const godot::String &dir,
|
||||
const godot::String &stem, const godot::String &surface_name,
|
||||
const godot::PackedStringArray &dds_files);
|
||||
};
|
||||
|
||||
} // namespace mtgodot
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,198 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#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>
|
||||
|
||||
#include <asset_resolver.h>
|
||||
#include <environment.h>
|
||||
#include <map_setting.h>
|
||||
#include <property.h>
|
||||
#include <terrain_files.h>
|
||||
#include <texture_set.h>
|
||||
|
||||
#include <memory>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "static_object.h"
|
||||
|
||||
namespace godot {
|
||||
class MeshInstance3D;
|
||||
}
|
||||
|
||||
namespace mtgodot {
|
||||
|
||||
// W1 —— 加载一张 Metin2 户外地图并渲染灰色高度地形。
|
||||
// SHINSOO-WORLD-RENDERING.md §8(Metin2World API 契约)/ §9-W1。
|
||||
//
|
||||
// Metin2World (Node3D)
|
||||
// └─ Terrain_000000 (MeshInstance3D) …每区块一个
|
||||
//
|
||||
// 后续阶段往这里加 splat 材质(W2)、静态对象(W3)、树(W4)、环境(W5)…
|
||||
class Metin2World : public godot::Node3D {
|
||||
GDCLASS(Metin2World, godot::Node3D)
|
||||
|
||||
public:
|
||||
struct HeightTriangle {
|
||||
godot::Vector3 v0;
|
||||
godot::Vector3 v1;
|
||||
godot::Vector3 v2;
|
||||
float min_x = 0.0f, max_x = 0.0f;
|
||||
float min_z = 0.0f, max_z = 0.0f;
|
||||
godot::Vector3 normal;
|
||||
float d = 0.0f;
|
||||
};
|
||||
|
||||
Metin2World();
|
||||
~Metin2World() override;
|
||||
|
||||
void _ready() override;
|
||||
void _process(double delta) override;
|
||||
|
||||
void set_assets_root(const godot::String &p);
|
||||
godot::String get_assets_root() const { return assets_root; }
|
||||
void set_map_path(const godot::String &p);
|
||||
godot::String get_map_path() const { return map_path; }
|
||||
void set_load_radius_tiles(int r) { load_radius = r; }
|
||||
int get_load_radius_tiles() const { return load_radius; }
|
||||
void set_focus_tile(godot::Vector2i t);
|
||||
void set_initial_focus_cm(godot::Vector2 position) { initial_focus_cm = position; }
|
||||
godot::Vector2i get_focus_tile() const { return godot::Vector2i(focus_tx, focus_ty); }
|
||||
void set_auto_load(bool v) { auto_load = v; }
|
||||
bool get_auto_load() const { return auto_load; }
|
||||
void set_splat_enabled(bool v) { splat_enabled = v; }
|
||||
bool get_splat_enabled() const { return splat_enabled; }
|
||||
void set_terrain_patches(int n) { terrain_patches = n; }
|
||||
int get_terrain_patches() const { return terrain_patches; }
|
||||
void set_objects_enabled(bool v) { objects_enabled = v; }
|
||||
bool get_objects_enabled() const { return objects_enabled; }
|
||||
void set_env_enabled(bool v) { env_enabled = v; }
|
||||
bool get_env_enabled() const { return env_enabled; }
|
||||
void set_water_enabled(bool v) { water_enabled = v; }
|
||||
void set_collision_enabled(bool v) { collision_enabled = v; }
|
||||
bool get_water_enabled() const { return water_enabled; }
|
||||
// §3.3: static building/tree shadows are already baked into shadowmap.dds, so
|
||||
// they don't cast realtime sun shadows by default (avoids double-darkening).
|
||||
// Only shadowflag=1 buildings and dynamic actors cast realtime. Flip these on
|
||||
// for a modernized look.
|
||||
void set_tree_shadows(bool v) { tree_shadows = v; }
|
||||
bool get_tree_shadows() const { return tree_shadows; }
|
||||
void set_static_shadows(bool v) { static_shadows = v; }
|
||||
bool get_static_shadows() const { return static_shadows; }
|
||||
void set_stream_budget(int v) { stream_budget = v < 1 ? 1 : v; }
|
||||
int get_stream_budget() const { return stream_budget; }
|
||||
|
||||
bool load_map();
|
||||
void unload_map();
|
||||
// 流式:把关注点设到某全局米坐标;load_radius_tiles >= 0 时按 3×3(radius) 装/卸区块。
|
||||
void set_focus_position(double gx_m, double gz_m);
|
||||
godot::Dictionary get_perf() const; // fps / frame ms / draw calls / prims / vram / 节点数
|
||||
// 全局米坐标(Godot 空间)下的地表高度;地图外返回 0。
|
||||
godot::Vector2 get_map_base_cm() const; // setting.txt BasePosition, in cm
|
||||
godot::Vector2i get_map_size_tiles() const;
|
||||
double sample_height(double gx_m, double gz_m) const;
|
||||
// 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;
|
||||
// map_path 下某区块目录的绝对路径(HUD 找 minimap.dds 用)。
|
||||
godot::String chunk_dir(int tile_x, int tile_y) const;
|
||||
|
||||
// 构建期用:扫 assets_root 建 AssetResolver 索引,写到 out_path(asset_index.txt)。
|
||||
// 打进 PCK 后移动端 build_or_load() 直接装载,不再 std::filesystem 扫盘。
|
||||
bool bake_asset_index(const godot::String &out_path);
|
||||
|
||||
protected:
|
||||
static void _bind_methods();
|
||||
|
||||
private:
|
||||
godot::String assets_root;
|
||||
godot::String map_path = "OutdoorA1/metin2_map_a1";
|
||||
int load_radius = -1; // <0 = 全图
|
||||
int focus_tx = 0, focus_ty = 0;
|
||||
godot::Vector2 initial_focus_cm;
|
||||
bool auto_load = true;
|
||||
bool splat_enabled = true;
|
||||
// §3.5: 每区块地形拆成 N×N patch(各自 MeshInstance),启用逐 patch 视锥剔除 +
|
||||
// visibility_range 远距整片剔除。1 = 不拆(旧行为)。必须整除 128。
|
||||
int terrain_patches = 4;
|
||||
// patch 超过这个距离整片不画(米)。默认 3500 覆盖全图+俯视调试,实际增益来自
|
||||
// 逐 patch 视锥剔除;游戏内可调低省远景地形。
|
||||
float terrain_patch_view = 3500.0f;
|
||||
bool objects_enabled = true;
|
||||
bool env_enabled = true;
|
||||
bool water_enabled = true;
|
||||
bool collision_enabled = true;
|
||||
bool tree_shadows = false; // trees: baked in shadowmap.dds -> no realtime cast
|
||||
bool static_shadows = false; // shadowflag=0/empty buildings: same
|
||||
int water_pieces = 0;
|
||||
|
||||
fmt::MapSetting setting;
|
||||
fmt::TextureSet texture_set;
|
||||
fmt::Environment env;
|
||||
bool env_ok = false;
|
||||
std::shared_ptr<fmt::AssetResolver> resolver;
|
||||
fmt::PropertyRegistry registry;
|
||||
StaticMeshCache static_cache;
|
||||
bool setting_ok = false;
|
||||
bool splat_ready = false;
|
||||
bool registry_ok = false;
|
||||
godot::String last_error;
|
||||
int chunks_built = 0, chunks_failed = 0, chunks_splatted = 0;
|
||||
int objects_placed = 0, objects_skipped = 0, objects_missing_model = 0;
|
||||
int trees_placed = 0, tree_species = 0;
|
||||
int native_trees_placed = 0, proxy_trees_placed = 0;
|
||||
int objects_mdatr_built = 0;
|
||||
godot::Node3D *objects_root = nullptr;
|
||||
double build_ms = 0;
|
||||
|
||||
struct Chunk {
|
||||
int tx = 0, ty = 0;
|
||||
std::shared_ptr<fmt::HeightMap> hm;
|
||||
std::shared_ptr<fmt::AttrMap> am;
|
||||
godot::Node3D *root = nullptr; // 该区块的全部场景节点(terrain + water + 对象 + 树)
|
||||
int objects = 0, trees = 0;
|
||||
std::vector<HeightTriangle> height_triangles;
|
||||
};
|
||||
struct AmbienceSource {
|
||||
int tile_x = 0, tile_y = 0;
|
||||
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,
|
||||
std::vector<HeightTriangle> &out_heights);
|
||||
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;
|
||||
};
|
||||
|
||||
} // namespace mtgodot
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,433 +0,0 @@
|
||||
#pragma once
|
||||
// M2Client — GDExtension Node wrapping the Metin2 net client. GDScript drives it:
|
||||
//
|
||||
// var c = M2Client.new()
|
||||
// add_child(c)
|
||||
// c.phase_changed.connect(func(p): print("phase ", p))
|
||||
// c.char_list.connect(func(list): c.select_character(list[0]["index"]))
|
||||
// c.entered_game.connect(func(): print("in game"))
|
||||
// c.connect_to_server("192.168.21.203", 11000, "192.168.21.203", 11011,
|
||||
// "admin", "123456789")
|
||||
//
|
||||
// Orchestration: AuthClient(auth_host:auth_port) -> on auth_ok, connect
|
||||
// GameClient(game_host:game_port) which does its own KX handshake, sends
|
||||
// CG_LOGIN2, surfaces the character list, then select_character() drives to the
|
||||
// game phase. _process() pumps whichever stream is active.
|
||||
|
||||
#include <godot_cpp/classes/image.hpp>
|
||||
#include <godot_cpp/classes/node.hpp>
|
||||
#include <godot_cpp/core/object_id.hpp>
|
||||
#include <godot_cpp/classes/ref.hpp>
|
||||
#include <godot_cpp/variant/array.hpp>
|
||||
#include <godot_cpp/variant/dictionary.hpp>
|
||||
#include <godot_cpp/variant/packed_byte_array.hpp>
|
||||
#include <godot_cpp/variant/string.hpp>
|
||||
|
||||
#include <memory>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
namespace mtnet {
|
||||
class AuthClient;
|
||||
class GameClient;
|
||||
class MarkClient;
|
||||
class MarkImageSet;
|
||||
class EntityStore;
|
||||
namespace classic {
|
||||
class ClassicSession;
|
||||
class ClassicMarkClient;
|
||||
}
|
||||
} // namespace mtnet
|
||||
|
||||
namespace mtgodot {
|
||||
|
||||
class M2Client : public godot::Node {
|
||||
GDCLASS(M2Client, godot::Node)
|
||||
|
||||
public:
|
||||
M2Client();
|
||||
~M2Client() override;
|
||||
|
||||
void _process(double delta) override;
|
||||
|
||||
// host/port for auth and game servers; account credentials.
|
||||
void connect_to_server(const godot::String &auth_host, int auth_port,
|
||||
const godot::String &game_host, int game_port, const godot::String &id,
|
||||
const godot::String &pw);
|
||||
// LoginPhase retry equivalent of LoginWindow.Connect. The implementation
|
||||
// reuses a pending classic session and replaces only its auth transport.
|
||||
void retry_login(const godot::String &auth_host, int auth_port,
|
||||
const godot::String &game_host, int game_port, const godot::String &id,
|
||||
const godot::String &pw);
|
||||
void disconnect_from_server();
|
||||
// Share the already-loaded item_proto with native action gates. The object
|
||||
// is held by instance id so unloading a scene cannot leave a dangling ptr.
|
||||
void set_item_proto(godot::Object *proto);
|
||||
// Resolve the same item use/drop sound CPythonItem selects from item_proto.
|
||||
// This small public seam keeps the resource-side branch directly testable.
|
||||
godot::String item_action_sound_for_item(int vnum, bool drop) const;
|
||||
|
||||
// pick a slot from the char_list payload (its "index" field).
|
||||
bool select_character(int index);
|
||||
// Enter a character through the stock two-connection direct-enter flow.
|
||||
bool enter_game(int index);
|
||||
// CG_CHARACTER_CREATE / CG_CHARACTER_DELETE (mirror SendCreate/DestroyCharacterPacket).
|
||||
bool create_character(int slot, const godot::String &name, int job, int shape,
|
||||
int con, int intel, int str, int dex);
|
||||
bool delete_character(int slot, const godot::String &private_code);
|
||||
// Rename a character slot with a server-side rename card.
|
||||
bool change_name(int slot, const godot::String &name);
|
||||
// GC_EMPIRE (0 = server still wants an empire pick before select).
|
||||
int get_empire() const;
|
||||
// CG_EMPIRE: choose one of the three empires when the account has no empire.
|
||||
// Both network backends expose the same intent for the character-select UI.
|
||||
bool select_empire(int empire);
|
||||
// 3 or 4 depending on which GC_LOGIN_SUCCESS the server sent.
|
||||
int get_slot_count() const;
|
||||
|
||||
// --- in-game intents (mirror CPythonNetworkStream) ---
|
||||
// rot_deg is a compass heading in degrees; wire form is rot_deg/5 like the
|
||||
// original client. x/y are server cm. time is filled from the frame clock.
|
||||
bool move(int func, int arg, double rot_deg, int x, int y);
|
||||
// Sends CG_CHARACTER_POSITION (legacy posture/position enum, 0..255).
|
||||
bool character_position(int position);
|
||||
// Sends a CG_SYNC_POSITION batch. Each entry is {vid, x, y} in server cm;
|
||||
// the legacy packet allows at most 16 entries.
|
||||
bool sync_positions(const godot::Array &positions);
|
||||
bool request_warp();
|
||||
bool fishing(double rot_deg);
|
||||
bool request_dungeon();
|
||||
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),
|
||||
// arg = loop count low nibble | moving-skill bit (1 << 4).
|
||||
bool cast_skill(int motion_idx, double rot_deg, int x, int y, int arg = 0);
|
||||
// Real skill intent. The non-classic backend may include its primary target
|
||||
// packet here; the classic 40250 caller sends primary fly targeting from the
|
||||
// bow event and uses this method for CG_USE_SKILL itself.
|
||||
bool use_skill(int skill_id, int target_vid);
|
||||
// Ranged animation events and area-target selection use separate packets.
|
||||
bool shoot(int skill_id);
|
||||
// Primary bow/skill fly target: CG_FLY_TARGETING. Area skills use the
|
||||
// separate CG_ADD_FLY_TARGETING path below.
|
||||
bool fly_targeting(int target_vid, int x, int y);
|
||||
bool add_fly_targeting(int target_vid, int x, int y);
|
||||
// server-driven quest command: "/skillup <skill_id>".
|
||||
bool skill_up(int skill_id);
|
||||
int get_skill_group() const;
|
||||
godot::Array get_skills() const; // [{id, level, master}] for skills the player has
|
||||
godot::Array get_quickslots() const; // [{pos, type, ref}] restored quickslots
|
||||
bool quickslot_add(int pos, int type, int ref);
|
||||
bool quickslot_del(int pos);
|
||||
bool quickslot_swap(int pos, int change_pos);
|
||||
|
||||
// quest / NPC
|
||||
bool click_npc(int vid);
|
||||
bool script_answer(int answer); // dialog choice (0..N-1) or 255 = continue
|
||||
bool script_button(int idx); // quest-log button
|
||||
bool script_select_item(int selection); // inventory cell/item position
|
||||
bool quest_input(const godot::String &text);
|
||||
bool quest_confirm(bool yes, int request_pid);
|
||||
bool quest_cancel();
|
||||
godot::Array get_quests() const; // [{index, title, counter_name, counter_value, ...}]
|
||||
|
||||
// --- P8 party ---
|
||||
bool party_invite(int vid);
|
||||
bool party_answer(int leader_pid, bool accept);
|
||||
bool party_leave(int pid); // expel <pid>, or your own pid to leave
|
||||
bool party_use_skill(int skill_index, int target_vid);
|
||||
bool party_set_distribute(int mode);
|
||||
bool party_set_state(int pid, int role, bool on); // CG_PARTY_SET_STATE (role = PARTY_ROLE_*)
|
||||
godot::Array get_party() const; // [{pid, vid, name, leader, hp_pct, state, affects[7]}]
|
||||
int get_party_distribute_mode() const;
|
||||
|
||||
// --- P8 messenger / friends ---
|
||||
bool add_friend(const godot::String &name);
|
||||
bool remove_friend(const godot::String &name);
|
||||
bool friend_answer(const godot::String &name, bool accept);
|
||||
godot::Array get_friends() const; // [{name, online}]
|
||||
godot::Dictionary get_lover() const; // {valid, name, love_point}
|
||||
|
||||
// --- P8 NPC shop ---
|
||||
bool shop_buy(int pos, int count);
|
||||
bool shop_sell(int inv_cell, int count);
|
||||
bool shop_close();
|
||||
bool is_shop_open() const;
|
||||
godot::Array get_shop_items() const; // [{pos, vnum, price, count}] — tab 0
|
||||
godot::Dictionary get_shop() const; // {vid, open, tabs:[{name, coin_type, items:[...]}]}
|
||||
|
||||
// --- P8 exchange / trade ---
|
||||
bool exchange_start(int vid);
|
||||
bool exchange_add_item(int inv_window, int inv_cell, int display_pos);
|
||||
bool exchange_add_gold(int gold);
|
||||
bool exchange_accept();
|
||||
bool exchange_cancel();
|
||||
godot::Dictionary get_exchange() const; // {active, partner_vid, self_items, peer_items, ...}
|
||||
|
||||
// --- P8 safebox / storage ---
|
||||
bool safebox_checkin(int safe_pos, int inv_window, int inv_cell);
|
||||
bool safebox_checkout(int safe_pos, int inv_window, int inv_cell);
|
||||
bool safebox_move(int from_cell, int to_cell, int count);
|
||||
bool is_safebox_open() const;
|
||||
int get_safebox_size() const;
|
||||
int get_safebox_gold() const;
|
||||
godot::Array get_safebox_items() const; // [{cell, vnum, count}]
|
||||
bool safebox_password(const godot::String &password);
|
||||
// 40250 的仓库关闭/改密走 chat 命令(cmd.cpp do_safebox_close /
|
||||
// do_safebox_change_password,均 GM_PLAYER),与 safebox_password 同通道。
|
||||
bool safebox_change_password(const godot::String &old_password,
|
||||
const godot::String &new_password);
|
||||
bool safebox_close();
|
||||
|
||||
// --- item-mall (창고몰) ---
|
||||
bool is_mall_open() const;
|
||||
int get_mall_size() const;
|
||||
godot::Array get_mall_items() const; // [{cell, vnum, count}]
|
||||
bool mall_checkout(int mall_pos, int inv_window, int inv_cell);
|
||||
bool mall_password(const godot::String &password);
|
||||
bool mall_close(); // "/mall_close"(cmd.cpp do_mall_close)
|
||||
|
||||
// --- private (PC) shop ---
|
||||
// items: Array of {vnum, count, inv_cell, price, display_pos}
|
||||
bool open_private_shop(const godot::String &sign, const godot::Array &items);
|
||||
bool close_private_shop();
|
||||
|
||||
// --- cube (제작) ---
|
||||
godot::Dictionary get_cube() const; // {open, npc_vnum, need_gold, recipes:[...]}
|
||||
bool cube_make(int result_index);
|
||||
bool cube_request_result_list(int npc_vnum);
|
||||
bool cube_request_materials(int start_index, int count);
|
||||
bool cube_open();
|
||||
bool cube_close();
|
||||
bool cube_list();
|
||||
bool cube_add_item(int cube_index, int inventory_index);
|
||||
bool cube_delete_item(int cube_index);
|
||||
|
||||
// --- guild ---
|
||||
godot::Dictionary get_guild() const; // {in_guild, id, name, level, exp, gold, ...}
|
||||
godot::Array get_guild_members() const; // [{pid, name, grade, job, level, offer, general}]
|
||||
godot::Array get_guild_grades() const; // [{name, auth}] index = grade
|
||||
bool guild_add_member(int vid);
|
||||
bool guild_remove_member(int pid);
|
||||
bool guild_offer(int amount);
|
||||
bool guild_charge_gsp(int amount);
|
||||
// 40250 服务器把这两个子头禁用了(input_main.cpp 直接 return,死代码在下方),
|
||||
// 但原版 ClientVS22 UI 仍照发(uiguild.py OnDeposit/OnWithdraw)——保持 1:1。
|
||||
bool guild_deposit_money(int amount);
|
||||
bool guild_withdraw_money(int amount);
|
||||
bool guild_change_grade_name(int grade, const godot::String &name);
|
||||
bool guild_change_grade_authority(int grade, int authority);
|
||||
bool guild_change_member_grade(int pid, int grade);
|
||||
bool guild_change_member_general(int pid, bool enabled);
|
||||
bool guild_post_comment(const godot::String &text);
|
||||
bool guild_delete_comment(int comment_id);
|
||||
bool guild_refresh_comments();
|
||||
bool guild_answer_invite(int guild_id, bool accept);
|
||||
bool guild_answer_make(const godot::String &name);
|
||||
godot::Array get_guild_comments() const; // [{id, name, content}]
|
||||
|
||||
// --- guild war / guild skill ---
|
||||
godot::Dictionary get_guild_skill() const; // {valid, skill_point, guild_point, max_guild_point, levels[12]}
|
||||
godot::Array get_guild_wars() const; // [{src, dst, src_name, dst_name}] active GvG
|
||||
godot::Dictionary get_guild_war() const; // {opp_guild_id, opp_name, type, state}
|
||||
godot::String get_guild_name(int guild_id) const;
|
||||
bool use_guild_skill(int skill_vnum, int target_vid);
|
||||
bool declare_guild_war(const godot::String &guild_name); // sends "/war <name>"
|
||||
|
||||
// --- guild marks (会徽) ---
|
||||
// Opens the side connection to `host:port` and pulls the mark images using
|
||||
// the handle/random_key from login. `guild_marks_ready` fires when done.
|
||||
// A port of 0 skips the query. Safe to call again on `guild_mark_updated`.
|
||||
bool download_guild_marks(const godot::String &host, int port);
|
||||
// Download one raw guild-symbol file via the mark side connection.
|
||||
bool download_guild_symbol(const godot::String &host, int port, int guild_id);
|
||||
// Raw bytes from the last completed download_guild_symbol call.
|
||||
godot::PackedByteArray get_guild_symbol() const;
|
||||
bool are_guild_marks_ready() const;
|
||||
// {host, port} last used for a mark connection (0 port = none configured yet).
|
||||
godot::Dictionary get_mark_server() const;
|
||||
// Upload this guild's 16x12 mark (a godot::Image, converted/resized as needed).
|
||||
// `guild_mark_uploaded(ok)` fires when the packet has left the socket.
|
||||
bool upload_guild_mark(const godot::String &host, int port, int guild_id,
|
||||
const godot::Ref<godot::Image> &img);
|
||||
// Upload the raw bytes of a guild-symbol image file (server validates 64x128).
|
||||
bool upload_guild_symbol(const godot::String &host, int port, int guild_id,
|
||||
const godot::PackedByteArray &file_bytes);
|
||||
// {found, img_idx, x, y, w, h} — position of a guild's 16x12 mark in its image.
|
||||
godot::Dictionary get_guild_mark(int guild_id) const;
|
||||
// A 16x12 RGBA8 godot::Image for a guild's mark, or an empty (null) Ref if we
|
||||
// have no mark for it yet.
|
||||
godot::Ref<godot::Image> get_guild_mark_image(int guild_id) const;
|
||||
|
||||
// --- refine / upgrade ---
|
||||
bool refine(int pos, int type); // confirm refine of the item at inventory `pos`
|
||||
|
||||
// --- dragon soul refine ---
|
||||
// mode: 0 = 升级 (upgrade), 1 = 改良 (improvement), 2 = 精炼 (refine).
|
||||
// cells: inventory cells; cells[0] = the dragon soul, cells[1..] = materials (<=15 total).
|
||||
bool ds_refine(int mode, const godot::Array &cells);
|
||||
godot::Array get_dragon_souls() const; // [{cell, vnum, count}] non-empty DS-window slots
|
||||
|
||||
// --- P9 world systems ---
|
||||
int get_channel() const;
|
||||
int64_t get_server_time() const; // unix seconds as of last GC_TIME
|
||||
godot::Array get_npc_marks() const; // [{type, vnum, name, pos}] pos = Godot metres
|
||||
godot::Array get_land_areas() const; // [{id, guild_id, rect}] in server cm
|
||||
godot::Array get_observers() const; // [{vid, pos}] in Godot metres
|
||||
godot::Array get_world_markers() const; // [{id, name, vid, type, pos}]
|
||||
bool is_observer_mode() const;
|
||||
int get_observer_count() const;
|
||||
bool has_mobile_flag() const;
|
||||
bool combo_skill_enabled() const;
|
||||
godot::Dictionary get_stamina_state() const; // {consuming, per_sec, current}
|
||||
|
||||
// items. window: 1=inventory, 2=equipment (mtnet::WINDOW_*).
|
||||
bool move_item(int from_window, int from_cell, int to_window, int to_cell, int count);
|
||||
bool use_item(int window, int cell);
|
||||
bool drop_item(int window, int cell, int gold);
|
||||
bool drop_item_count(int window, int cell, int gold, int count);
|
||||
bool use_item_to_item(int source_window, int source_cell, int target_window, int target_cell);
|
||||
bool give_item(int target_vid, int window, int cell, int count);
|
||||
bool pickup_item(int ground_vid);
|
||||
godot::Array get_inventory() const; // non-empty slots
|
||||
godot::Array get_equipment() const; // WEAR_MAX_NUM slots (may be empty)
|
||||
godot::Array get_belt_inventory() const; // non-empty 4x4 belt slots
|
||||
godot::Array get_view_equipment(int vid) const; // 11 legacy inspect slots (may be empty)
|
||||
godot::Dictionary get_item(int window, int cell) const;
|
||||
godot::Array get_ground_items() const; // {vid, vnum, pos}
|
||||
godot::Array get_pvp_relations() const; // [{src_vid, dst_vid, mode}]
|
||||
godot::Dictionary get_duel() const; // {active, cannot_attack, opponents}
|
||||
|
||||
// --- networked world snapshot (positions already Godot-space, metres) ---
|
||||
godot::Dictionary get_entity(int vid) const;
|
||||
// Root-motion speeds (cm/s at movSpd 100) of a remote actor's WALK / RUN loop,
|
||||
// measured by the view from its .msa; drives the reference-style remote walk.
|
||||
void set_entity_motion_speed(int vid, double walk_cm_s, double run_cm_s);
|
||||
// Presentation-owned CInstanceBase network-state gates. These are updated
|
||||
// when a remote skill/emotion starts or reaches its motion tail.
|
||||
void set_entity_network_state_gate(int vid, bool skill_active, bool skill_can_cancel,
|
||||
bool acting_emotion, bool tcp_state_enabled);
|
||||
// Presentation-owned knockdown latch used by TEMP_Push's
|
||||
// DAMAGE_FLYING -> STAND_UP motion chain.
|
||||
void set_entity_knockdown(int vid, bool on);
|
||||
godot::Array get_entities() const;
|
||||
int get_main_vid() const;
|
||||
int get_main_pid() const;
|
||||
godot::Dictionary get_points() const; // local player stat block
|
||||
godot::Dictionary get_target() const; // {vid, hp_percent} of selected target
|
||||
godot::Array get_affects() const; // active buffs/debuffs on the local player
|
||||
|
||||
// App lifecycle (F5). suspend() stops pumping the socket (called
|
||||
// automatically on NOTIFICATION_APPLICATION_PAUSED); resume() re-enables it.
|
||||
// A mobile OS tears the TCP connection down within seconds of backgrounding,
|
||||
// so the first pump after resume typically surfaces `disconnected` — call
|
||||
// reconnect() to redo the login with the stored credentials.
|
||||
void suspend();
|
||||
void resume();
|
||||
bool reconnect();
|
||||
bool is_suspended() const { return suspended; }
|
||||
|
||||
godot::String get_stage() const { return stage_name(); }
|
||||
bool is_in_game() const;
|
||||
|
||||
// Drain the socket + answer PING once, WITHOUT emitting the per-frame world
|
||||
// signals. Call this from GDScript around any blocking work (model loads,
|
||||
// map build) so the server doesn't drop us for going silent.
|
||||
void net_poll();
|
||||
|
||||
protected:
|
||||
static void _bind_methods();
|
||||
|
||||
private:
|
||||
enum class Stage { Idle, AuthConnect, AuthWait, GameConnect, GameLogin, InGame, Failed };
|
||||
Stage stage = Stage::Idle;
|
||||
godot::String stage_name() const;
|
||||
void set_stage(Stage s);
|
||||
|
||||
godot::String game_host;
|
||||
int game_port = 0;
|
||||
godot::String account_id;
|
||||
|
||||
// Stored only until the classic session has sent its login packet. The
|
||||
// reference client clears its network password at that boundary; classic
|
||||
// reconnects then use last_login_key or require a fresh explicit login.
|
||||
godot::String cfg_auth_host, cfg_game_host, cfg_id, cfg_pw;
|
||||
int cfg_auth_port = 0, cfg_game_port = 0;
|
||||
bool have_cfg = false;
|
||||
bool suspended = false;
|
||||
|
||||
std::unique_ptr<mtnet::AuthClient> auth;
|
||||
std::unique_ptr<mtnet::GameClient> game;
|
||||
// MT_PROTOCOL=classic (40250): replaces auth+game for the flows it supports
|
||||
// (connect/login/select/enter-game/world/intents). Null otherwise.
|
||||
std::unique_ptr<mtnet::classic::ClassicSession> classic_sess;
|
||||
bool classic_list_emitted = false;
|
||||
// A peer-close or replacement-connect failure has already returned
|
||||
// ClassicSession to LoginPhase, but the host must still allow the next
|
||||
// reconnect request to replace the old socket instead of treating the
|
||||
// transient GameLogin stage as in-flight.
|
||||
bool classic_remote_disconnect_pending = false;
|
||||
// Keep the auth Offline owner retryable across pumps after
|
||||
// AccountConnector::OnConnectFailure; a later frame must not synthesize a
|
||||
// terminal Failed state for the same transport error.
|
||||
bool classic_auth_connect_failure_pending = false;
|
||||
// AccountConnector::OnRemoteDisconnect returns to Offline without invoking
|
||||
// OnLoginFailure/OnConnectFailure; keep that owner non-terminal across pumps.
|
||||
bool classic_auth_remote_disconnect_pending = false;
|
||||
int classic_last_stage = -1;
|
||||
int classic_last_empire = -1;
|
||||
void pump_classic();
|
||||
void bind_classic_loading_reset();
|
||||
// the EntityStore of whichever backend is active (classic_sess or game).
|
||||
const mtnet::EntityStore *active_world() const;
|
||||
std::unique_ptr<mtnet::MarkClient> mark;
|
||||
std::unique_ptr<mtnet::classic::ClassicMarkClient> classic_mark;
|
||||
std::unique_ptr<mtnet::MarkImageSet> mark_store; // survives after `mark` is torn down
|
||||
godot::String mark_host;
|
||||
int mark_port = 0;
|
||||
bool mark_ready = false;
|
||||
uint32_t symbol_guild_id = 0;
|
||||
std::vector<uint8_t> symbol_data;
|
||||
void pump_mark();
|
||||
void pump_classic_mark();
|
||||
|
||||
int last_auth_state = -1;
|
||||
int last_game_state = -1;
|
||||
int last_game_phase = -1;
|
||||
int last_game_empire = -1;
|
||||
bool char_list_emitted = false;
|
||||
uint32_t selected_pid = 0;
|
||||
godot::Array build_char_list() const;
|
||||
// CG_ENTERGAME is sent ~1.5s into PHASE_LOADING (not immediately) — sending
|
||||
// it before the server finishes the spawn burst makes it drop us ~10s later.
|
||||
uint64_t loading_since_ms = 0;
|
||||
bool enter_game_sent = false;
|
||||
bool shop_open_seen = false; // last shop_open() state we emitted a signal for
|
||||
bool mall_open_seen = false;
|
||||
bool safebox_open_seen = false;
|
||||
uint32_t last_login_key = 0; // for a cross-server GC_WARP reconnect
|
||||
void warp_to_game_server(const godot::String &host, int port);
|
||||
std::unordered_set<uint32_t> dead_seen; // vids we've already fired entity_dead for
|
||||
|
||||
void pump_auth();
|
||||
void pump_game();
|
||||
bool item_action_allowed(bool equipment_touched) const;
|
||||
bool is_equip_item_in_slot(int window, int cell) const;
|
||||
bool is_equipment_cell(int window, int cell) const;
|
||||
bool item_proto_known(uint32_t vnum) const;
|
||||
bool item_proto_type(uint32_t vnum, uint8_t &type) const;
|
||||
void emit_item_action_sound(int window, int cell, bool drop);
|
||||
void apply_item_proto_validator();
|
||||
godot::ObjectID item_proto_id;
|
||||
bool item_proto_ready = false;
|
||||
mtnet::EntityStore *item_validator_world = nullptr;
|
||||
bool item_validator_ready = false;
|
||||
bool local_main_attacking = false;
|
||||
};
|
||||
|
||||
} // namespace mtgodot
|
||||
@@ -7,29 +7,18 @@
|
||||
// with the Chinese build using GB2312-compatible bytes) and fills fixed C
|
||||
// buffers by byte capacity, never by UTF-8 character count.
|
||||
//
|
||||
// This project carries text as UTF-8 end to end (GDScript String ->
|
||||
// std::string). The public helpers below are the one boundary where fixed
|
||||
// This project carries text as UTF-8 in std::string. The public helpers below are the one boundary where fixed
|
||||
// 40250 fields are converted to/from the wire code page. Map names, quest
|
||||
// titles, system text and other dynamic text must use their own packet capacity
|
||||
// and must not be validated as CHARACTER_NAME_MAX_LEN fields.
|
||||
//
|
||||
// mtnet (ClassicParser / ClassicSession) links **without** godot-cpp, so the
|
||||
// std::string_view core below is the one those call sites use. The
|
||||
// godot::String overloads are compiled only where <godot_cpp/...> is on the
|
||||
// include path (the M2Client / GDScript boundary).
|
||||
// ClassicParser and ClassicSession use the std::string_view API below.
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#if defined(__has_include)
|
||||
# if __has_include(<godot_cpp/variant/string.hpp>)
|
||||
# include <godot_cpp/variant/char_string.hpp>
|
||||
# include <godot_cpp/variant/string.hpp>
|
||||
# define MT_TEXT_CODEC_HAS_GODOT 1
|
||||
# endif
|
||||
#endif
|
||||
|
||||
namespace mtnet {
|
||||
|
||||
@@ -83,19 +72,4 @@ inline bool protocol_text_fits(std::string_view utf8, size_t cap, bool legacy_co
|
||||
return legacy_codepage ? wire_text_fits(utf8, cap) : utf8.size() <= cap;
|
||||
}
|
||||
|
||||
#ifdef MT_TEXT_CODEC_HAS_GODOT
|
||||
// §1.10 named API — the GDScript / M2Client boundary.
|
||||
inline std::string to_wire(const godot::String &s, size_t cap) {
|
||||
const godot::CharString u = s.utf8();
|
||||
const char *data = u.get_data();
|
||||
return to_wire(std::string_view(data ? data : "", data ? static_cast<size_t>(u.length()) : 0),
|
||||
cap);
|
||||
}
|
||||
|
||||
inline godot::String from_wire(const char *bytes, size_t cap) {
|
||||
const std::string s = from_wire_str(bytes, cap);
|
||||
return godot::String::utf8(s.c_str(), static_cast<int64_t>(s.size()));
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace mtnet
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
#include "pack40250_node.h"
|
||||
|
||||
#include <godot_cpp/core/class_db.hpp>
|
||||
|
||||
#include "asset_io.h"
|
||||
#include "platform/PackBackend.h"
|
||||
|
||||
using namespace godot;
|
||||
|
||||
namespace mtgodot {
|
||||
|
||||
bool Metin2Pack::initialize(const String &client_dir) {
|
||||
return mtpack40250::initialize(client_dir.utf8().get_data());
|
||||
}
|
||||
|
||||
bool Metin2Pack::is_ready() {
|
||||
return mtpack40250::ready();
|
||||
}
|
||||
|
||||
bool Metin2Pack::exists(const String &name) {
|
||||
return file_exists(String(PACK_SCHEME) + name);
|
||||
}
|
||||
|
||||
PackedByteArray Metin2Pack::get_bytes(const String &name) {
|
||||
return read_file(String(PACK_SCHEME) + name);
|
||||
}
|
||||
|
||||
void Metin2Pack::_bind_methods() {
|
||||
ClassDB::bind_static_method("Metin2Pack", D_METHOD("initialize", "client_dir"), &Metin2Pack::initialize);
|
||||
ClassDB::bind_static_method("Metin2Pack", D_METHOD("is_ready"), &Metin2Pack::is_ready);
|
||||
ClassDB::bind_static_method("Metin2Pack", D_METHOD("exists", "name"), &Metin2Pack::exists);
|
||||
ClassDB::bind_static_method("Metin2Pack", D_METHOD("get_bytes", "name"), &Metin2Pack::get_bytes);
|
||||
}
|
||||
|
||||
} // namespace mtgodot
|
||||
@@ -1,28 +0,0 @@
|
||||
#pragma once
|
||||
// Metin2Pack — GDScript access to the 40250 packs (platform/PackBackend.h).
|
||||
//
|
||||
// Metin2Pack.initialize(AssetRoot.client_path()) # registers <Client>/pack/Index once
|
||||
// var b := Metin2Pack.get_bytes("locale/en/item_proto")
|
||||
//
|
||||
// Every native reader that takes a path accepts "pack://<40250 path>" through asset_io.
|
||||
|
||||
#include <godot_cpp/classes/object.hpp>
|
||||
#include <godot_cpp/variant/packed_byte_array.hpp>
|
||||
#include <godot_cpp/variant/string.hpp>
|
||||
|
||||
namespace mtgodot {
|
||||
|
||||
class Metin2Pack : public godot::Object {
|
||||
GDCLASS(Metin2Pack, godot::Object)
|
||||
|
||||
public:
|
||||
static bool initialize(const godot::String &client_dir);
|
||||
static bool is_ready();
|
||||
static bool exists(const godot::String &name);
|
||||
static godot::PackedByteArray get_bytes(const godot::String &name);
|
||||
|
||||
protected:
|
||||
static void _bind_methods();
|
||||
};
|
||||
|
||||
} // namespace mtgodot
|
||||
@@ -8,6 +8,7 @@
|
||||
#include "EterBase/Timer.h"
|
||||
#include "EterLib/Util.h"
|
||||
#include "EterLib/GrpBase.h"
|
||||
#include "EterLib/GrpImage.h"
|
||||
#include "EterLib/Camera.h"
|
||||
#include "../EterLib/HostCursor.h"
|
||||
#include "UserInterface/PythonNetworkStream.h"
|
||||
@@ -408,6 +409,44 @@ void SetAttackKey(bool pressed)
|
||||
CPythonPlayer::Instance().SetAttackKeyState(pressed);
|
||||
}
|
||||
|
||||
void UseLocalQuickSlot(int localSlotIndex)
|
||||
{
|
||||
if (!g_game_singletons || localSlotIndex < 0 || localSlotIndex >= 8) return;
|
||||
CPythonPlayer::Instance().RequestUseLocalQuickSlot(static_cast<DWORD>(localSlotIndex));
|
||||
}
|
||||
|
||||
bool TryAssignAttachedObjectToLocalQuickSlot(int localSlotIndex)
|
||||
{
|
||||
if (!g_game_singletons || localSlotIndex < 0 || localSlotIndex >= 8) return false;
|
||||
std::string attached;
|
||||
std::string error;
|
||||
if (!Evaluate("__import__('mouseModule').mouseController.isAttached()", &attached, &error) ||
|
||||
(attached != "True" && attached != "1"))
|
||||
return false;
|
||||
|
||||
const std::string command =
|
||||
"_stream.curPhaseWindow.interface.wndTaskBar.AddQuickSlot(" + std::to_string(localSlotIndex) + ")";
|
||||
return RunLine(command.c_str(), &error);
|
||||
}
|
||||
|
||||
std::string LocalQuickSlotSkillIcon(int localSlotIndex)
|
||||
{
|
||||
if (!g_game_singletons || localSlotIndex < 0 || localSlotIndex >= 8) return {};
|
||||
DWORD type = SLOT_TYPE_NONE;
|
||||
DWORD position = 0;
|
||||
CPythonPlayer& player = CPythonPlayer::Instance();
|
||||
player.GetLocalQuickSlotData(static_cast<DWORD>(localSlotIndex), &type, &position);
|
||||
if (type != SLOT_TYPE_SKILL) return {};
|
||||
|
||||
CPythonSkill::TSkillData* skillData = nullptr;
|
||||
if (!CPythonSkill::Instance().GetSkillData(player.GetSkillIndex(position), &skillData) || !skillData)
|
||||
return {};
|
||||
const int grade = std::clamp(player.GetSkillGrade(position), 0, CPythonSkill::SKILL_GRADE_COUNT - 1);
|
||||
CGraphicImage* image = skillData->GradeData[grade].pImage;
|
||||
if (!image) image = skillData->pImage;
|
||||
return image ? image->GetFileName() : std::string{};
|
||||
}
|
||||
|
||||
void CameraBeginDrag(int x, int y)
|
||||
{
|
||||
CCameraManager& rkCmrMgr = CCameraManager::Instance();
|
||||
|
||||
@@ -66,6 +66,9 @@ void UIKey(int key, bool pressed);
|
||||
// Mobile touch & joystick controls
|
||||
void SetMoveDirection(float angleDeg, bool moving);
|
||||
void SetAttackKey(bool pressed);
|
||||
void UseLocalQuickSlot(int localSlotIndex);
|
||||
bool TryAssignAttachedObjectToLocalQuickSlot(int localSlotIndex);
|
||||
std::string LocalQuickSlotSkillIcon(int localSlotIndex);
|
||||
void CameraBeginDrag(int x, int y);
|
||||
void CameraDrag(int x, int y);
|
||||
void CameraEndDrag();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# port_logic — mirror of the 40250 client logic (extension/src/port/<Lib>/<File>.{h,cpp}).
|
||||
# No godot-cpp dependency: libmtgodot links it, and it also builds on its own (top-level
|
||||
# -DMTGODOT_BUILD_EXTENSION=OFF -DMTGODOT_BUILD_PORT=ON) for targets without an extension build.
|
||||
# This library and port_platform are built directly for the SDL/Vulkan client and port tests.
|
||||
# See docs/PORT-PLAN.md batch 2A.
|
||||
|
||||
file(GLOB_RECURSE MT_PORT_SOURCES CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp)
|
||||
@@ -31,8 +30,7 @@ target_include_directories(port_logic PUBLIC ${MT_PORT_SHIMS})
|
||||
# Libraries 40250 links that are vendored as-is: <lzo/lzo1x.h> (EterBase/lzo.h), <cryptopp/*>
|
||||
# (EterBase/cipher.h), <Python-2.7/*> (ScriptLib).
|
||||
target_link_libraries(port_logic PUBLIC mt3p::minilzo mt3p::cryptopp)
|
||||
# common/Win32Crt.cpp converts ANSI code pages with mt_codepage. extension/CMakeLists.txt adds it for
|
||||
# mtnet; the port-only build (script/port_gate.sh, native_render) never reads that file.
|
||||
# common/Win32Crt.cpp converts ANSI code pages with mt_codepage.
|
||||
if(NOT TARGET mt_codepage)
|
||||
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/../codepage ${CMAKE_CURRENT_BINARY_DIR}/../codepage)
|
||||
endif()
|
||||
@@ -241,8 +239,7 @@ if(BUILD_TESTING AND CMAKE_SYSTEM_NAME STREQUAL CMAKE_HOST_SYSTEM_NAME)
|
||||
# Locale packs in the original format (script/build_zh_locale.py packs locale/zh with it).
|
||||
add_executable(eterpack_pack ${CMAKE_CURRENT_SOURCE_DIR}/../../tests/eterpack_pack.cpp)
|
||||
target_link_libraries(eterpack_pack PRIVATE port_platform)
|
||||
# 2V2-e: the same fake server as a process, for the Godot-driven render run
|
||||
# (script/python_game_render_test.sh).
|
||||
# The same fake server as a process, for native rendering tests.
|
||||
add_executable(port_fake_login_server ${CMAKE_CURRENT_SOURCE_DIR}/../../tests/port_fake_login_server_main.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../../tests/port_login_flow_server.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../net/classic/classic_cipher.cpp)
|
||||
|
||||
@@ -33,6 +33,32 @@
|
||||
|
||||
#include <iostream>
|
||||
#include <fstream>
|
||||
#if !defined(_WIN32)
|
||||
#include <dirent.h> // PORT: resolve separately cased .eix/.epk names on case-sensitive mobile filesystems
|
||||
#include <strings.h> // PORT: strcasecmp for the on-disk pack name lookup
|
||||
|
||||
// PORT: 40250 ships pairs such as ETC.eix and Etc.epk. Windows opens both through the
|
||||
// same stem; Android must locate each file using its actual on-disk spelling.
|
||||
static std::string ResolvePackFileName(const std::string& requested)
|
||||
{
|
||||
const std::string::size_type slash = requested.find_last_of('/');
|
||||
const std::string folder = slash == std::string::npos ? "." : requested.substr(0, slash);
|
||||
const std::string name = requested.substr(slash == std::string::npos ? 0 : slash + 1);
|
||||
DIR* dir = opendir(folder.c_str());
|
||||
if (!dir) return requested;
|
||||
std::string result = requested;
|
||||
while (dirent* entry = readdir(dir))
|
||||
{
|
||||
if (strcasecmp(entry->d_name, name.c_str()) == 0)
|
||||
{
|
||||
result = folder + "/" + entry->d_name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
closedir(dir);
|
||||
return result;
|
||||
}
|
||||
#endif
|
||||
|
||||
void CMakePackLog::SetFileName(const char* c_szFileName)
|
||||
{
|
||||
@@ -257,6 +283,18 @@ bool CEterPack::Create(CEterFileDict& rkFileDict, const char * dbname, const cha
|
||||
|
||||
m_stDataFileName = dbname;
|
||||
m_stDataFileName += ".epk";
|
||||
// PORT: the two extensions may have different capitalization in the original Client/pack.
|
||||
#if !defined(_WIN32)
|
||||
if (bReadOnly)
|
||||
{
|
||||
const std::string indexName = ResolvePackFileName(m_indexFileName);
|
||||
if (indexName.size() < sizeof(m_indexFileName))
|
||||
{
|
||||
strcpy(m_indexFileName, indexName.c_str());
|
||||
}
|
||||
m_stDataFileName = ResolvePackFileName(m_stDataFileName);
|
||||
}
|
||||
#endif
|
||||
|
||||
m_bReadOnly = bReadOnly;
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
#include "StdAfx.h"
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include "../EterBase/CRC32.h"
|
||||
#include "PythonWindow.h"
|
||||
#include "PythonSlotWindow.h"
|
||||
@@ -1392,7 +1394,7 @@ namespace UI
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
CExpandedImageBox::CExpandedImageBox(PyObject * ppyObject) : CImageBox(ppyObject)
|
||||
CExpandedImageBox::CExpandedImageBox(PyObject * ppyObject) : CImageBox(ppyObject), m_bAspectFill(false)
|
||||
{
|
||||
}
|
||||
CExpandedImageBox::~CExpandedImageBox()
|
||||
@@ -1400,6 +1402,73 @@ namespace UI
|
||||
OnDestroyInstance();
|
||||
}
|
||||
|
||||
void CExpandedImageBox::SetAspectFill(bool bEnable)
|
||||
{
|
||||
m_bAspectFill = bEnable;
|
||||
}
|
||||
|
||||
bool CExpandedImageBox::IsAutoAspectFillBackground() const
|
||||
{
|
||||
const char* c_szFileName = "";
|
||||
if (m_pImageInstance && m_pImageInstance->GetGraphicImagePointer())
|
||||
c_szFileName = m_pImageInstance->GetGraphicImagePointer()->GetFileName();
|
||||
if (!c_szFileName)
|
||||
c_szFileName = "";
|
||||
|
||||
std::string lowerFile = c_szFileName;
|
||||
std::transform(lowerFile.begin(), lowerFile.end(), lowerFile.begin(), ::tolower);
|
||||
|
||||
const std::string& name = m_strName;
|
||||
std::string parentName = m_pParent ? m_pParent->GetName() : "";
|
||||
|
||||
// 1. Server list and login backgrounds (LoginWindow bg1 and bg2)
|
||||
if (name == "bg1" || name == "bg2")
|
||||
{
|
||||
if (parentName == "LoginWindow")
|
||||
return true;
|
||||
if (lowerFile.find("serverlist") != std::string::npos || lowerFile.find("login") != std::string::npos)
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. Character selection & creation backgrounds (BackGround with select.sub / select.jpg)
|
||||
if (name == "BackGround")
|
||||
{
|
||||
if (parentName == "SelectCharacterWindow" || parentName == "CreateCharacterWindow")
|
||||
return true;
|
||||
if (lowerFile.find("select.sub") != std::string::npos ||
|
||||
lowerFile.find("select.jpg") != std::string::npos ||
|
||||
lowerFile.find("select.png") != std::string::npos ||
|
||||
lowerFile.find("ui/select") != std::string::npos)
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3. Loading screen backgrounds (BackGround with Line_Pattern.tga or uiloading/*.sub)
|
||||
if (name == "BackGround")
|
||||
{
|
||||
if (parentName == "LoadingWindow")
|
||||
return true;
|
||||
if (lowerFile.find("uiloading") != std::string::npos ||
|
||||
lowerFile.find("line_pattern") != std::string::npos)
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback check by file name directly if element name wasn't set or is generic
|
||||
if (lowerFile.find("serverlist.sub") != std::string::npos ||
|
||||
lowerFile.find("serverlist.jpg") != std::string::npos ||
|
||||
lowerFile.find("uiloading/") != std::string::npos ||
|
||||
lowerFile.find("uiloading\\") != std::string::npos)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool CExpandedImageBox::IsAspectFill() const
|
||||
{
|
||||
return m_bAspectFill || IsAutoAspectFillBackground();
|
||||
}
|
||||
|
||||
void CExpandedImageBox::OnCreateInstance()
|
||||
{
|
||||
OnDestroyInstance();
|
||||
@@ -1420,9 +1489,56 @@ namespace UI
|
||||
if (!m_pImageInstance)
|
||||
return;
|
||||
|
||||
if (IsAspectFill())
|
||||
{
|
||||
long screenWidth = UI::CWindowManager::Instance().GetScreenWidth();
|
||||
long screenHeight = UI::CWindowManager::Instance().GetScreenHeight();
|
||||
int imgWidth = GetWidth();
|
||||
int imgHeight = GetHeight();
|
||||
if (screenWidth > 0 && screenHeight > 0 && imgWidth > 0 && imgHeight > 0)
|
||||
{
|
||||
float sx = float(screenWidth) / float(imgWidth);
|
||||
float sy = float(screenHeight) / float(imgHeight);
|
||||
float s = std::max(sx, sy);
|
||||
((CGraphicExpandedImageInstance*)m_pImageInstance)->SetScale(s, s);
|
||||
CWindow::SetSize(screenWidth, screenHeight);
|
||||
float posX = (float(screenWidth) - float(imgWidth) * s) * 0.5f;
|
||||
float posY = (float(screenHeight) - float(imgHeight) * s) * 0.5f;
|
||||
m_pImageInstance->SetPosition(posX, posY);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
((CGraphicExpandedImageInstance*)m_pImageInstance)->SetScale(fx, fy);
|
||||
CWindow::SetSize(long(float(GetWidth())*fx), long(float(GetHeight())*fy));
|
||||
}
|
||||
|
||||
void CExpandedImageBox::OnChangePosition()
|
||||
{
|
||||
if (!m_pImageInstance)
|
||||
return;
|
||||
|
||||
if (IsAspectFill())
|
||||
{
|
||||
long screenWidth = UI::CWindowManager::Instance().GetScreenWidth();
|
||||
long screenHeight = UI::CWindowManager::Instance().GetScreenHeight();
|
||||
int imgWidth = GetWidth();
|
||||
int imgHeight = GetHeight();
|
||||
if (screenWidth > 0 && screenHeight > 0 && imgWidth > 0 && imgHeight > 0)
|
||||
{
|
||||
float sx = float(screenWidth) / float(imgWidth);
|
||||
float sy = float(screenHeight) / float(imgHeight);
|
||||
float s = std::max(sx, sy);
|
||||
float posX = (float(screenWidth) - float(imgWidth) * s) * 0.5f;
|
||||
float posY = (float(screenHeight) - float(imgHeight) * s) * 0.5f;
|
||||
m_pImageInstance->SetPosition(posX, posY);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
CImageBox::OnChangePosition();
|
||||
}
|
||||
|
||||
void CExpandedImageBox::SetOrigin(float fx, float fy)
|
||||
{
|
||||
if (!m_pImageInstance)
|
||||
@@ -1458,6 +1574,24 @@ namespace UI
|
||||
if (!m_pImageInstance)
|
||||
return;
|
||||
|
||||
if (IsAspectFill())
|
||||
{
|
||||
long screenWidth = UI::CWindowManager::Instance().GetScreenWidth();
|
||||
long screenHeight = UI::CWindowManager::Instance().GetScreenHeight();
|
||||
int imgWidth = GetWidth();
|
||||
int imgHeight = GetHeight();
|
||||
if (screenWidth > 0 && screenHeight > 0 && imgWidth > 0 && imgHeight > 0)
|
||||
{
|
||||
float sx = float(screenWidth) / float(imgWidth);
|
||||
float sy = float(screenHeight) / float(imgHeight);
|
||||
float s = std::max(sx, sy);
|
||||
((CGraphicExpandedImageInstance*)m_pImageInstance)->SetScale(s, s);
|
||||
float posX = (float(screenWidth) - float(imgWidth) * s) * 0.5f;
|
||||
float posY = (float(screenHeight) - float(imgHeight) * s) * 0.5f;
|
||||
m_pImageInstance->SetPosition(posX, posY);
|
||||
}
|
||||
}
|
||||
|
||||
if (IsShow())
|
||||
m_pImageInstance->Render();
|
||||
}
|
||||
|
||||
@@ -386,6 +386,8 @@ namespace UI
|
||||
void SetRotation(float fRotation);
|
||||
void SetRenderingRect(float fLeft, float fTop, float fRight, float fBottom);
|
||||
void SetRenderingMode(int iMode);
|
||||
void SetAspectFill(bool bEnable);
|
||||
bool IsAspectFill() const;
|
||||
|
||||
protected:
|
||||
void OnCreateInstance();
|
||||
@@ -393,8 +395,14 @@ namespace UI
|
||||
|
||||
virtual void OnUpdate();
|
||||
virtual void OnRender();
|
||||
virtual void OnChangePosition();
|
||||
|
||||
BOOL OnIsType(DWORD dwType);
|
||||
|
||||
bool IsAutoAspectFillBackground() const;
|
||||
|
||||
protected:
|
||||
bool m_bAspectFill;
|
||||
};
|
||||
class CAniImageBox : public CWindow
|
||||
{
|
||||
|
||||
@@ -2012,6 +2012,27 @@ PyObject * wndImageSetScale(PyObject * poSelf, PyObject * poArgs)
|
||||
|
||||
return Py_BuildNone();
|
||||
}
|
||||
PyObject * wndImageSetAspectFill(PyObject * poSelf, PyObject * poArgs)
|
||||
{
|
||||
UI::CWindow * pWindow;
|
||||
if (!PyTuple_GetWindow(poArgs, 0, &pWindow))
|
||||
return Py_BuildException();
|
||||
|
||||
bool bEnable = true;
|
||||
if (PyTuple_Size(poArgs) > 1)
|
||||
{
|
||||
int iEnable;
|
||||
if (PyTuple_GetInteger(poArgs, 1, &iEnable))
|
||||
bEnable = (iEnable != 0);
|
||||
}
|
||||
|
||||
if (pWindow->IsType(UI::CExpandedImageBox::Type()))
|
||||
{
|
||||
((UI::CExpandedImageBox*)pWindow)->SetAspectFill(bEnable);
|
||||
}
|
||||
|
||||
return Py_BuildNone();
|
||||
}
|
||||
PyObject * wndImageSetOrigin(PyObject * poSelf, PyObject * poArgs)
|
||||
{
|
||||
UI::CWindow * pWindow;
|
||||
@@ -2454,6 +2475,7 @@ void initwndMgr()
|
||||
{ "GetHeight", wndImageGetHeight, METH_VARARGS },
|
||||
// ExpandedImageBox
|
||||
{ "SetScale", wndImageSetScale, METH_VARARGS },
|
||||
{ "SetAspectFill", wndImageSetAspectFill, METH_VARARGS },
|
||||
{ "SetOrigin", wndImageSetOrigin, METH_VARARGS },
|
||||
{ "SetRotation", wndImageSetRotation, METH_VARARGS },
|
||||
{ "SetRenderingRect", wndImageSetRenderingRect, METH_VARARGS },
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
#include "proto_node.h"
|
||||
|
||||
#include "../asset_io.h"
|
||||
|
||||
#include <godot_cpp/core/class_db.hpp>
|
||||
|
||||
#include <string>
|
||||
|
||||
using namespace godot;
|
||||
|
||||
namespace mtgodot {
|
||||
|
||||
namespace {
|
||||
|
||||
// szName is the Korean (CP949) development name; what 40250 shows is szLocaleName (CItemData::GetName,
|
||||
// CPythonNonPlayer::GetMonsterName) in the locale code page, 1252 for locale.cfg "10002 1252 en".
|
||||
String from_cp1252(const std::string &s) {
|
||||
static const char32_t k80[32] = {
|
||||
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0xFFFD, 0x017D, 0xFFFD,
|
||||
0xFFFD, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0xFFFD, 0x017E, 0x0178,
|
||||
};
|
||||
String out;
|
||||
for (unsigned char c : s) {
|
||||
out += String::chr(c >= 0x80 && c < 0xA0 ? k80[c - 0x80] : static_cast<char32_t>(c));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void Metin2Proto::_bind_methods() {
|
||||
ClassDB::bind_method(D_METHOD("load_item_proto", "path"), &Metin2Proto::load_item_proto);
|
||||
ClassDB::bind_method(D_METHOD("load_mob_proto", "path"), &Metin2Proto::load_mob_proto);
|
||||
ClassDB::bind_method(D_METHOD("item", "vnum"), &Metin2Proto::item);
|
||||
ClassDB::bind_method(D_METHOD("mob", "vnum"), &Metin2Proto::mob);
|
||||
ClassDB::bind_method(D_METHOD("item_count"), &Metin2Proto::item_count);
|
||||
ClassDB::bind_method(D_METHOD("mob_count"), &Metin2Proto::mob_count);
|
||||
ClassDB::bind_method(D_METHOD("get_last_error"), &Metin2Proto::get_last_error);
|
||||
}
|
||||
|
||||
bool Metin2Proto::load_item_proto(const String &path) {
|
||||
std::string err;
|
||||
PackedByteArray bytes = mtgodot::read_file(path);
|
||||
std::vector<uint8_t> buf(bytes.ptr(), bytes.ptr() + bytes.size());
|
||||
if (!mtproto::load_proto_bytes(buf, mtproto::ITEM_PROTO_KEY, m_item, &err)) {
|
||||
last_error = String(err.c_str());
|
||||
return false;
|
||||
}
|
||||
m_item_ix.clear();
|
||||
m_item_ix.reserve(m_item.elements);
|
||||
for (uint32_t i = 0; i < m_item.elements; ++i) {
|
||||
mtproto::ItemRecord r = mtproto::parse_item(m_item.record(i), m_item.stride);
|
||||
m_item_ix[r.vnum] = i;
|
||||
}
|
||||
last_error = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Metin2Proto::load_mob_proto(const String &path) {
|
||||
std::string err;
|
||||
PackedByteArray bytes = mtgodot::read_file(path);
|
||||
std::vector<uint8_t> buf(bytes.ptr(), bytes.ptr() + bytes.size());
|
||||
if (!mtproto::load_proto_bytes(buf, mtproto::MOB_PROTO_KEY, m_mob, &err)) {
|
||||
last_error = String(err.c_str());
|
||||
return false;
|
||||
}
|
||||
m_mob_ix.clear();
|
||||
m_mob_ix.reserve(m_mob.elements);
|
||||
for (uint32_t i = 0; i < m_mob.elements; ++i) {
|
||||
mtproto::MobRecord r = mtproto::parse_mob(m_mob.record(i), m_mob.stride);
|
||||
m_mob_ix[r.vnum] = i;
|
||||
}
|
||||
last_error = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
Dictionary Metin2Proto::item(int vnum) const {
|
||||
Dictionary d;
|
||||
auto it = m_item_ix.find((uint32_t)vnum);
|
||||
if (it == m_item_ix.end() || m_item.record(it->second) == nullptr) {
|
||||
return d;
|
||||
}
|
||||
mtproto::ItemRecord r = mtproto::parse_item(m_item.record(it->second), m_item.stride);
|
||||
d["vnum"] = (int)r.vnum;
|
||||
d["vnum_range"] = (int)r.vnum_range;
|
||||
d["name"] = from_cp1252(r.locale_name); // CItemData::GetName()
|
||||
d["locale_name"] = from_cp1252(r.locale_name);
|
||||
d["type"] = (int)r.type;
|
||||
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["immune_flag"] = (int)r.immune_flag;
|
||||
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) {
|
||||
vals.push_back((int)r.values[i]);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
Dictionary Metin2Proto::mob(int vnum) const {
|
||||
Dictionary d;
|
||||
auto it = m_mob_ix.find((uint32_t)vnum);
|
||||
if (it == m_mob_ix.end() || m_mob.record(it->second) == nullptr) {
|
||||
return d;
|
||||
}
|
||||
mtproto::MobRecord r = mtproto::parse_mob(m_mob.record(it->second), m_mob.stride);
|
||||
d["vnum"] = (int)r.vnum;
|
||||
d["name"] = from_cp1252(r.locale_name); // CPythonNonPlayer::GetMonsterName()
|
||||
d["locale_name"] = from_cp1252(r.locale_name);
|
||||
d["type"] = (int)r.type;
|
||||
d["rank"] = (int)r.rank;
|
||||
d["battle_type"] = (int)r.battle_type;
|
||||
d["level"] = (int)r.level;
|
||||
d["size"] = (int)r.size;
|
||||
d["attack_speed"] = (int)r.attack_speed;
|
||||
d["moving_speed"] = (int)r.moving_speed;
|
||||
d["on_click_type"] = (int)r.on_click_type;
|
||||
d["empire"] = (int)r.empire;
|
||||
d["folder"] = String(r.folder.c_str());
|
||||
d["monster_color"] = (int64_t)r.monster_color;
|
||||
return d;
|
||||
}
|
||||
|
||||
} // namespace mtgodot
|
||||
@@ -1,48 +0,0 @@
|
||||
#pragma once
|
||||
// Metin2Proto — GDExtension node exposing item_proto / mob_proto to GDScript.
|
||||
//
|
||||
// var proto = Metin2Proto.new()
|
||||
// proto.load_item_proto("pack://locale/en/item_proto") # 40250 format, via asset_io
|
||||
// var d := proto.item(19) # { vnum, name, locale_name, type, sub_type, ... }
|
||||
//
|
||||
// Used by the P2 inventory/equipment windows for names / types / tooltips and by
|
||||
// the equip->model path.
|
||||
|
||||
#include <godot_cpp/classes/node.hpp>
|
||||
#include <godot_cpp/variant/array.hpp>
|
||||
#include <godot_cpp/variant/dictionary.hpp>
|
||||
#include <godot_cpp/variant/string.hpp>
|
||||
|
||||
#include <cstdint>
|
||||
#include <unordered_map>
|
||||
|
||||
#include "proto.h"
|
||||
|
||||
namespace mtgodot {
|
||||
|
||||
class Metin2Proto : public godot::Node {
|
||||
GDCLASS(Metin2Proto, godot::Node)
|
||||
|
||||
public:
|
||||
bool load_item_proto(const godot::String &path);
|
||||
bool load_mob_proto(const godot::String &path);
|
||||
|
||||
godot::Dictionary item(int vnum) const;
|
||||
godot::Dictionary mob(int vnum) const;
|
||||
|
||||
int item_count() const { return (int)m_item.elements; }
|
||||
int mob_count() const { return (int)m_mob.elements; }
|
||||
godot::String get_last_error() const { return last_error; }
|
||||
|
||||
protected:
|
||||
static void _bind_methods();
|
||||
|
||||
private:
|
||||
mtproto::Proto m_item;
|
||||
mtproto::Proto m_mob;
|
||||
std::unordered_map<uint32_t, uint32_t> m_item_ix; // vnum -> record index
|
||||
std::unordered_map<uint32_t, uint32_t> m_mob_ix;
|
||||
godot::String last_error;
|
||||
};
|
||||
|
||||
} // namespace mtgodot
|
||||
@@ -1,669 +0,0 @@
|
||||
#include "python_host_node.h"
|
||||
|
||||
#include <godot_cpp/core/class_db.hpp>
|
||||
#include <godot_cpp/variant/utility_functions.hpp>
|
||||
|
||||
#include "python_stdlib.h"
|
||||
|
||||
#ifdef MTGODOT_HAVE_PYTHON
|
||||
// Standard types only — PythonBoot.h keeps <Python-2.7/*> and the Win32 shims out of this translation
|
||||
// unit, which has to stay compilable next to godot-cpp.
|
||||
#include "platform/ScriptLib/PythonBoot.h"
|
||||
#include "platform/EterLib/UIRenderCommands.h"
|
||||
#include "platform/EterLib/RenderCommands3D.h"
|
||||
#include "platform/MilesLib/AudioCommands.h"
|
||||
#include "platform/PackBackend.h"
|
||||
#include "../../native_render/draw_capture.h"
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <type_traits>
|
||||
#include <unordered_map>
|
||||
#endif
|
||||
|
||||
#include <godot_cpp/classes/image.hpp>
|
||||
#include <godot_cpp/variant/dictionary.hpp>
|
||||
#include <godot_cpp/variant/packed_byte_array.hpp>
|
||||
#include <godot_cpp/variant/packed_color_array.hpp>
|
||||
#include <godot_cpp/variant/packed_float32_array.hpp>
|
||||
#include <godot_cpp/variant/packed_int32_array.hpp>
|
||||
#include <godot_cpp/variant/packed_vector2_array.hpp>
|
||||
#include <godot_cpp/variant/packed_vector3_array.hpp>
|
||||
#include <godot_cpp/variant/string_name.hpp>
|
||||
|
||||
|
||||
using namespace godot;
|
||||
|
||||
namespace mtgodot {
|
||||
|
||||
#ifdef MTGODOT_HAVE_PYTHON
|
||||
|
||||
String Metin2PythonHost::start(const String &stdlib_path) {
|
||||
String path = stdlib_path;
|
||||
if (path.is_empty()) {
|
||||
path = Metin2Python::stdlib_path(false);
|
||||
if (path.is_empty()) {
|
||||
return String("python27.zip unavailable: ") + Metin2Python::last_error();
|
||||
}
|
||||
}
|
||||
std::string error;
|
||||
if (!PythonBoot::Start(path.utf8().get_data(), &error)) {
|
||||
return String::utf8(error.c_str());
|
||||
}
|
||||
return String();
|
||||
}
|
||||
|
||||
bool Metin2PythonHost::is_running() {
|
||||
return PythonBoot::IsRunning();
|
||||
}
|
||||
|
||||
String Metin2PythonHost::run_main_script(const String &command_line) {
|
||||
std::string error;
|
||||
if (!PythonBoot::RunMainScript(command_line.utf8().get_data(), &error)) {
|
||||
return String::utf8(error.c_str());
|
||||
}
|
||||
return String();
|
||||
}
|
||||
|
||||
String Metin2PythonHost::run_line(const String &source) {
|
||||
std::string error;
|
||||
if (!PythonBoot::RunLine(source.utf8().get_data(), &error)) {
|
||||
return String::utf8(error.c_str());
|
||||
}
|
||||
return String();
|
||||
}
|
||||
|
||||
// str(expression) from __main__, or "" when it raised (the error goes to the log like run_line's).
|
||||
String Metin2PythonHost::evaluate(const String &expression) {
|
||||
std::string result, error;
|
||||
if (!PythonBoot::Evaluate(expression.utf8().get_data(), &result, &error)) {
|
||||
UtilityFunctions::push_error(String::utf8(error.c_str()));
|
||||
return String();
|
||||
}
|
||||
return String::utf8(result.c_str());
|
||||
}
|
||||
|
||||
bool Metin2PythonHost::is_app_looping() { return PythonBoot::IsAppLooping(); }
|
||||
|
||||
void Metin2PythonHost::set_ui_size(int width, int height) { PythonBoot::SetUISize(width, height); }
|
||||
void Metin2PythonHost::ui_mouse_move(int x, int y) { PythonBoot::UIMouseMove(x, y); }
|
||||
void Metin2PythonHost::ui_mouse_button(int button, bool pressed, int x, int y) {
|
||||
PythonBoot::UIMouseButton(button, pressed, x, y);
|
||||
}
|
||||
void Metin2PythonHost::ui_mouse_wheel(int delta) { PythonBoot::UIMouseWheel(delta); }
|
||||
void Metin2PythonHost::ui_key(int key, bool pressed) { PythonBoot::UIKey(key, pressed); }
|
||||
void Metin2PythonHost::ui_char(int codepoint) { PythonBoot::UIChar(unsigned(codepoint)); }
|
||||
void Metin2PythonHost::ui_ime_key(int vkey) { PythonBoot::UIIMEKeyDown(vkey); }
|
||||
void Metin2PythonHost::ui_update() { PythonBoot::UIUpdate(); }
|
||||
namespace {
|
||||
Dictionary ui_command_item(const UIRenderCommand &command) {
|
||||
static const StringName s_kind("kind");
|
||||
static const StringName s_x1("x1");
|
||||
static const StringName s_y1("y1");
|
||||
static const StringName s_x2("x2");
|
||||
static const StringName s_y2("y2");
|
||||
static const StringName s_argb("argb");
|
||||
static const StringName s_end_argb("end_argb");
|
||||
static const StringName s_clip_x1("clip_x1");
|
||||
static const StringName s_clip_y1("clip_y1");
|
||||
static const StringName s_clip_x2("clip_x2");
|
||||
static const StringName s_clip_y2("clip_y2");
|
||||
static const StringName s_text("text");
|
||||
static const StringName s_quad("quad");
|
||||
static const StringName s_uv("uv");
|
||||
static const StringName s_blend("blend");
|
||||
static const StringName s_mask("mask");
|
||||
static const StringName s_mask_uv("mask_uv");
|
||||
static const StringName s_behind_3d("behind_3d");
|
||||
|
||||
static const StringName s_val_bar("bar");
|
||||
static const StringName s_val_gradient_bar("gradient_bar");
|
||||
static const StringName s_val_line("line");
|
||||
static const StringName s_val_image("image");
|
||||
static const StringName s_val_text("text");
|
||||
Dictionary item;
|
||||
item[s_kind] = command.kind == UIRenderCommand::GradientBar ? s_val_gradient_bar :
|
||||
(command.kind == UIRenderCommand::Bar ? s_val_bar :
|
||||
(command.kind == UIRenderCommand::Line ? s_val_line :
|
||||
(command.kind == UIRenderCommand::Image ? s_val_image : s_val_text)));
|
||||
item[s_x1] = command.x1;
|
||||
item[s_y1] = command.y1;
|
||||
item[s_x2] = command.x2;
|
||||
item[s_y2] = command.y2;
|
||||
item[s_argb] = static_cast<int64_t>(command.argb);
|
||||
if (command.kind == UIRenderCommand::GradientBar)
|
||||
item[s_end_argb] = static_cast<int64_t>(command.end_argb);
|
||||
item[s_clip_x1] = command.clip_x1;
|
||||
item[s_clip_y1] = command.clip_y1;
|
||||
item[s_clip_x2] = command.clip_x2;
|
||||
item[s_clip_y2] = command.clip_y2;
|
||||
if (command.kind == UIRenderCommand::Text || command.kind == UIRenderCommand::Image)
|
||||
item[s_text] = String::utf8(command.text.c_str());
|
||||
if (command.quad) {
|
||||
PackedVector2Array quad;
|
||||
quad.resize(4);
|
||||
Vector2 *qptr = quad.ptrw();
|
||||
for (int i = 0; i < 4; ++i)
|
||||
qptr[i] = Vector2(command.qx[i], command.qy[i]);
|
||||
item[s_quad] = quad;
|
||||
|
||||
PackedVector2Array uv;
|
||||
uv.resize(4);
|
||||
Vector2 *uvptr = uv.ptrw();
|
||||
uvptr[0] = Vector2(command.su, command.sv);
|
||||
uvptr[1] = Vector2(command.eu, command.sv);
|
||||
uvptr[2] = Vector2(command.su, command.ev);
|
||||
uvptr[3] = Vector2(command.eu, command.ev);
|
||||
item[s_uv] = uv;
|
||||
item[s_blend] = command.blend;
|
||||
if (!command.mask.empty()) {
|
||||
item[s_mask] = String::utf8(command.mask.c_str());
|
||||
PackedVector2Array mask_uv;
|
||||
mask_uv.resize(4);
|
||||
Vector2 *mptr = mask_uv.ptrw();
|
||||
for (int i = 0; i < 4; ++i)
|
||||
mptr[i] = Vector2(command.mu[i], command.mv[i]);
|
||||
item[s_mask_uv] = mask_uv;
|
||||
}
|
||||
}
|
||||
item[s_behind_3d] = command.behind_3d;
|
||||
return item;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
Array Metin2PythonHost::ui_render_commands() {
|
||||
PythonBoot::UIRender();
|
||||
static std::uint64_t cached_frame_id = UINT64_MAX;
|
||||
static Array cached_commands;
|
||||
const std::uint64_t frame_id = UIRenderFrameId();
|
||||
if (cached_frame_id == frame_id)
|
||||
return cached_commands;
|
||||
const auto &commands = UIRenderCommands();
|
||||
Array out;
|
||||
out.resize(static_cast<int64_t>(commands.size()));
|
||||
for (size_t idx = 0; idx < commands.size(); ++idx)
|
||||
out[static_cast<int64_t>(idx)] = ui_command_item(commands[idx]);
|
||||
cached_frame_id = frame_id;
|
||||
cached_commands = out;
|
||||
return out;
|
||||
}
|
||||
|
||||
Array Metin2PythonHost::ui_render_commands_batched() {
|
||||
PythonBoot::UIRender();
|
||||
static std::uint64_t cached_frame_id = UINT64_MAX;
|
||||
static Array cached_commands;
|
||||
const std::uint64_t frame_id = UIRenderFrameId();
|
||||
if (cached_frame_id == frame_id)
|
||||
return cached_commands;
|
||||
|
||||
const auto &commands = UIRenderCommands();
|
||||
const auto batchable = [](const UIRenderCommand &c) {
|
||||
return c.kind == UIRenderCommand::Image && c.quad && c.text.rfind("mem:", 0) == 0 &&
|
||||
c.mask.empty() && c.blend == 0 && c.x1 >= c.clip_x1 && c.y1 >= c.clip_y1 &&
|
||||
c.x2 <= c.clip_x2 && c.y2 <= c.clip_y2;
|
||||
};
|
||||
Array out;
|
||||
for (size_t i = 0; i < commands.size();) {
|
||||
const UIRenderCommand &first = commands[i];
|
||||
if (!batchable(first)) {
|
||||
out.push_back(ui_command_item(first));
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
size_t end = i + 1;
|
||||
while (end < commands.size() && batchable(commands[end]) &&
|
||||
commands[end].text == first.text && commands[end].behind_3d == first.behind_3d)
|
||||
++end;
|
||||
if (end == i + 1) {
|
||||
out.push_back(ui_command_item(first));
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
const int64_t count = static_cast<int64_t>(end - i);
|
||||
PackedVector2Array points, uvs;
|
||||
PackedColorArray colors;
|
||||
PackedInt32Array indices;
|
||||
points.resize(count * 4);
|
||||
uvs.resize(count * 4);
|
||||
colors.resize(count * 4);
|
||||
indices.resize(count * 6);
|
||||
Vector2 *point_data = points.ptrw(), *uv_data = uvs.ptrw();
|
||||
Color *color_data = colors.ptrw();
|
||||
int32_t *index_data = indices.ptrw();
|
||||
for (int64_t j = 0; j < count; ++j) {
|
||||
const UIRenderCommand &c = commands[i + static_cast<size_t>(j)];
|
||||
const int64_t v = j * 4, t = j * 6;
|
||||
point_data[v] = Vector2(c.qx[0], c.qy[0]);
|
||||
point_data[v + 1] = Vector2(c.qx[1], c.qy[1]);
|
||||
point_data[v + 2] = Vector2(c.qx[3], c.qy[3]);
|
||||
point_data[v + 3] = Vector2(c.qx[2], c.qy[2]);
|
||||
uv_data[v] = Vector2(c.su, c.sv);
|
||||
uv_data[v + 1] = Vector2(c.eu, c.sv);
|
||||
uv_data[v + 2] = Vector2(c.eu, c.ev);
|
||||
uv_data[v + 3] = Vector2(c.su, c.ev);
|
||||
const Color color(((c.argb >> 16) & 255) / 255.0f, ((c.argb >> 8) & 255) / 255.0f,
|
||||
(c.argb & 255) / 255.0f, ((c.argb >> 24) & 255) / 255.0f);
|
||||
for (int k = 0; k < 4; ++k)
|
||||
color_data[v + k] = color;
|
||||
const int32_t base = static_cast<int32_t>(v);
|
||||
index_data[t] = base;
|
||||
index_data[t + 1] = base + 1;
|
||||
index_data[t + 2] = base + 2;
|
||||
index_data[t + 3] = base;
|
||||
index_data[t + 4] = base + 2;
|
||||
index_data[t + 5] = base + 3;
|
||||
}
|
||||
Dictionary item;
|
||||
item["kind"] = StringName("glyph_batch");
|
||||
item["text"] = String::utf8(first.text.c_str());
|
||||
item["behind_3d"] = first.behind_3d;
|
||||
item["points"] = points;
|
||||
item["uvs"] = uvs;
|
||||
item["colors"] = colors;
|
||||
item["indices"] = indices;
|
||||
item["glyph_count"] = count;
|
||||
out.push_back(item);
|
||||
i = end;
|
||||
}
|
||||
cached_frame_id = frame_id;
|
||||
cached_commands = out;
|
||||
return out;
|
||||
}
|
||||
|
||||
bool Metin2PythonHost::has_3d_draws() { return !Render3DDraws().empty(); }
|
||||
|
||||
Ref<Image> Metin2PythonHost::memory_texture(const String &name, int64_t known_revision) {
|
||||
UIMemoryTexture texture;
|
||||
if (!UIRenderMemoryTexture(name.utf8().get_data(), &texture) || texture.width <= 0 || texture.height <= 0)
|
||||
return Ref<Image>();
|
||||
if (known_revision >= 0 && texture.revision == static_cast<std::uint32_t>(known_revision))
|
||||
return Ref<Image>();
|
||||
PackedByteArray rgba;
|
||||
rgba.resize(static_cast<int64_t>(texture.argb.size()) * 4);
|
||||
uint8_t *out = rgba.ptrw();
|
||||
for (std::uint32_t argb : texture.argb) {
|
||||
*out++ = static_cast<uint8_t>(argb >> 16);
|
||||
*out++ = static_cast<uint8_t>(argb >> 8);
|
||||
*out++ = static_cast<uint8_t>(argb);
|
||||
*out++ = static_cast<uint8_t>(argb >> 24);
|
||||
}
|
||||
return Image::create_from_data(texture.width, texture.height, false, Image::FORMAT_RGBA8, rgba);
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
PackedFloat32Array floats(const float *values, int count) {
|
||||
PackedFloat32Array out;
|
||||
out.resize(count);
|
||||
for (int i = 0; i < count; ++i)
|
||||
out[i] = values[i];
|
||||
return out;
|
||||
}
|
||||
|
||||
Color argb_color(std::uint32_t argb) {
|
||||
return Color(((argb >> 16) & 255) / 255.0f, ((argb >> 8) & 255) / 255.0f, (argb & 255) / 255.0f,
|
||||
((argb >> 24) & 255) / 255.0f);
|
||||
}
|
||||
|
||||
bool read_pack_texture(const std::string &vpath, std::vector<std::uint8_t> &bytes) {
|
||||
if (vpath.empty() || !mtpack40250::ready())
|
||||
return false;
|
||||
std::string norm = vpath;
|
||||
for (char &ch : norm)
|
||||
if (ch == '\\')
|
||||
ch = '/';
|
||||
std::string stripped = norm;
|
||||
if (stripped.size() >= 2 && stripped[1] == ':')
|
||||
stripped = stripped.substr(2);
|
||||
while (!stripped.empty() && stripped.front() == '/')
|
||||
stripped.erase(stripped.begin());
|
||||
auto lower = [](std::string s) {
|
||||
for (char &ch : s)
|
||||
if (ch >= 'A' && ch <= 'Z')
|
||||
ch = static_cast<char>(ch - 'A' + 'a');
|
||||
return s;
|
||||
};
|
||||
for (const std::string &candidate : {
|
||||
norm, stripped, "d:/" + stripped,
|
||||
lower(norm), lower(stripped), lower("d:/" + stripped)}) {
|
||||
if (mtpack40250::read(candidate, bytes) && !bytes.empty())
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Array Metin2PythonHost::render3d_draws() {
|
||||
struct CachedGeometry {
|
||||
std::uint64_t revision;
|
||||
std::uint64_t last_used;
|
||||
Dictionary arrays;
|
||||
};
|
||||
static std::unordered_map<std::uint64_t, CachedGeometry> geometry_cache;
|
||||
static std::uint64_t extraction = 0;
|
||||
++extraction;
|
||||
const auto &draws = Render3DDraws();
|
||||
static bool capture_written = false;
|
||||
if (!capture_written) {
|
||||
const char *capture_path = std::getenv("MT_NATIVE_CAPTURE_PATH");
|
||||
const char *minimum_text = std::getenv("MT_NATIVE_CAPTURE_MIN_DRAWS");
|
||||
const unsigned long minimum = minimum_text ? std::strtoul(minimum_text, nullptr, 10) : 100UL;
|
||||
if (capture_path && *capture_path && draws.size() >= minimum) {
|
||||
capture_written = true;
|
||||
try {
|
||||
std::unordered_map<std::string, std::vector<std::uint8_t>> textures;
|
||||
auto add_texture = [&](const std::string &name) {
|
||||
if (name.empty() || textures.count(name))
|
||||
return;
|
||||
if (name.rfind("mem:", 0) == 0) {
|
||||
UIMemoryTexture mem_tex;
|
||||
if (UIRenderMemoryTexture(name, &mem_tex) && mem_tex.width > 0 && mem_tex.height > 0) {
|
||||
auto mtra = native_draw_capture::encode_raw_argb_as_mtra(
|
||||
static_cast<std::uint32_t>(mem_tex.width),
|
||||
static_cast<std::uint32_t>(mem_tex.height),
|
||||
mem_tex.argb.data());
|
||||
if (!mtra.empty())
|
||||
textures.emplace(name, std::move(mtra));
|
||||
}
|
||||
return;
|
||||
}
|
||||
std::vector<std::uint8_t> bytes;
|
||||
if (read_pack_texture(name, bytes))
|
||||
textures.emplace(name, std::move(bytes));
|
||||
};
|
||||
for (const Render3DDraw &draw : draws) {
|
||||
add_texture(draw.texture0);
|
||||
add_texture(draw.texture1);
|
||||
}
|
||||
const auto &ui_commands = UIRenderCommands();
|
||||
for (const UIRenderCommand &cmd : ui_commands) {
|
||||
if (cmd.kind == UIRenderCommand::Image) {
|
||||
add_texture(cmd.text);
|
||||
add_texture(cmd.mask);
|
||||
}
|
||||
}
|
||||
unsigned ui_w = 0, ui_h = 0;
|
||||
UIRenderGetSize(&ui_w, &ui_h);
|
||||
native_draw_capture::write(
|
||||
capture_path, draws, textures,
|
||||
ui_w ? ui_w : 960u, ui_h ? ui_h : 640u, ui_commands);
|
||||
UtilityFunctions::print(
|
||||
"native draw capture: ", capture_path,
|
||||
" draws=", static_cast<int64_t>(draws.size()),
|
||||
" ui_commands=", static_cast<int64_t>(ui_commands.size()),
|
||||
" textures=", static_cast<int64_t>(textures.size()));
|
||||
} catch (const std::exception &error) {
|
||||
UtilityFunctions::printerr("native draw capture failed: ", error.what());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Array out;
|
||||
out.resize(static_cast<int64_t>(draws.size()));
|
||||
int64_t draw_index = 0;
|
||||
for (const Render3DDraw &draw : draws) {
|
||||
if (draw.clear_flags)
|
||||
continue; // back-buffer clears are consumed by the native renderer only
|
||||
Dictionary item;
|
||||
item["geometry_key"] = static_cast<int64_t>(draw.geometry_key);
|
||||
item["geometry_revision"] = static_cast<int64_t>(draw.geometry_revision);
|
||||
item["world"] = floats(draw.world, 16);
|
||||
item["view"] = floats(draw.view, 16);
|
||||
item["proj"] = floats(draw.proj, 16);
|
||||
item["texture0"] = String::utf8(draw.texture0.c_str());
|
||||
item["texture1"] = String::utf8(draw.texture1.c_str());
|
||||
item["pretransformed"] = draw.pretransformed;
|
||||
item["lines"] = draw.lines;
|
||||
|
||||
// Stage-0 coordinates the D3D8 pipeline generates or transforms depend on per-draw matrices,
|
||||
// so such draws carry them outside the geometry cache.
|
||||
const bool stage0_texgen = (draw.texcoord_index[0] & 0xFFFF0000u) != 0 ||
|
||||
(draw.texcoord_index[0] & 0xFFFFu) != 0 || draw.texture_transform_flags[0] != 0;
|
||||
if (stage0_texgen)
|
||||
item["geometry_key"] = static_cast<int64_t>(0);
|
||||
Dictionary geometry;
|
||||
if (draw.geometry_key != 0 && !stage0_texgen) {
|
||||
auto cached = geometry_cache.find(draw.geometry_key);
|
||||
if (cached != geometry_cache.end() && cached->second.revision == draw.geometry_revision) {
|
||||
cached->second.last_used = extraction;
|
||||
geometry = cached->second.arrays;
|
||||
}
|
||||
}
|
||||
if (geometry.is_empty()) {
|
||||
PackedVector3Array positions;
|
||||
positions.resize(static_cast<int64_t>(draw.positions.size() / 3));
|
||||
if constexpr (sizeof(Vector3) == sizeof(float) * 3 && std::is_trivially_copyable_v<Vector3>) {
|
||||
if (!draw.positions.empty())
|
||||
std::memcpy(positions.ptrw(), draw.positions.data(), draw.positions.size() * sizeof(float));
|
||||
} else {
|
||||
Vector3 *dst = positions.ptrw();
|
||||
for (int64_t i = 0; i < positions.size(); ++i)
|
||||
dst[i] = Vector3(draw.positions[i * 3], draw.positions[i * 3 + 1], draw.positions[i * 3 + 2]);
|
||||
}
|
||||
geometry["positions"] = positions;
|
||||
if (!draw.rhw.empty())
|
||||
geometry["rhw"] = floats(draw.rhw.data(), static_cast<int>(draw.rhw.size()));
|
||||
if (!draw.normals.empty()) {
|
||||
PackedVector3Array normals;
|
||||
normals.resize(static_cast<int64_t>(draw.normals.size() / 3));
|
||||
if constexpr (sizeof(Vector3) == sizeof(float) * 3 && std::is_trivially_copyable_v<Vector3>) {
|
||||
std::memcpy(normals.ptrw(), draw.normals.data(), draw.normals.size() * sizeof(float));
|
||||
} else {
|
||||
Vector3 *dst = normals.ptrw();
|
||||
for (int64_t i = 0; i < normals.size(); ++i)
|
||||
dst[i] = Vector3(draw.normals[i * 3], draw.normals[i * 3 + 1], draw.normals[i * 3 + 2]);
|
||||
}
|
||||
geometry["normals"] = normals;
|
||||
}
|
||||
auto uvs = [](const std::vector<float> &values) {
|
||||
PackedVector2Array out;
|
||||
out.resize(static_cast<int64_t>(values.size() / 2));
|
||||
if constexpr (sizeof(Vector2) == sizeof(float) * 2 && std::is_trivially_copyable_v<Vector2>) {
|
||||
if (!values.empty())
|
||||
std::memcpy(out.ptrw(), values.data(), values.size() * sizeof(float));
|
||||
} else {
|
||||
Vector2 *dst = out.ptrw();
|
||||
for (int64_t i = 0; i < out.size(); ++i)
|
||||
dst[i] = Vector2(values[i * 2], values[i * 2 + 1]);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
if (stage0_texgen) {
|
||||
std::vector<float> stage_uv;
|
||||
Render3DStageTexcoords(draw, 0, stage_uv);
|
||||
geometry["uv0"] = uvs(stage_uv);
|
||||
} else if (!draw.uv0.empty())
|
||||
geometry["uv0"] = uvs(draw.uv0);
|
||||
if (!draw.uv1.empty())
|
||||
geometry["uv1"] = uvs(draw.uv1);
|
||||
if (!draw.diffuse.empty()) {
|
||||
PackedColorArray colors;
|
||||
colors.resize(static_cast<int64_t>(draw.diffuse.size()));
|
||||
for (int64_t i = 0; i < colors.size(); ++i)
|
||||
colors[i] = argb_color(draw.diffuse[i]);
|
||||
geometry["diffuse"] = colors;
|
||||
}
|
||||
PackedInt32Array indices;
|
||||
indices.resize(static_cast<int64_t>(draw.indices.size()));
|
||||
for (int64_t i = 0; i < indices.size(); ++i)
|
||||
indices[i] = static_cast<int32_t>(draw.indices[i]);
|
||||
geometry["indices"] = indices;
|
||||
if (draw.geometry_key != 0 && !stage0_texgen)
|
||||
geometry_cache[draw.geometry_key] = {draw.geometry_revision, extraction, geometry};
|
||||
}
|
||||
item.merge(geometry);
|
||||
|
||||
item["alpha_blend"] = static_cast<int64_t>(draw.alpha_blend);
|
||||
item["src_blend"] = static_cast<int64_t>(draw.src_blend);
|
||||
item["dest_blend"] = static_cast<int64_t>(draw.dest_blend);
|
||||
item["alpha_test"] = static_cast<int64_t>(draw.alpha_test);
|
||||
item["alpha_ref"] = static_cast<int64_t>(draw.alpha_ref);
|
||||
item["alpha_func"] = static_cast<int64_t>(draw.alpha_func);
|
||||
item["cull_mode"] = static_cast<int64_t>(draw.cull_mode);
|
||||
item["z_enable"] = static_cast<int64_t>(draw.z_enable);
|
||||
item["z_write"] = static_cast<int64_t>(draw.z_write);
|
||||
item["z_func"] = static_cast<int64_t>(draw.z_func);
|
||||
item["lighting"] = static_cast<int64_t>(draw.lighting);
|
||||
item["texture_factor"] = static_cast<int64_t>(draw.texture_factor);
|
||||
bool uses_tf = false;
|
||||
for (int s = 0; s < 2; ++s) {
|
||||
if (draw.color_op[s] > 1 &&
|
||||
(((draw.color_arg1[s] & 0xF) == 3) || ((draw.color_arg2[s] & 0xF) == 3)))
|
||||
uses_tf = true;
|
||||
if (draw.alpha_op[s] > 1 &&
|
||||
(((draw.alpha_arg1[s] & 0xF) == 3) || ((draw.alpha_arg2[s] & 0xF) == 3)))
|
||||
uses_tf = true;
|
||||
}
|
||||
item["uses_tf"] = uses_tf;
|
||||
item["fog_enable"] = static_cast<int64_t>(draw.fog_enable);
|
||||
item["color_op"] = static_cast<int64_t>(draw.color_op[0]);
|
||||
item["alpha_op"] = static_cast<int64_t>(draw.alpha_op[0]);
|
||||
item["material_diffuse"] = Color(draw.material_diffuse[0], draw.material_diffuse[1],
|
||||
draw.material_diffuse[2], draw.material_diffuse[3]);
|
||||
item["material_ambient"] = Color(draw.material_ambient[0], draw.material_ambient[1],
|
||||
draw.material_ambient[2], draw.material_ambient[3]);
|
||||
item["material_emissive"] = Color(draw.material_emissive[0], draw.material_emissive[1],
|
||||
draw.material_emissive[2], draw.material_emissive[3]);
|
||||
item["light0"] = draw.light0;
|
||||
item["light0_direction"] = Vector3(draw.light0_direction[0], draw.light0_direction[1],
|
||||
draw.light0_direction[2]);
|
||||
item["light0_diffuse"] = Color(draw.light0_diffuse[0], draw.light0_diffuse[1], draw.light0_diffuse[2],
|
||||
draw.light0_diffuse[3]);
|
||||
item["light0_ambient"] = Color(draw.light0_ambient[0], draw.light0_ambient[1], draw.light0_ambient[2],
|
||||
draw.light0_ambient[3]);
|
||||
item["ambient"] = argb_color(draw.ambient);
|
||||
PackedFloat32Array vp;
|
||||
vp.resize(4);
|
||||
vp[0] = draw.viewport[0];
|
||||
vp[1] = draw.viewport[1];
|
||||
vp[2] = draw.viewport[2];
|
||||
vp[3] = draw.viewport[3];
|
||||
item["viewport"] = vp;
|
||||
out[draw_index++] = item;
|
||||
}
|
||||
out.resize(draw_index);
|
||||
if (extraction % 120 == 0) {
|
||||
for (auto it = geometry_cache.begin(); it != geometry_cache.end();) {
|
||||
if (extraction - it->second.last_used > 120)
|
||||
it = geometry_cache.erase(it);
|
||||
else
|
||||
++it;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Array Metin2PythonHost::audio_commands() {
|
||||
Array out;
|
||||
for (const auto &cmd : DrainAudioCommands()) {
|
||||
Dictionary item;
|
||||
switch (cmd.type) {
|
||||
case AudioCommand::PlaySound2D: item["type"] = "play_sound_2d"; break;
|
||||
case AudioCommand::PlaySound3D: item["type"] = "play_sound_3d"; break;
|
||||
case AudioCommand::StopSound3D: item["type"] = "stop_sound_3d"; break;
|
||||
case AudioCommand::StopAllSound3D: item["type"] = "stop_all_sound_3d"; break;
|
||||
case AudioCommand::PlayMusic: item["type"] = "play_music"; break;
|
||||
case AudioCommand::FadeOutMusic: item["type"] = "fade_out_music"; break;
|
||||
case AudioCommand::FadeOutAllMusic: item["type"] = "fade_out_all_music"; break;
|
||||
case AudioCommand::SetSoundVolume: item["type"] = "set_sound_volume"; break;
|
||||
case AudioCommand::SetMusicVolume: item["type"] = "set_music_volume"; break;
|
||||
case AudioCommand::FadeInMusic: item["type"] = "fade_in_music"; break;
|
||||
case AudioCommand::FadeLimitOutMusic: item["type"] = "fade_limit_out_music"; break;
|
||||
case AudioCommand::SetSoundVolume3D: item["type"] = "set_sound_volume_3d"; break;
|
||||
default: continue;
|
||||
}
|
||||
item["filename"] = String::utf8(cmd.filename.c_str());
|
||||
item["x"] = cmd.x;
|
||||
item["y"] = cmd.y;
|
||||
item["z"] = cmd.z;
|
||||
item["volume"] = cmd.volume;
|
||||
item["speed"] = cmd.speed;
|
||||
item["play_count"] = cmd.play_count;
|
||||
item["id"] = cmd.id;
|
||||
out.append(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
String Metin2PythonHost::current_map_name() {
|
||||
return String::utf8(PythonBoot::CurrentMapName().c_str());
|
||||
}
|
||||
|
||||
void Metin2PythonHost::stop() {
|
||||
PythonBoot::Stop();
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
// Built without -DMTGODOT_EMBED_PYTHON=ON: the class is still registered so GDScript can tell "no
|
||||
// interpreter in this build" apart from "the interpreter failed to start".
|
||||
String Metin2PythonHost::start(const String &) {
|
||||
return String("this build has no embedded Python (-DMTGODOT_EMBED_PYTHON=ON)");
|
||||
}
|
||||
|
||||
bool Metin2PythonHost::is_running() {
|
||||
return false;
|
||||
}
|
||||
|
||||
String Metin2PythonHost::run_main_script(const String &) {
|
||||
return String("this build has no embedded Python (-DMTGODOT_EMBED_PYTHON=ON)");
|
||||
}
|
||||
|
||||
String Metin2PythonHost::run_line(const String &) {
|
||||
return String("this build has no embedded Python (-DMTGODOT_EMBED_PYTHON=ON)");
|
||||
}
|
||||
|
||||
String Metin2PythonHost::evaluate(const String &) { return String(); }
|
||||
|
||||
bool Metin2PythonHost::is_app_looping() { return false; }
|
||||
void Metin2PythonHost::set_ui_size(int, int) {}
|
||||
void Metin2PythonHost::ui_mouse_move(int, int) {}
|
||||
void Metin2PythonHost::ui_mouse_button(int, bool, int, int) {}
|
||||
void Metin2PythonHost::ui_mouse_wheel(int) {}
|
||||
void Metin2PythonHost::ui_key(int, bool) {}
|
||||
void Metin2PythonHost::ui_char(int) {}
|
||||
void Metin2PythonHost::ui_ime_key(int) {}
|
||||
void Metin2PythonHost::ui_update() {}
|
||||
Array Metin2PythonHost::ui_render_commands() { return Array(); }
|
||||
Array Metin2PythonHost::ui_render_commands_batched() { return Array(); }
|
||||
bool Metin2PythonHost::has_3d_draws() { return false; }
|
||||
Ref<Image> Metin2PythonHost::memory_texture(const String &, int64_t) { return Ref<Image>(); }
|
||||
Array Metin2PythonHost::render3d_draws() { return Array(); }
|
||||
Array Metin2PythonHost::audio_commands() { return Array(); }
|
||||
String Metin2PythonHost::current_map_name() { return String(); }
|
||||
|
||||
void Metin2PythonHost::stop() {
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
void Metin2PythonHost::_bind_methods() {
|
||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("start", "stdlib_path"),
|
||||
&Metin2PythonHost::start, DEFVAL(String()));
|
||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("is_running"), &Metin2PythonHost::is_running);
|
||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("evaluate", "expression"), &Metin2PythonHost::evaluate);
|
||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("run_main_script", "command_line"),
|
||||
&Metin2PythonHost::run_main_script, DEFVAL(String()));
|
||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("run_line", "source"), &Metin2PythonHost::run_line);
|
||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("is_app_looping"), &Metin2PythonHost::is_app_looping);
|
||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("set_ui_size", "width", "height"), &Metin2PythonHost::set_ui_size);
|
||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("ui_mouse_move", "x", "y"), &Metin2PythonHost::ui_mouse_move);
|
||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("ui_mouse_button", "button", "pressed", "x", "y"), &Metin2PythonHost::ui_mouse_button);
|
||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("ui_mouse_wheel", "delta"), &Metin2PythonHost::ui_mouse_wheel);
|
||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("ui_key", "key", "pressed"), &Metin2PythonHost::ui_key);
|
||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("ui_char", "codepoint"), &Metin2PythonHost::ui_char);
|
||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("ui_ime_key", "vkey"), &Metin2PythonHost::ui_ime_key);
|
||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("ui_update"), &Metin2PythonHost::ui_update);
|
||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("ui_render_commands"), &Metin2PythonHost::ui_render_commands);
|
||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("ui_render_commands_batched"), &Metin2PythonHost::ui_render_commands_batched);
|
||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("has_3d_draws"), &Metin2PythonHost::has_3d_draws);
|
||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("memory_texture", "name", "known_revision"),
|
||||
&Metin2PythonHost::memory_texture, DEFVAL(-1));
|
||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("render3d_draws"), &Metin2PythonHost::render3d_draws);
|
||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("audio_commands"), &Metin2PythonHost::audio_commands);
|
||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("current_map_name"), &Metin2PythonHost::current_map_name);
|
||||
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("stop"), &Metin2PythonHost::stop);
|
||||
}
|
||||
|
||||
} // namespace mtgodot
|
||||
@@ -1,66 +0,0 @@
|
||||
#pragma once
|
||||
// Metin2PythonHost — the 40250 script layer inside the Godot process (docs/PORT-PLAN.md 批次 2P step 4).
|
||||
//
|
||||
// Metin2Pack.initialize(AssetRoot.client_path()) # the packs system.py is read from
|
||||
// if Metin2PythonHost.start() == "":
|
||||
// var err := Metin2PythonHost.run_main_script("")
|
||||
//
|
||||
// Both calls answer with "" on success and with the reason otherwise, so GDScript never has to guess
|
||||
// from a bool. 2V0-e reaches prototype.RunApp(); until 2V0-f implements the application adapter,
|
||||
// app.Create reports CREATE_DEVICE and the original script displays its error popup.
|
||||
//
|
||||
// This is the only unit that knows both godot-cpp and the port tree: the sequence itself lives in
|
||||
// platform/ScriptLib/PythonBoot.h, which stays godot-free.
|
||||
|
||||
#include <godot_cpp/classes/object.hpp>
|
||||
#include <godot_cpp/variant/string.hpp>
|
||||
#include <godot_cpp/variant/array.hpp>
|
||||
#include <godot_cpp/classes/image.hpp>
|
||||
#include <godot_cpp/classes/ref.hpp>
|
||||
|
||||
namespace mtgodot {
|
||||
|
||||
class Metin2PythonHost : public godot::Object {
|
||||
GDCLASS(Metin2PythonHost, godot::Object)
|
||||
|
||||
public:
|
||||
// Stages the standard library (Metin2Python::stdlib_path) when `stdlib_path` is empty, then starts
|
||||
// the interpreter. "" on success, including when it was already running.
|
||||
static godot::String start(const godot::String &stdlib_path);
|
||||
static bool is_running();
|
||||
// __DEBUG__, __COMMAND_LINE__ and system.py out of the packs. "" when system.py ran to the end.
|
||||
static godot::String run_main_script(const godot::String &command_line);
|
||||
// One line of Python in the same __main__ dictionary. "" when it ran.
|
||||
static godot::String run_line(const godot::String &source);
|
||||
static godot::String evaluate(const godot::String &expression);
|
||||
// True while system.py is inside app.Loop(); ui_update() then runs one CPythonApplication::Process().
|
||||
static bool is_app_looping();
|
||||
static void set_ui_size(int width, int height);
|
||||
static void ui_mouse_move(int x, int y);
|
||||
static void ui_mouse_button(int button, bool pressed, int x, int y);
|
||||
static void ui_mouse_wheel(int delta);
|
||||
static void ui_key(int key, bool pressed);
|
||||
// WM_CHAR (Unicode code point) and WM_KEYDOWN (Win32 VK code) for the 40250 IME.
|
||||
static void ui_char(int codepoint);
|
||||
static void ui_ime_key(int vkey);
|
||||
static void ui_update();
|
||||
static godot::Array ui_render_commands();
|
||||
// Ordered UI commands with consecutive, unclipped font quads packed into triangle arrays.
|
||||
static godot::Array ui_render_commands_batched();
|
||||
static bool has_3d_draws();
|
||||
// The pixels of a "mem:<id>@<revision>" image command (the CGraphicFontTexture glyph pages) as an
|
||||
// RGBA8 Image; null when the texture is gone or its revision is still `known_revision`.
|
||||
static godot::Ref<godot::Image> memory_texture(const godot::String &name, int64_t known_revision);
|
||||
// The 3D draw calls of the last ui_update() (RenderCommands3D.h), one Dictionary each: "world",
|
||||
// "view", "proj" (PackedFloat32Array, D3D row-vector layout), vertex arrays, "indices", "texture0"
|
||||
// and the blend/depth/cull/lighting state.
|
||||
static godot::Array render3d_draws();
|
||||
static godot::Array audio_commands();
|
||||
static godot::String current_map_name();
|
||||
static void stop();
|
||||
|
||||
protected:
|
||||
static void _bind_methods();
|
||||
};
|
||||
|
||||
} // namespace mtgodot
|
||||
@@ -1,133 +0,0 @@
|
||||
#include "python_stdlib.h"
|
||||
|
||||
#include <godot_cpp/classes/dir_access.hpp>
|
||||
#include <godot_cpp/classes/file_access.hpp>
|
||||
#include <godot_cpp/classes/project_settings.hpp>
|
||||
#include <godot_cpp/core/class_db.hpp>
|
||||
#include <godot_cpp/variant/utility_functions.hpp>
|
||||
|
||||
#include <string>
|
||||
#include <cstdio>
|
||||
|
||||
#ifdef MTGODOT_HAVE_PYTHON
|
||||
#include "platform/ScriptLib/PythonHost.h"
|
||||
#endif
|
||||
|
||||
using namespace godot;
|
||||
|
||||
namespace mtgodot {
|
||||
|
||||
namespace {
|
||||
|
||||
// Both ship in the PCK; the build copies them next to project.godot (extension/CMakeLists.txt).
|
||||
constexpr const char *ZIP_RES = "res://python27.zip";
|
||||
constexpr const char *SHA_RES = "res://python27.zip.sha256";
|
||||
constexpr const char *ZIP_USER = "user://python27.zip";
|
||||
constexpr const char *ZIP_USER_TMP = "user://python27.zip.part";
|
||||
|
||||
// std::string, not String: this lives at file scope and a godot::String's constructor would run at
|
||||
// dlopen time, before the extension's bindings exist.
|
||||
std::string g_last_error;
|
||||
|
||||
String fail(String *error, const String &text) {
|
||||
g_last_error = text.utf8().get_data();
|
||||
if (error) {
|
||||
*error = text;
|
||||
}
|
||||
return String();
|
||||
}
|
||||
|
||||
String expected_sha() {
|
||||
if (!FileAccess::file_exists(SHA_RES)) {
|
||||
return String();
|
||||
}
|
||||
return FileAccess::get_file_as_string(SHA_RES).strip_edges();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
String stage_python_stdlib(String *error, bool force_stage) {
|
||||
g_last_error.clear();
|
||||
if (error) {
|
||||
*error = String();
|
||||
}
|
||||
if (!FileAccess::file_exists(ZIP_RES)) {
|
||||
return fail(error, String("no ") + ZIP_RES +
|
||||
" (build with -DMTGODOT_EMBED_PYTHON=ON, which copies it into project/)");
|
||||
}
|
||||
|
||||
ProjectSettings *settings = ProjectSettings::get_singleton();
|
||||
// Desktop and the editor: res:// is a directory on disk, so CPython can open the file in place and
|
||||
// nothing is copied. In an exported game globalize_path() returns a path next to the executable
|
||||
// that does not exist, which is exactly the case the staging below is for.
|
||||
const String in_place = settings->globalize_path(ZIP_RES);
|
||||
if (!force_stage && !in_place.begins_with("res://")) {
|
||||
// FileAccess may resolve an exported PCK entry through a relative globalized path. CPython
|
||||
// uses fopen(), so only return the path when libc can open a real on-disk zip there.
|
||||
if (FILE *file = std::fopen(in_place.utf8().get_data(), "rb")) {
|
||||
std::fclose(file);
|
||||
return in_place;
|
||||
}
|
||||
}
|
||||
|
||||
const String want = expected_sha();
|
||||
if (want.is_empty()) {
|
||||
return fail(error, String("no ") + SHA_RES + " beside the stdlib zip");
|
||||
}
|
||||
|
||||
// An already-staged copy is reused only when it is the zip this build ships: a new version of the
|
||||
// app brings a new digest, and a first start that was killed mid-copy leaves a short file.
|
||||
if (FileAccess::file_exists(ZIP_USER) && FileAccess::get_sha256(ZIP_USER) == want) {
|
||||
return settings->globalize_path(ZIP_USER);
|
||||
}
|
||||
|
||||
const PackedByteArray data = FileAccess::get_file_as_bytes(ZIP_RES);
|
||||
if (data.is_empty()) {
|
||||
return fail(error, String("cannot read ") + ZIP_RES);
|
||||
}
|
||||
{
|
||||
Ref<FileAccess> out = FileAccess::open(ZIP_USER_TMP, FileAccess::WRITE);
|
||||
if (out.is_null()) {
|
||||
return fail(error, String("cannot write ") + ZIP_USER_TMP + ": " +
|
||||
itos((int) FileAccess::get_open_error()));
|
||||
}
|
||||
out->store_buffer(data);
|
||||
out->close();
|
||||
}
|
||||
// Hash the file as written, not the buffer: a full disk reports itself here and not later as a
|
||||
// zipimport failure three imports into system.py.
|
||||
if (FileAccess::get_sha256(ZIP_USER_TMP) != want) {
|
||||
DirAccess::remove_absolute(ZIP_USER_TMP);
|
||||
return fail(error, String("staged ") + ZIP_USER + " does not match " + want);
|
||||
}
|
||||
if (FileAccess::file_exists(ZIP_USER)) {
|
||||
DirAccess::remove_absolute(ZIP_USER);
|
||||
}
|
||||
if (DirAccess::rename_absolute(ZIP_USER_TMP, ZIP_USER) != OK) {
|
||||
return fail(error, String("cannot rename ") + ZIP_USER_TMP + " to " + ZIP_USER);
|
||||
}
|
||||
UtilityFunctions::print("[python27] staged ", ZIP_USER, " (", data.size(), " bytes, sha256 ", want, ")");
|
||||
return settings->globalize_path(ZIP_USER);
|
||||
}
|
||||
|
||||
String Metin2Python::stdlib_path(bool force_stage) {
|
||||
const String path = stage_python_stdlib(nullptr, force_stage);
|
||||
#ifdef MTGODOT_HAVE_PYTHON
|
||||
if (!path.is_empty()) {
|
||||
PythonHost::SetDefaultStdLibPath(path.utf8().get_data());
|
||||
}
|
||||
#endif
|
||||
return path;
|
||||
}
|
||||
|
||||
String Metin2Python::last_error() {
|
||||
return String::utf8(g_last_error.c_str());
|
||||
}
|
||||
|
||||
void Metin2Python::_bind_methods() {
|
||||
ClassDB::bind_static_method("Metin2Python", D_METHOD("stdlib_path", "force_stage"),
|
||||
&Metin2Python::stdlib_path, DEFVAL(false));
|
||||
ClassDB::bind_static_method("Metin2Python", D_METHOD("last_error"), &Metin2Python::last_error);
|
||||
}
|
||||
|
||||
} // namespace mtgodot
|
||||
@@ -1,37 +0,0 @@
|
||||
#pragma once
|
||||
// python_stdlib — where the embedded interpreter's standard library is on this platform
|
||||
// (docs/PORT-PLAN.md 批次 2P step 3c).
|
||||
//
|
||||
// CPython opens python27.zip with its own stdio, so it needs a real filesystem path. That is free on
|
||||
// the desktop, where res:// is a directory on disk, but on Android and iOS res:// is inside the PCK,
|
||||
// which nothing outside Godot can read. There the zip is copied once into the sandbox (user://) and
|
||||
// the copy is accepted only when it hashes to the digest built beside the zip, so an interrupted
|
||||
// first start or a zip replaced by a new build re-stages instead of feeding zipimport a torn file.
|
||||
//
|
||||
// Only this file knows about res:// / user://: PythonHost (platform/ScriptLib) stays godot-free and
|
||||
// is told the path, through PythonHost::SetDefaultStdLibPath.
|
||||
|
||||
#include <godot_cpp/classes/object.hpp>
|
||||
#include <godot_cpp/variant/string.hpp>
|
||||
|
||||
namespace mtgodot {
|
||||
|
||||
// The real filesystem path of python27.zip, staging it out of res:// when res:// is not on disk.
|
||||
// Empty when the zip is missing or the staged copy does not verify; the reason goes to *error.
|
||||
// force_stage runs the mobile path even where res:// is readable, which is how it is tested.
|
||||
godot::String stage_python_stdlib(godot::String *error = nullptr, bool force_stage = false);
|
||||
|
||||
class Metin2Python : public godot::Object {
|
||||
GDCLASS(Metin2Python, godot::Object)
|
||||
|
||||
public:
|
||||
// Returns the path and, when the embedded interpreter is built in, hands it to PythonHost so
|
||||
// PythonHost::DefaultStdLibPath() answers with it from then on.
|
||||
static godot::String stdlib_path(bool force_stage);
|
||||
static godot::String last_error();
|
||||
|
||||
protected:
|
||||
static void _bind_methods();
|
||||
};
|
||||
|
||||
} // namespace mtgodot
|
||||
@@ -1,82 +0,0 @@
|
||||
#include "register_types.h"
|
||||
|
||||
#include <gdextension_interface.h>
|
||||
#include <godot_cpp/core/defs.hpp>
|
||||
#include <godot_cpp/godot.hpp>
|
||||
|
||||
#include "asset_io.h"
|
||||
#include "metin2_anim.h"
|
||||
#include "metin2_model.h"
|
||||
#include "metin2_world.h"
|
||||
#include "m2_material.h"
|
||||
#include "net/m2_client.h"
|
||||
#include "pack40250_node.h"
|
||||
#include "proto/proto_node.h"
|
||||
#include "python_host_node.h"
|
||||
#include "python_stdlib.h"
|
||||
#include "static_object.h"
|
||||
#include "terrain_splat.h"
|
||||
#include "tree_placeholder.h"
|
||||
#include "water_builder.h"
|
||||
|
||||
#include <m2_tokvec.h> // fmt::set_file_reader
|
||||
#include <string>
|
||||
|
||||
using namespace godot;
|
||||
|
||||
// formats/ reads every asset (maps, .msenv, .msm/.msa via textscript, .spt, …)
|
||||
// through fmt::read_file. Route it through godot::FileAccess so it works from
|
||||
// res:// (the PCK) on the read-only iOS/Android bundles as well as loose dev files.
|
||||
static bool mt_fmt_read_file(const std::string &path, std::string &out) {
|
||||
PackedByteArray b = mtgodot::read_file(String::utf8(path.c_str()));
|
||||
if (b.is_empty()) {
|
||||
return false;
|
||||
}
|
||||
out.assign(reinterpret_cast<const char *>(b.ptr()), (size_t)b.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
void initialize_mtgodot_module(ModuleInitializationLevel p_level) {
|
||||
if (p_level != MODULE_INITIALIZATION_LEVEL_SCENE) {
|
||||
return;
|
||||
}
|
||||
fmt::set_file_reader(&mt_fmt_read_file);
|
||||
GDREGISTER_CLASS(mtgodot::Metin2Model);
|
||||
GDREGISTER_CLASS(mtgodot::Metin2AnimPlayer);
|
||||
GDREGISTER_CLASS(mtgodot::Metin2World);
|
||||
GDREGISTER_CLASS(mtgodot::M2Client);
|
||||
GDREGISTER_CLASS(mtgodot::Metin2Proto);
|
||||
GDREGISTER_CLASS(mtgodot::Metin2Pack);
|
||||
GDREGISTER_CLASS(mtgodot::Metin2Python);
|
||||
GDREGISTER_CLASS(mtgodot::Metin2PythonHost);
|
||||
}
|
||||
|
||||
void uninitialize_mtgodot_module(ModuleInitializationLevel p_level) {
|
||||
if (p_level == MODULE_INITIALIZATION_LEVEL_SCENE) {
|
||||
fmt::set_file_reader(nullptr);
|
||||
mtgodot::Metin2AnimPlayer::clear_clip_cache();
|
||||
// CPythonApplication::Destroy / CResourceManager teardown releases
|
||||
// model-owned decoded textures before Godot's ObjectDB is destroyed.
|
||||
// Leaving this static Ref cache alive makes renderer shutdown report
|
||||
// leaked ImageTexture objects after a real model/map scenario.
|
||||
mtgodot::Metin2Model::clear_texture_cache();
|
||||
mtgodot::cleanup_material_shaders();
|
||||
mtgodot::cleanup_terrain_shader();
|
||||
mtgodot::cleanup_tree_shader();
|
||||
mtgodot::cleanup_static_object_cache();
|
||||
mtgodot::cleanup_water_shader();
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" {
|
||||
GDExtensionBool GDE_EXPORT mtgodot_library_init(
|
||||
GDExtensionInterfaceGetProcAddress p_get_proc_address,
|
||||
const GDExtensionClassLibraryPtr p_library,
|
||||
GDExtensionInitialization *r_initialization) {
|
||||
godot::GDExtensionBinding::InitObject init_obj(p_get_proc_address, p_library, r_initialization);
|
||||
init_obj.register_initializer(initialize_mtgodot_module);
|
||||
init_obj.register_terminator(uninitialize_mtgodot_module);
|
||||
init_obj.set_minimum_library_initialization_level(MODULE_INITIALIZATION_LEVEL_SCENE);
|
||||
return init_obj.init();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <godot_cpp/core/class_db.hpp>
|
||||
|
||||
void initialize_mtgodot_module(godot::ModuleInitializationLevel p_level);
|
||||
void uninitialize_mtgodot_module(godot::ModuleInitializationLevel p_level);
|
||||
@@ -1,163 +0,0 @@
|
||||
#include "static_object.h"
|
||||
|
||||
#include "asset_io.h"
|
||||
#include "dxt.h"
|
||||
#include "gr2_bridge.h"
|
||||
#include "texture_util.h"
|
||||
|
||||
#include <godot_cpp/classes/image.hpp>
|
||||
#include <godot_cpp/classes/image_texture.hpp>
|
||||
#include <godot_cpp/classes/standard_material3d.hpp>
|
||||
#include <godot_cpp/variant/packed_byte_array.hpp>
|
||||
#include <godot_cpp/variant/utility_functions.hpp>
|
||||
|
||||
#include <asset_resolver.h>
|
||||
#include <gr2/gr2.h>
|
||||
|
||||
#include <unordered_map>
|
||||
|
||||
using namespace godot;
|
||||
|
||||
namespace mtgodot {
|
||||
|
||||
namespace {
|
||||
|
||||
std::unordered_map<std::string, Ref<ImageTexture>> g_dds_cache;
|
||||
|
||||
// Building albedo (sRGB colour) -> mobile-ASTC-eligible via make_color_texture
|
||||
// (no-op on desktop). mipmaps=false keeps the desktop result byte-identical to
|
||||
// the old path (buildings had no mip chain). §F4.
|
||||
Ref<ImageTexture> decode_dds_cached(const std::string &real_path) {
|
||||
auto &cache = g_dds_cache;
|
||||
auto it = cache.find(real_path);
|
||||
if (it != cache.end())
|
||||
return it->second;
|
||||
Ref<ImageTexture> tex;
|
||||
mtgodot::Image d = mtgodot::dds_from_file(godot::String(real_path.c_str()));
|
||||
if (d.ok()) {
|
||||
tex = mtgodot::make_color_texture(d.w, d.h, d.rgba.data(), d.rgba.size(),
|
||||
/*mipmaps=*/false);
|
||||
}
|
||||
cache.emplace(real_path, tex);
|
||||
return tex;
|
||||
}
|
||||
|
||||
Ref<StandardMaterial3D> material_for(const std::string &tex_name, bool alpha_blend, bool two_sided,
|
||||
const fmt::AssetResolver &res) {
|
||||
Ref<StandardMaterial3D> mat;
|
||||
mat.instantiate();
|
||||
mat->set_albedo(Color(0.7f, 0.7f, 0.7f));
|
||||
mat->set_roughness(1.0f);
|
||||
mat->set_texture_filter(StandardMaterial3D::TEXTURE_FILTER_LINEAR_WITH_MIPMAPS_ANISOTROPIC);
|
||||
mat->set_cull_mode(StandardMaterial3D::CULL_DISABLED);
|
||||
mat->set_diffuse_mode(StandardMaterial3D::DIFFUSE_LAMBERT_WRAP);
|
||||
mat->set_specular_mode(StandardMaterial3D::SPECULAR_DISABLED);
|
||||
mat->set_feature(StandardMaterial3D::FEATURE_BACKLIGHT, true);
|
||||
mat->set_backlight(Color(0.2f, 0.2f, 0.2f));
|
||||
|
||||
String t = String(tex_name.c_str()).to_lower();
|
||||
bool kw_alpha = t.find("leaf") != -1 || t.find("grass") != -1 || t.find("fence") != -1 ||
|
||||
t.find("net") != -1 || t.find("ivy") != -1 || t.find("tree") != -1 ||
|
||||
t.find("branch") != -1;
|
||||
if (alpha_blend) {
|
||||
// EterGrnLib TYPE_BLEND_PNT:真 alpha 混合(第 2 map 作 opacity)
|
||||
mat->set_transparency(StandardMaterial3D::TRANSPARENCY_ALPHA);
|
||||
mat->set_cull_mode(StandardMaterial3D::CULL_DISABLED);
|
||||
} else if (kw_alpha) {
|
||||
mat->set_transparency(StandardMaterial3D::TRANSPARENCY_ALPHA_SCISSOR);
|
||||
mat->set_alpha_scissor_threshold(0.5f);
|
||||
mat->set_cull_mode(StandardMaterial3D::CULL_DISABLED);
|
||||
}
|
||||
if (two_sided)
|
||||
mat->set_cull_mode(StandardMaterial3D::CULL_DISABLED);
|
||||
|
||||
if (tex_name.empty())
|
||||
return mat;
|
||||
std::string rp = res.resolve(tex_name, nullptr);
|
||||
if (rp.empty()) {
|
||||
// gr2 里常是 .dds;有时资产用别的大小写 / 扩展。先只试原名。
|
||||
return mat;
|
||||
}
|
||||
Ref<ImageTexture> tex = decode_dds_cached(rp);
|
||||
if (tex.is_valid()) {
|
||||
mat->set_texture(StandardMaterial3D::TEXTURE_ALBEDO, tex);
|
||||
mat->set_albedo(Color(1, 1, 1));
|
||||
mat->set_feature(StandardMaterial3D::FEATURE_EMISSION, true);
|
||||
mat->set_texture(StandardMaterial3D::TEXTURE_EMISSION, tex);
|
||||
mat->set_emission(Color(1.0f, 1.0f, 1.0f));
|
||||
mat->set_emission_energy_multiplier(0.18f);
|
||||
}
|
||||
return mat;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Ref<godot::ArrayMesh> get_static_mesh(const std::string &real_gr2_path,
|
||||
const fmt::AssetResolver &res, StaticMeshCache &cache) {
|
||||
auto it = cache.by_path.find(real_gr2_path);
|
||||
if (it != cache.by_path.end())
|
||||
return it->second;
|
||||
|
||||
Ref<godot::ArrayMesh> result; // invalid until success
|
||||
|
||||
gr2::LoadError err;
|
||||
auto loaded = mtgodot::gr2_from_file(godot::String(real_gr2_path.c_str()), &err);
|
||||
if (!loaded) {
|
||||
++cache.failed;
|
||||
UtilityFunctions::push_warning(String("[static] gr2 load failed: ") +
|
||||
real_gr2_path.c_str() + " (" + err.message.c_str() + ")");
|
||||
cache.by_path.emplace(real_gr2_path, result);
|
||||
return result;
|
||||
}
|
||||
const gr2::FileInfo &fi = loaded->file_info();
|
||||
|
||||
// 贴图名 -> 渲染态(来自 libgr2 dump_materials 的名字/map 推断)
|
||||
std::vector<gr2::MaterialInfo> mats = gr2::dump_materials(*loaded);
|
||||
std::map<std::string, std::pair<bool, bool>> tex_state; // lower(tex) -> {alpha, two_sided}
|
||||
for (const auto &m : mats) {
|
||||
std::string k;
|
||||
for (char c : m.diffuse_texture)
|
||||
k += (char)std::tolower((unsigned char)c);
|
||||
if (!k.empty())
|
||||
tex_state[k] = {m.alpha_blend, m.two_sided};
|
||||
}
|
||||
|
||||
std::vector<mtgodot::RenderPart> parts = mtgodot::build_parts(fi);
|
||||
AABB bounds;
|
||||
Ref<godot::ArrayMesh> mesh = mtgodot::build_mesh(fi, parts, /*flip_winding=*/false, bounds);
|
||||
if (mesh.is_valid() && mesh->get_surface_count() > 0) {
|
||||
for (int s = 0; s < mesh->get_surface_count() && s < (int)parts.size(); ++s) {
|
||||
const mtgodot::RenderPart &rp = parts[s];
|
||||
std::string tex;
|
||||
if (rp.mesh >= 0 && rp.mesh < (int)fi.meshes.size()) {
|
||||
const auto &mt = fi.meshes[rp.mesh].material_textures;
|
||||
if (rp.mat_index >= 0 && rp.mat_index < (int)mt.size())
|
||||
tex = mt[rp.mat_index];
|
||||
else if (!mt.empty())
|
||||
tex = mt[0];
|
||||
}
|
||||
bool alpha = false, two = false;
|
||||
{
|
||||
std::string k;
|
||||
for (char c : tex)
|
||||
k += (char)std::tolower((unsigned char)c);
|
||||
auto f = tex_state.find(k);
|
||||
if (f != tex_state.end()) {
|
||||
alpha = f->second.first;
|
||||
two = f->second.second;
|
||||
}
|
||||
}
|
||||
mesh->surface_set_material(s, material_for(tex, alpha, two, res));
|
||||
}
|
||||
result = mesh;
|
||||
++cache.loaded;
|
||||
} else {
|
||||
++cache.failed;
|
||||
}
|
||||
cache.by_path.emplace(real_gr2_path, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
void cleanup_static_object_cache() { g_dds_cache.clear(); }
|
||||
|
||||
} // namespace mtgodot
|
||||
@@ -1,35 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <godot_cpp/classes/array_mesh.hpp>
|
||||
#include <godot_cpp/classes/ref.hpp>
|
||||
|
||||
#include <map>
|
||||
#include <string>
|
||||
|
||||
namespace fmt {
|
||||
struct AssetResolver;
|
||||
}
|
||||
|
||||
namespace mtgodot {
|
||||
|
||||
// W3 —— 静态(无骨骼)GR2 对象。用 gr2_bridge 的 build_parts/build_mesh,
|
||||
// 逐 surface 从 gr2 material binding 取贴图(走 AssetResolver 定位 + dxt 解码),
|
||||
// 不建 Skeleton3D、不逐帧蒙皮。Building / DungeonBlock 用。SHINSOO §9-W3。
|
||||
//
|
||||
// SHINSOO §9-W3「Metin2StaticModel 新节点 vs 拆 Metin2Model」的决策:
|
||||
// 取轻量方案 —— 一个自由函数产出带材质的共享 ArrayMesh,调用方(Metin2World)
|
||||
// 直接挂到 MeshInstance3D,跨实例共享同一份 mesh。
|
||||
struct StaticMeshCache {
|
||||
std::map<std::string, godot::Ref<godot::ArrayMesh>> by_path;
|
||||
int loaded = 0, failed = 0;
|
||||
};
|
||||
|
||||
// real_gr2_path = AssetResolver 解析后的真实路径。返回共享 ArrayMesh(含材质)。
|
||||
// 失败返回 invalid Ref。
|
||||
godot::Ref<godot::ArrayMesh> get_static_mesh(const std::string &real_gr2_path,
|
||||
const fmt::AssetResolver &res, StaticMeshCache &cache);
|
||||
|
||||
// 退出时清缓存的 Ref<Image>(别拖到 __cxa_finalize)。
|
||||
void cleanup_static_object_cache();
|
||||
|
||||
} // namespace mtgodot
|
||||
@@ -1,264 +0,0 @@
|
||||
#include "terrain_splat.h"
|
||||
|
||||
#include "asset_io.h"
|
||||
#include "dxt.h"
|
||||
|
||||
#include <godot_cpp/classes/image.hpp>
|
||||
#include <godot_cpp/classes/image_texture.hpp>
|
||||
#include <godot_cpp/classes/shader.hpp>
|
||||
#include <godot_cpp/classes/texture2d_array.hpp>
|
||||
#include <godot_cpp/variant/color.hpp>
|
||||
#include <godot_cpp/variant/packed_byte_array.hpp>
|
||||
#include <godot_cpp/variant/typed_array.hpp>
|
||||
|
||||
#include <asset_resolver.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <unordered_map>
|
||||
#include <utility>
|
||||
|
||||
using namespace godot;
|
||||
|
||||
namespace mtgodot {
|
||||
|
||||
// 最多 16 层:A1 单区块实测最多 12 个活动图层(`000004`/`003004`),旧的 8 层上限会静默丢层。
|
||||
static const int MAX_LAYERS = 16;
|
||||
|
||||
namespace {
|
||||
|
||||
const char *SRC_TERRAIN = R"(shader_type spatial;
|
||||
render_mode diffuse_lambert, specular_disabled, cull_back;
|
||||
|
||||
uniform sampler2DArray layers : source_color, filter_linear_mipmap_anisotropic, repeat_enable;
|
||||
uniform sampler2D weights0 : filter_linear; // RGBA = layer 0..3 alpha
|
||||
uniform sampler2D weights1 : filter_linear; // RGBA = layer 4..7 alpha
|
||||
uniform sampler2D weights2 : filter_linear; // RGBA = layer 8..11 alpha
|
||||
uniform sampler2D weights3 : filter_linear; // RGBA = layer 12..15 alpha
|
||||
uniform vec4 layer_uv[16]; // xy = 每区块平铺频率 (8*Scale),zw = offset
|
||||
uniform int layer_count = 0;
|
||||
uniform sampler2D shadowmap : source_color, filter_linear;
|
||||
uniform bool use_shadowmap = false;
|
||||
|
||||
void fragment() {
|
||||
vec3 col = vec3(0.32, 0.30, 0.24);
|
||||
vec4 w[4];
|
||||
w[0] = texture(weights0, UV);
|
||||
w[1] = texture(weights1, UV);
|
||||
w[2] = texture(weights2, UV);
|
||||
w[3] = texture(weights3, UV);
|
||||
for (int i = 0; i < 16; i++) {
|
||||
if (i >= layer_count) { break; }
|
||||
float wi = w[i >> 2][i & 3];
|
||||
if (wi <= 0.003) { continue; }
|
||||
vec2 tuv = UV * layer_uv[i].xy + layer_uv[i].zw;
|
||||
vec3 lc = texture(layers, vec3(tuv, float(i))).rgb;
|
||||
col = mix(col, lc, wi);
|
||||
}
|
||||
if (use_shadowmap) {
|
||||
col *= texture(shadowmap, UV).rgb;
|
||||
}
|
||||
ALBEDO = col;
|
||||
ROUGHNESS = 1.0;
|
||||
}
|
||||
)";
|
||||
|
||||
Ref<Shader> g_terrain_shader;
|
||||
Ref<Shader> terrain_shader() {
|
||||
if (g_terrain_shader.is_null()) {
|
||||
g_terrain_shader.instantiate();
|
||||
g_terrain_shader->set_code(SRC_TERRAIN);
|
||||
}
|
||||
return g_terrain_shader;
|
||||
}
|
||||
|
||||
// DDS -> RGBA8 Image,resize 到 size×size。缓存(非函数静态 —— 见 cleanup)。
|
||||
std::unordered_map<std::string, Ref<godot::Image>> g_layer_cache;
|
||||
std::unordered_map<std::string, std::pair<int, int>> g_layer_dimensions;
|
||||
Ref<godot::Image> layer_image(const std::string &real_path, int size,
|
||||
std::unordered_map<std::string, mtgodot::Image> &decoded_sources) {
|
||||
auto &cache = g_layer_cache;
|
||||
std::string key = real_path + "@" + std::to_string(size);
|
||||
auto it = cache.find(key);
|
||||
if (it != cache.end())
|
||||
return it->second;
|
||||
Ref<godot::Image> out;
|
||||
mtgodot::Image d;
|
||||
auto decoded = decoded_sources.find(real_path);
|
||||
if (decoded != decoded_sources.end()) {
|
||||
d = std::move(decoded->second);
|
||||
decoded_sources.erase(decoded);
|
||||
} else {
|
||||
d = mtgodot::dds_from_file(godot::String(real_path.c_str()));
|
||||
}
|
||||
if (d.ok()) {
|
||||
PackedByteArray b;
|
||||
b.resize((int64_t)d.rgba.size());
|
||||
std::copy(d.rgba.begin(), d.rgba.end(), b.ptrw());
|
||||
out = godot::Image::create_from_data(d.w, d.h, false, godot::Image::FORMAT_RGBA8, b);
|
||||
if (out.is_valid() && (d.w != size || d.h != size))
|
||||
out->resize(size, size, godot::Image::INTERPOLATE_BILINEAR);
|
||||
if (out.is_valid())
|
||||
out->generate_mipmaps();
|
||||
}
|
||||
cache.emplace(key, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
// SplatLayer.alpha (258²) -> 256² 的某个通道
|
||||
void pack_channel(uint8_t *dst /*256*256*4*/, int ch, const std::vector<uint8_t> &alpha258) {
|
||||
const int S = fmt::SPLAT_RAW_XY; // 258
|
||||
for (int y = 0; y < 256; ++y)
|
||||
for (int x = 0; x < 256; ++x)
|
||||
dst[(y * 256 + x) * 4 + ch] = alpha258[size_t(y + 1) * S + (x + 1)];
|
||||
}
|
||||
|
||||
Ref<ImageTexture> weight_tex(const std::vector<const fmt::SplatLayer *> &four) {
|
||||
std::vector<uint8_t> buf(size_t(256) * 256 * 4, 0);
|
||||
for (int c = 0; c < 4 && c < (int)four.size(); ++c)
|
||||
if (four[c])
|
||||
pack_channel(buf.data(), c, four[c]->alpha);
|
||||
PackedByteArray b;
|
||||
b.resize((int64_t)buf.size());
|
||||
std::copy(buf.begin(), buf.end(), b.ptrw());
|
||||
Ref<godot::Image> img =
|
||||
godot::Image::create_from_data(256, 256, false, godot::Image::FORMAT_RGBA8, b);
|
||||
return ImageTexture::create_from_image(img);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void cleanup_terrain_shader() {
|
||||
g_terrain_shader.unref();
|
||||
g_layer_cache.clear(); // 释放缓存的 Ref<Image>,别拖到 __cxa_finalize(那时引擎已析构)
|
||||
g_layer_dimensions.clear();
|
||||
}
|
||||
|
||||
Ref<ShaderMaterial> build_chunk_terrain_material(const fmt::SplatSet &splat,
|
||||
const fmt::TextureSet &tset, const fmt::AssetResolver &res,
|
||||
const String &shadowmap_path) {
|
||||
const bool profile = std::getenv("MT_PROFILE_MAP") != nullptr;
|
||||
const auto now = [] { return std::chrono::steady_clock::now(); };
|
||||
const auto start = now();
|
||||
// Texture2DArray 要求各 slice 同尺寸 —— 取用到的图层里的最大源边长(上限 1024),
|
||||
// 只放大不缩小最大源,避免把 512² 地表贴图硬降采样(PARITY-GAP §3.4)。
|
||||
int src_max = 256;
|
||||
// Keep newly decoded sources only until the image pass consumes them. The size pass and image pass
|
||||
// used to decode the same DDS twice on its first use.
|
||||
std::unordered_map<std::string, mtgodot::Image> decoded_sources;
|
||||
for (const auto &L : splat.layers) {
|
||||
if (L.layer >= 1 && L.layer <= (int)tset.layers.size()) {
|
||||
std::string rp = res.resolve(tset.layers[L.layer - 1].texture, nullptr);
|
||||
if (rp.empty())
|
||||
continue;
|
||||
auto it = g_layer_dimensions.find(rp);
|
||||
if (it == g_layer_dimensions.end()) {
|
||||
mtgodot::Image d = mtgodot::dds_from_file(godot::String(rp.c_str()));
|
||||
it = g_layer_dimensions.emplace(rp, d.ok() ? std::make_pair(int(d.w), int(d.h))
|
||||
: std::make_pair(0, 0)).first;
|
||||
decoded_sources.emplace(rp, std::move(d));
|
||||
}
|
||||
src_max = std::max(src_max, std::max(it->second.first, it->second.second));
|
||||
}
|
||||
}
|
||||
const int LSIZE = std::min(1024, src_max);
|
||||
const auto dimensions_done = now();
|
||||
|
||||
int n = std::min<int>(MAX_LAYERS, (int)splat.layers.size());
|
||||
if (n == 0)
|
||||
return Ref<ShaderMaterial>();
|
||||
|
||||
// 颜色数组
|
||||
TypedArray<godot::Image> imgs;
|
||||
std::vector<Color> uvparm(MAX_LAYERS, Color(40, 40, 0, 0));
|
||||
Ref<godot::Image> fallback;
|
||||
{
|
||||
PackedByteArray b;
|
||||
b.resize(LSIZE * LSIZE * 4);
|
||||
for (int i = 0; i < LSIZE * LSIZE * 4; i += 4) {
|
||||
b[i] = 90;
|
||||
b[i + 1] = 110;
|
||||
b[i + 2] = 70;
|
||||
b[i + 3] = 255;
|
||||
}
|
||||
fallback = godot::Image::create_from_data(LSIZE, LSIZE, false, godot::Image::FORMAT_RGBA8, b);
|
||||
fallback->generate_mipmaps();
|
||||
}
|
||||
for (int i = 0; i < n; ++i) {
|
||||
const fmt::SplatLayer &L = splat.layers[i];
|
||||
Ref<godot::Image> img;
|
||||
if (L.layer >= 1 && L.layer <= (int)tset.layers.size()) {
|
||||
const fmt::TextureLayer &tl = tset.layers[L.layer - 1];
|
||||
std::string rp = res.resolve(tl.texture, nullptr);
|
||||
if (!rp.empty())
|
||||
img = layer_image(rp, LSIZE, decoded_sources);
|
||||
// 原客户端 TextureSet.cpp:185:u' = (TexCoordBase*UScale)*vtx_cm + UOffset,
|
||||
// TexCoordBase = 1/(PATCH_XSIZE*CELLSCALE) = 1/3200;区块归一化 UV -> 平铺频率 = 8*Scale。
|
||||
float us = tl.u_scale > 0.01f ? tl.u_scale : 1.0f;
|
||||
float vs = tl.v_scale > 0.01f ? tl.v_scale : 1.0f;
|
||||
uvparm[i] = Color(8.0f * us, -8.0f * vs, tl.u_offset, -tl.v_offset);
|
||||
}
|
||||
imgs.push_back(img.is_valid() ? img : fallback);
|
||||
}
|
||||
const auto images_done = now();
|
||||
|
||||
Ref<Texture2DArray> arr;
|
||||
arr.instantiate();
|
||||
arr->create_from_images(imgs);
|
||||
const auto array_done = now();
|
||||
|
||||
// 权重贴图:ceil(n/4) 张 RGBA8(每通道一层 alpha)
|
||||
Ref<ImageTexture> wtex[4];
|
||||
for (int g = 0; g < 4; ++g) {
|
||||
std::vector<const fmt::SplatLayer *> grp(4, nullptr);
|
||||
for (int k = 0; k < 4; ++k) {
|
||||
int li = g * 4 + k;
|
||||
if (li < n)
|
||||
grp[k] = &splat.layers[li];
|
||||
}
|
||||
wtex[g] = weight_tex(grp);
|
||||
}
|
||||
const auto weights_done = now();
|
||||
|
||||
Ref<ShaderMaterial> mat;
|
||||
mat.instantiate();
|
||||
mat->set_shader(terrain_shader());
|
||||
mat->set_shader_parameter("layers", arr);
|
||||
mat->set_shader_parameter("weights0", wtex[0]);
|
||||
mat->set_shader_parameter("weights1", wtex[1]);
|
||||
mat->set_shader_parameter("weights2", wtex[2]);
|
||||
mat->set_shader_parameter("weights3", wtex[3]);
|
||||
mat->set_shader_parameter("layer_count", n);
|
||||
{
|
||||
Array uva;
|
||||
for (int i = 0; i < MAX_LAYERS; ++i)
|
||||
uva.push_back(Plane(uvparm[i].r, uvparm[i].g, uvparm[i].b, uvparm[i].a));
|
||||
mat->set_shader_parameter("layer_uv", uva);
|
||||
}
|
||||
|
||||
const auto shader_done = now();
|
||||
if (!shadowmap_path.is_empty()) {
|
||||
mtgodot::Image sm = mtgodot::dds_from_file(shadowmap_path);
|
||||
if (sm.ok()) {
|
||||
PackedByteArray b;
|
||||
b.resize((int64_t)sm.rgba.size());
|
||||
std::copy(sm.rgba.begin(), sm.rgba.end(), b.ptrw());
|
||||
Ref<godot::Image> smi = godot::Image::create_from_data(
|
||||
sm.w, sm.h, false, godot::Image::FORMAT_RGBA8, b);
|
||||
mat->set_shader_parameter("shadowmap", ImageTexture::create_from_image(smi));
|
||||
mat->set_shader_parameter("use_shadowmap", true);
|
||||
}
|
||||
}
|
||||
if (profile) {
|
||||
const auto ms = [](auto a, auto b) { return std::chrono::duration<double, std::milli>(b - a).count(); };
|
||||
std::fprintf(stderr, "MATERIAL_PROFILE dimensions=%.3f images=%.3f array=%.3f weights=%.3f shader=%.3f shadow=%.3f total=%.3f layers=%d size=%d\n",
|
||||
ms(start, dimensions_done), ms(dimensions_done, images_done), ms(images_done, array_done),
|
||||
ms(array_done, weights_done), ms(weights_done, shader_done), ms(shader_done, now()),
|
||||
ms(start, now()), n, LSIZE);
|
||||
}
|
||||
return mat;
|
||||
}
|
||||
|
||||
} // namespace mtgodot
|
||||
@@ -1,32 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <godot_cpp/classes/image.hpp>
|
||||
#include <godot_cpp/classes/ref.hpp>
|
||||
#include <godot_cpp/classes/shader_material.hpp>
|
||||
#include <godot_cpp/variant/string.hpp>
|
||||
|
||||
#include <splat.h>
|
||||
#include <texture_set.h>
|
||||
|
||||
namespace fmt {
|
||||
struct AssetResolver;
|
||||
}
|
||||
|
||||
namespace mtgodot {
|
||||
|
||||
// W2 —— 真正的多图层地表材质。SHINSOO §9-W2。
|
||||
// - 每图层的 258→256 alpha 打进 RGBA8 权重贴图(≤2 张 = ≤8 图层)
|
||||
// - 每图层的颜色 DDS 解码 + resize 256²,堆成 Texture2DArray
|
||||
// - ShaderMaterial:逐图层按权重 mix,UV 按 TextureLayer.u_scale/offset 平铺
|
||||
// - shadowmap.dds 作为 albedo 乘法项(有则)
|
||||
// 光照交给 Godot 内置 DirectionalLight(W5 由 .msenv 驱动)。
|
||||
godot::Ref<godot::ShaderMaterial> build_chunk_terrain_material(
|
||||
const fmt::SplatSet &splat,
|
||||
const fmt::TextureSet &tset,
|
||||
const fmt::AssetResolver &res,
|
||||
const godot::String &shadowmap_path);
|
||||
|
||||
// 清理 function-static 的 terrain shader(退出时调,避免 "shader never freed")。
|
||||
void cleanup_terrain_shader();
|
||||
|
||||
} // namespace mtgodot
|
||||
@@ -1,76 +0,0 @@
|
||||
#include "texture_util.h"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
#include <godot_cpp/classes/os.hpp>
|
||||
#include <godot_cpp/core/class_db.hpp>
|
||||
#include <godot_cpp/variant/packed_byte_array.hpp>
|
||||
#include <godot_cpp/variant/utility_functions.hpp>
|
||||
|
||||
using namespace godot;
|
||||
|
||||
namespace mtgodot {
|
||||
namespace {
|
||||
|
||||
int g_state = -1; // -1 = uninit, 0 = off, 1 = on
|
||||
bool g_warned = false;
|
||||
|
||||
void lazy_init() {
|
||||
if (g_state != -1) {
|
||||
return;
|
||||
}
|
||||
if (const char *e = std::getenv("MTGODOT_TEXCOMP")) {
|
||||
g_state = (e[0] == '1') ? 1 : 0;
|
||||
return;
|
||||
}
|
||||
// No explicit override: on for mobile targets, off for desktop (keeps the
|
||||
// Phase-1 parity path byte-identical).
|
||||
OS *os = OS::get_singleton();
|
||||
g_state = (os && os->has_feature("mobile")) ? 1 : 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool texcomp_enabled() {
|
||||
lazy_init();
|
||||
return g_state == 1;
|
||||
}
|
||||
|
||||
void texcomp_set_enabled(bool on) {
|
||||
g_state = on ? 1 : 0;
|
||||
}
|
||||
|
||||
Ref<ImageTexture> make_color_texture(int w, int h, const uint8_t *rgba, size_t len,
|
||||
bool mipmaps, TexUse use) {
|
||||
if (w <= 0 || h <= 0 || rgba == nullptr || len < size_t(w) * size_t(h) * 4) {
|
||||
return Ref<ImageTexture>();
|
||||
}
|
||||
|
||||
PackedByteArray bytes;
|
||||
bytes.resize(int64_t(w) * h * 4);
|
||||
std::memcpy(bytes.ptrw(), rgba, size_t(w) * size_t(h) * 4);
|
||||
|
||||
Ref<Image> img = Image::create_from_data(w, h, false, Image::FORMAT_RGBA8, bytes);
|
||||
if (img.is_null()) {
|
||||
return Ref<ImageTexture>();
|
||||
}
|
||||
if (mipmaps) {
|
||||
img->generate_mipmaps();
|
||||
}
|
||||
|
||||
if (use == TexUse::COLOR && texcomp_enabled()) {
|
||||
// ASTC 8x8: ~2 bpp vs 32 for RGBA8. GENERIC source hint (sRGB colour).
|
||||
Error err = img->compress(Image::COMPRESS_ASTC, Image::COMPRESS_SOURCE_GENERIC,
|
||||
Image::ASTC_FORMAT_8x8);
|
||||
if (err != OK && !g_warned) {
|
||||
g_warned = true;
|
||||
UtilityFunctions::push_warning(
|
||||
"mtgodot: runtime ASTC compression unavailable, using RGBA8");
|
||||
}
|
||||
}
|
||||
|
||||
return ImageTexture::create_from_image(img);
|
||||
}
|
||||
|
||||
} // namespace mtgodot
|
||||
@@ -1,34 +0,0 @@
|
||||
// texture_util —— 统一的「RGBA8 字节 -> ImageTexture」出口,可选 GPU 压缩。
|
||||
//
|
||||
// 桌面默认关(与旧路径逐字节一致:create_from_data + generate_mipmaps +
|
||||
// create_from_image)。移动端(OS.has_feature("mobile"))或 MTGODOT_TEXCOMP=1
|
||||
// 时,对**颜色贴图**做运行时 ASTC 8x8 压缩,省 ~6–8x 显存/带宽;编码失败自动
|
||||
// 回退 RGBA8。控制图 / 阴影图 / splat / 法线数据图不要走这个(用 RGBA8)。
|
||||
//
|
||||
// BACKLOG F4 / docs/PLATFORMS.md。
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
#include <godot_cpp/classes/image_texture.hpp>
|
||||
#include <godot_cpp/classes/image.hpp>
|
||||
#include <godot_cpp/classes/ref.hpp>
|
||||
|
||||
namespace mtgodot {
|
||||
|
||||
// 颜色贴图(sRGB 视觉内容,可压)。法线/数据图另说,暂不压。
|
||||
enum class TexUse { COLOR };
|
||||
|
||||
// 运行时压缩开关。首次调用惰性初始化:MTGODOT_TEXCOMP 环境变量优先
|
||||
// ("1"/"0"),否则 OS.has_feature("mobile")。也可显式覆盖。
|
||||
bool texcomp_enabled();
|
||||
void texcomp_set_enabled(bool on);
|
||||
|
||||
// w*h*4 的 level-0 RGBA8 -> ImageTexture。mipmaps=true 时先生成 mip 链
|
||||
// (ASTC 压缩前必须)。压缩仅在 texcomp_enabled() && use==COLOR 时发生。
|
||||
godot::Ref<godot::ImageTexture> make_color_texture(
|
||||
int w, int h, const uint8_t *rgba, size_t len, bool mipmaps,
|
||||
TexUse use = TexUse::COLOR);
|
||||
|
||||
} // namespace mtgodot
|
||||
@@ -1,542 +0,0 @@
|
||||
#include "tree_placeholder.h"
|
||||
|
||||
#include "asset_io.h"
|
||||
#include "dxt.h"
|
||||
#include "texture_util.h"
|
||||
|
||||
#include <asset_resolver.h>
|
||||
#include <spt.h>
|
||||
|
||||
#include <godot_cpp/classes/image.hpp>
|
||||
#include <godot_cpp/classes/image_texture.hpp>
|
||||
#include <godot_cpp/classes/file_access.hpp>
|
||||
#include <godot_cpp/classes/json.hpp>
|
||||
#include <godot_cpp/classes/gltf_document.hpp>
|
||||
#include <godot_cpp/classes/gltf_state.hpp>
|
||||
#include <godot_cpp/classes/gltf_mesh.hpp>
|
||||
#include <godot_cpp/classes/importer_mesh.hpp>
|
||||
#include <godot_cpp/variant/utility_functions.hpp>
|
||||
#include <godot_cpp/classes/shader.hpp>
|
||||
#include <godot_cpp/classes/shader_material.hpp>
|
||||
#include <godot_cpp/classes/standard_material3d.hpp>
|
||||
#include <godot_cpp/variant/packed_byte_array.hpp>
|
||||
#include <godot_cpp/variant/packed_int32_array.hpp>
|
||||
#include <godot_cpp/variant/packed_vector2_array.hpp>
|
||||
#include <godot_cpp/variant/packed_vector3_array.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
using namespace godot;
|
||||
|
||||
namespace mtgodot {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr float kPI = 3.14159265358979323846f;
|
||||
constexpr float kTAU = 2.0f * kPI;
|
||||
|
||||
// SpeedTree 2 的叶片是中心点 + leaf-cluster table,在顶点 shader 中展开。
|
||||
// proxy 已在 CPU 侧把叶簇展开成 card;这里只保留 alpha-test 和轻微、按实例错相的风摆。
|
||||
const char *SRC_LEAF = R"(shader_type spatial;
|
||||
render_mode cull_disabled, diffuse_lambert, specular_disabled, depth_prepass_alpha;
|
||||
uniform sampler2D leaf_tex : source_color, filter_linear_mipmap_anisotropic;
|
||||
uniform float wind_strength = 1.0;
|
||||
void vertex() {
|
||||
vec3 wp = (MODEL_MATRIX * vec4(0.0, 0.0, 0.0, 1.0)).xyz;
|
||||
float ph = wp.x * 0.11 + wp.z * 0.13;
|
||||
float h = max(VERTEX.y, 0.0);
|
||||
VERTEX.x += sin(TIME * 1.3 + ph) * 0.025 * wind_strength * h;
|
||||
VERTEX.z += cos(TIME * 1.05 + ph) * 0.018 * wind_strength * h;
|
||||
}
|
||||
void fragment() {
|
||||
vec4 c = texture(leaf_tex, UV);
|
||||
if (c.a < 0.38) { discard; }
|
||||
ALBEDO = c.rgb;
|
||||
ROUGHNESS = 1.0;
|
||||
}
|
||||
)";
|
||||
|
||||
Ref<Shader> g_leaf_shader;
|
||||
Ref<ImageTexture> g_fallback_broadleaf;
|
||||
Ref<ImageTexture> g_fallback_conifer;
|
||||
std::unordered_map<std::string, Ref<ImageTexture>> g_tree_texture_cache;
|
||||
std::unordered_map<std::string, Ref<ArrayMesh>> g_tree_mesh_cache;
|
||||
|
||||
Ref<Shader> leaf_shader() {
|
||||
if (g_leaf_shader.is_null()) {
|
||||
g_leaf_shader.instantiate();
|
||||
g_leaf_shader->set_code(SRC_LEAF);
|
||||
}
|
||||
return g_leaf_shader;
|
||||
}
|
||||
|
||||
Ref<ImageTexture> fallback_leaf_texture(bool conifer) {
|
||||
Ref<ImageTexture> &cached = conifer ? g_fallback_conifer : g_fallback_broadleaf;
|
||||
if (cached.is_valid())
|
||||
return cached;
|
||||
const int N = 96;
|
||||
PackedByteArray b;
|
||||
b.resize(N * N * 4);
|
||||
for (int y = 0; y < N; ++y) {
|
||||
for (int x = 0; x < N; ++x) {
|
||||
const float u = (x + 0.5f) / N * 2.0f - 1.0f;
|
||||
const float v = (y + 0.5f) / N * 2.0f - 1.0f;
|
||||
const float d = std::sqrt(u * u + v * v);
|
||||
float a = 1.0f - d;
|
||||
a = a <= 0 ? 0.0f : a * a * (3.0f - 2.0f * a);
|
||||
const float n = 0.5f + 0.5f * std::sin(x * 0.9f) * std::sin(y * 0.7f);
|
||||
const float g = conifer ? (0.28f + 0.14f * n) : (0.40f + 0.16f * n);
|
||||
const float r = conifer ? (0.11f + 0.06f * n) : (0.18f + 0.10f * n);
|
||||
const float bl = 0.10f + 0.06f * n;
|
||||
const int o = (y * N + x) * 4;
|
||||
b[o + 0] = uint8_t(std::min(255.0f, r * 255.0f));
|
||||
b[o + 1] = uint8_t(std::min(255.0f, g * 255.0f));
|
||||
b[o + 2] = uint8_t(std::min(255.0f, bl * 255.0f));
|
||||
b[o + 3] = uint8_t(std::min(255.0f, a * 255.0f));
|
||||
}
|
||||
}
|
||||
Ref<godot::Image> img =
|
||||
godot::Image::create_from_data(N, N, false, godot::Image::FORMAT_RGBA8, b);
|
||||
img->generate_mipmaps();
|
||||
cached = ImageTexture::create_from_image(img);
|
||||
return cached;
|
||||
}
|
||||
|
||||
Ref<ImageTexture> load_dds_texture(const std::string &path) {
|
||||
if (path.empty())
|
||||
return Ref<ImageTexture>();
|
||||
auto found = g_tree_texture_cache.find(path);
|
||||
if (found != g_tree_texture_cache.end())
|
||||
return found->second;
|
||||
Ref<ImageTexture> result;
|
||||
mtgodot::Image d = mtgodot::dds_from_file(godot::String(path.c_str()));
|
||||
if (d.ok()) {
|
||||
// Bark / leaf-composite albedo (sRGB) -> mobile ASTC via
|
||||
// make_color_texture (no-op on desktop; keeps mipmaps). §F4.
|
||||
result = mtgodot::make_color_texture(d.w, d.h, d.rgba.data(), d.rgba.size(),
|
||||
/*mipmaps=*/true);
|
||||
}
|
||||
g_tree_texture_cache.emplace(path, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string lower(std::string s) {
|
||||
std::transform(s.begin(), s.end(), s.begin(),
|
||||
[](unsigned char c) { return (char)std::tolower(c); });
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string basename(std::string path) {
|
||||
for (char &c : path)
|
||||
if (c == '\\') c = '/';
|
||||
const size_t slash = path.find_last_of('/');
|
||||
return slash == std::string::npos ? path : path.substr(slash + 1);
|
||||
}
|
||||
|
||||
std::string as_dds(std::string path) {
|
||||
const size_t dot = path.find_last_of('.');
|
||||
if (dot != std::string::npos)
|
||||
path.resize(dot);
|
||||
return path + ".dds";
|
||||
}
|
||||
|
||||
std::string resolve_sibling(const std::string &treefile, const std::string &texture,
|
||||
const fmt::AssetResolver &resolver) {
|
||||
if (texture.empty())
|
||||
return "";
|
||||
std::string parent = fmt::AssetResolver::normalize(treefile);
|
||||
const size_t slash = parent.find_last_of('/');
|
||||
if (slash != std::string::npos)
|
||||
parent.resize(slash);
|
||||
else
|
||||
parent.clear();
|
||||
const std::string name = as_dds(basename(texture));
|
||||
return resolver.resolve(parent.empty() ? name : parent + "/" + name, nullptr);
|
||||
}
|
||||
|
||||
struct TreeTextures {
|
||||
Ref<ImageTexture> bark;
|
||||
Ref<ImageTexture> composite;
|
||||
std::string composite_name;
|
||||
};
|
||||
|
||||
TreeTextures resolve_tree_textures(const std::string &treefile,
|
||||
const fmt::AssetResolver &resolver) {
|
||||
TreeTextures out;
|
||||
const std::string spt_path = resolver.resolve(treefile, nullptr);
|
||||
if (spt_path.empty())
|
||||
return out;
|
||||
fmt::SptInfo info;
|
||||
if (!fmt::sniff_spt_file(spt_path, info))
|
||||
return out;
|
||||
std::string branch;
|
||||
for (const std::string &ref : info.texture_refs) {
|
||||
if (lower(ref).find("bark") != std::string::npos) {
|
||||
branch = ref;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (branch.empty() && !info.texture_refs.empty())
|
||||
branch = info.texture_refs.front();
|
||||
out.bark = load_dds_texture(resolve_sibling(treefile, branch, resolver));
|
||||
out.composite_name = info.composite_texture;
|
||||
out.composite = load_dds_texture(
|
||||
resolve_sibling(treefile, info.composite_texture, resolver));
|
||||
return out;
|
||||
}
|
||||
|
||||
struct UVRect {
|
||||
float u0 = 0, v0 = 0, u1 = 1, v1 = 1;
|
||||
};
|
||||
|
||||
std::vector<UVRect> foliage_rects(const std::string &species,
|
||||
const std::string &composite, bool atlas) {
|
||||
if (!atlas)
|
||||
return {{0, 0, 1, 1}};
|
||||
// SPT leaf-cluster UV 尚未导出;这些区域只选择各 composite atlas 中的真实叶簇,
|
||||
// 不声称复原了具体树种的原始 UV。Windows exporter 接入后删除这组 proxy 布局。
|
||||
const std::string s = lower(species);
|
||||
const std::string c = lower(composite);
|
||||
const bool fall = s.find("fall") != std::string::npos;
|
||||
const bool winter = s.find("winter") != std::string::npos;
|
||||
if (c.find("b1") != std::string::npos) {
|
||||
if (fall) return {{0.00f, 0.05f, 0.25f, 0.25f}, {0.25f, 0.25f, 0.50f, 0.50f}};
|
||||
return {{0.25f, 0.02f, 0.50f, 0.23f}, {0.25f, 0.18f, 0.50f, 0.36f},
|
||||
{0.00f, 0.27f, 0.27f, 0.49f}};
|
||||
}
|
||||
if (c.find("b2") != std::string::npos) {
|
||||
if (fall) return {{0.00f, 0.00f, 0.25f, 0.25f}, {0.25f, 0.25f, 0.50f, 0.50f}};
|
||||
return {{0.50f, 0.38f, 0.75f, 0.63f}, {0.50f, 0.63f, 0.75f, 0.88f},
|
||||
{0.00f, 0.38f, 0.25f, 0.62f}};
|
||||
}
|
||||
if (c.find("b3") != std::string::npos) {
|
||||
if (fall) return {{0.25f, 0.25f, 0.50f, 0.50f}};
|
||||
return {{0.00f, 0.25f, 0.25f, 0.50f}, {0.00f, 0.50f, 0.25f, 0.75f},
|
||||
{0.25f, 0.50f, 0.50f, 0.75f}};
|
||||
}
|
||||
if (c.find("n1") != std::string::npos) {
|
||||
if (winter) return {{0.00f, 0.36f, 0.50f, 0.58f}, {0.25f, 0.55f, 0.52f, 0.75f}};
|
||||
return {{0.00f, 0.72f, 0.28f, 0.96f}, {0.25f, 0.74f, 0.53f, 0.97f}};
|
||||
}
|
||||
if (c.find("n2") != std::string::npos) {
|
||||
return {{0.00f, 0.48f, 0.27f, 0.75f}, {0.26f, 0.73f, 0.58f, 1.00f},
|
||||
{0.75f, 0.48f, 1.00f, 0.80f}};
|
||||
}
|
||||
return {{0, 0, 1, 1}};
|
||||
}
|
||||
|
||||
struct Buf {
|
||||
PackedVector3Array v, n;
|
||||
PackedVector2Array uv;
|
||||
PackedInt32Array idx;
|
||||
|
||||
void quad(const Vector3 &a, const Vector3 &b, const Vector3 &c, const Vector3 &d,
|
||||
const UVRect &r, bool flip_u = false) {
|
||||
const int base = v.size();
|
||||
const Vector3 nn = (b - a).cross(d - a).normalized();
|
||||
v.push_back(a);
|
||||
v.push_back(b);
|
||||
v.push_back(c);
|
||||
v.push_back(d);
|
||||
for (int i = 0; i < 4; ++i)
|
||||
n.push_back(nn);
|
||||
const float l = flip_u ? r.u1 : r.u0;
|
||||
const float rr = flip_u ? r.u0 : r.u1;
|
||||
uv.push_back(Vector2(l, r.v1));
|
||||
uv.push_back(Vector2(rr, r.v1));
|
||||
uv.push_back(Vector2(rr, r.v0));
|
||||
uv.push_back(Vector2(l, r.v0));
|
||||
idx.push_back(base);
|
||||
idx.push_back(base + 1);
|
||||
idx.push_back(base + 2);
|
||||
idx.push_back(base);
|
||||
idx.push_back(base + 2);
|
||||
idx.push_back(base + 3);
|
||||
}
|
||||
|
||||
void tube_quad(const Vector3 &b0, const Vector3 &b1, const Vector3 &t1,
|
||||
const Vector3 &t0, const Vector3 &n0, const Vector3 &n1,
|
||||
float u0, float u1, float v0, float v1) {
|
||||
const int base = v.size();
|
||||
v.push_back(b0);
|
||||
v.push_back(b1);
|
||||
v.push_back(t1);
|
||||
v.push_back(t0);
|
||||
n.push_back(n0);
|
||||
n.push_back(n1);
|
||||
n.push_back(n1);
|
||||
n.push_back(n0);
|
||||
uv.push_back(Vector2(u0, v0));
|
||||
uv.push_back(Vector2(u1, v0));
|
||||
uv.push_back(Vector2(u1, v1));
|
||||
uv.push_back(Vector2(u0, v1));
|
||||
idx.push_back(base);
|
||||
idx.push_back(base + 2);
|
||||
idx.push_back(base + 1);
|
||||
idx.push_back(base);
|
||||
idx.push_back(base + 3);
|
||||
idx.push_back(base + 2);
|
||||
}
|
||||
|
||||
Array arrays() const {
|
||||
Array a;
|
||||
a.resize(Mesh::ARRAY_MAX);
|
||||
a[Mesh::ARRAY_VERTEX] = v;
|
||||
a[Mesh::ARRAY_NORMAL] = n;
|
||||
a[Mesh::ARRAY_TEX_UV] = uv;
|
||||
a[Mesh::ARRAY_INDEX] = idx;
|
||||
return a;
|
||||
}
|
||||
};
|
||||
|
||||
void tube(Buf &m, const Vector3 &from, const Vector3 &to,
|
||||
float r0, float r1, int seg, float bark_repeat = 1.0f) {
|
||||
const Vector3 axis = (to - from).normalized();
|
||||
if (axis.length_squared() < 0.5f)
|
||||
return;
|
||||
const Vector3 helper = std::fabs(axis.y) > 0.9f ? Vector3(1, 0, 0) : Vector3(0, 1, 0);
|
||||
const Vector3 u = axis.cross(helper).normalized();
|
||||
const Vector3 w = axis.cross(u).normalized();
|
||||
for (int i = 0; i < seg; ++i) {
|
||||
const float a0 = float(i) / seg * kTAU;
|
||||
const float a1 = float(i + 1) / seg * kTAU;
|
||||
const Vector3 n0 = u * std::cos(a0) + w * std::sin(a0);
|
||||
const Vector3 n1 = u * std::cos(a1) + w * std::sin(a1);
|
||||
m.tube_quad(from + n0 * r0, from + n1 * r0, to + n1 * r1, to + n0 * r1,
|
||||
n0, n1, float(i) / seg, float(i + 1) / seg, bark_repeat, 0.0f);
|
||||
}
|
||||
}
|
||||
|
||||
uint32_t hash32(uint32_t s) {
|
||||
s ^= s >> 16;
|
||||
s *= 0x7feb352dU;
|
||||
s ^= s >> 15;
|
||||
s *= 0x846ca68bU;
|
||||
s ^= s >> 16;
|
||||
return s;
|
||||
}
|
||||
|
||||
float hash01(uint32_t s) {
|
||||
return float(hash32(s) & 0x00FFFFFFU) / float(0x01000000U);
|
||||
}
|
||||
|
||||
uint32_t species_seed(const std::string &s) {
|
||||
uint32_t h = 2166136261U;
|
||||
for (unsigned char c : s) {
|
||||
h ^= c;
|
||||
h *= 16777619U;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
bool species_is_palm(const std::string &hint) {
|
||||
const std::string h = lower(hint);
|
||||
static const char *kw[] = {"palm", "banana", "aloe", "fern", "joshua"};
|
||||
for (const char *k : kw)
|
||||
if (h.find(k) != std::string::npos)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
void add_branches(Buf &wood, const std::string &species, float H, bool conifer, bool palm) {
|
||||
const float trunk_top = H * (palm ? 0.82f : (conifer ? 0.90f : 0.76f));
|
||||
const float trunk_r = H * (palm ? 0.028f : 0.035f);
|
||||
tube(wood, Vector3(0, 0, 0), Vector3(0, trunk_top, 0),
|
||||
trunk_r * 1.35f, trunk_r * 0.42f, 9, H * 0.22f);
|
||||
if (palm)
|
||||
return;
|
||||
const int count = conifer ? 9 : 8;
|
||||
const uint32_t seed = species_seed(species);
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const float f = (i + 1.0f) / (count + 1.0f);
|
||||
const float y = H * (conifer ? (0.28f + f * 0.52f) : (0.32f + f * 0.34f));
|
||||
const float angle = kTAU * (f * 1.6180339f + hash01(seed + i * 17U));
|
||||
const float len = H * (conifer ? (0.24f * (1.0f - f * 0.55f)) :
|
||||
(0.18f + 0.08f * hash01(seed + i * 29U)));
|
||||
const Vector3 from(0, y, 0);
|
||||
const Vector3 to(std::cos(angle) * len,
|
||||
y + H * (conifer ? 0.06f : (0.10f + 0.06f * hash01(seed + i * 31U))),
|
||||
std::sin(angle) * len);
|
||||
tube(wood, from, to, trunk_r * (0.55f - 0.20f * f), trunk_r * 0.12f, 6,
|
||||
H * 0.08f);
|
||||
if (!conifer && (i % 2 == 0)) {
|
||||
const float side = angle + (hash01(seed + i * 37U) > 0.5f ? 0.65f : -0.65f);
|
||||
const Vector3 tip = to + Vector3(std::cos(side), 0.65f, std::sin(side)) * (len * 0.42f);
|
||||
tube(wood, to, tip, trunk_r * 0.16f, trunk_r * 0.05f, 5, H * 0.04f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void add_leaf_cards(Buf &leaves, const std::string &species, float H,
|
||||
bool conifer, bool palm, const std::vector<UVRect> &rects) {
|
||||
const uint32_t seed = species_seed(species);
|
||||
const int count = palm ? 16 : (conifer ? 24 : 24);
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const float a = kTAU * (float(i) * 0.6180339f + hash01(seed + i * 101U) * 0.15f);
|
||||
Vector3 center;
|
||||
float width = 1.0f, height = 1.0f;
|
||||
if (palm) {
|
||||
const float radial = H * (0.10f + 0.18f * hash01(seed + i * 103U));
|
||||
center = Vector3(std::cos(a) * radial, H * (0.78f + 0.12f * hash01(seed + i * 107U)),
|
||||
std::sin(a) * radial);
|
||||
width = H * 0.32f;
|
||||
height = H * 0.18f;
|
||||
} else if (conifer) {
|
||||
const float yf = 0.30f + 0.62f * (float(i) + 0.5f) / count;
|
||||
const float radial = H * 0.23f * (1.0f - yf * 0.70f) *
|
||||
(0.35f + 0.65f * hash01(seed + i * 109U));
|
||||
center = Vector3(std::cos(a) * radial, H * yf, std::sin(a) * radial);
|
||||
width = H * (0.18f + 0.10f * (1.0f - yf));
|
||||
height = H * 0.18f;
|
||||
} else {
|
||||
const float yf = hash01(seed + i * 109U);
|
||||
const float yn = yf * 2.0f - 1.0f;
|
||||
const float radial = H * 0.34f * std::sqrt(std::max(0.05f, 1.0f - yn * yn)) *
|
||||
(0.25f + 0.75f * std::sqrt(hash01(seed + i * 113U)));
|
||||
center = Vector3(std::cos(a) * radial, H * (0.58f + yf * 0.34f),
|
||||
std::sin(a) * radial);
|
||||
width = H * (0.23f + 0.10f * hash01(seed + i * 127U));
|
||||
height = H * (0.15f + 0.08f * hash01(seed + i * 131U));
|
||||
}
|
||||
const Vector3 right(std::cos(a + kPI * 0.5f), 0, std::sin(a + kPI * 0.5f));
|
||||
const Vector3 up(0, 1, 0);
|
||||
const UVRect &uv = rects[size_t(i) % rects.size()];
|
||||
auto card = [&](const Vector3 &r, bool flip) {
|
||||
leaves.quad(center - r * (width * 0.5f) - up * (height * 0.5f),
|
||||
center + r * (width * 0.5f) - up * (height * 0.5f),
|
||||
center + r * (width * 0.5f) + up * (height * 0.5f),
|
||||
center - r * (width * 0.5f) + up * (height * 0.5f), uv, flip);
|
||||
};
|
||||
card(right, (i & 1) != 0);
|
||||
// 原 SpeedTree leaf cluster 始终面向相机;静态 proxy 用交叉 card 保证任意视角
|
||||
// 都不会只看到一条边。离线 exporter 接入后由真实 leaf table 替代。
|
||||
const Vector3 crossed(std::cos(a), 0, std::sin(a));
|
||||
card(crossed, (i & 1) == 0);
|
||||
}
|
||||
}
|
||||
|
||||
Ref<ArrayMesh> build_proxy_impl(const std::string &species, float height_m,
|
||||
const TreeTextures &textures) {
|
||||
const String hint(species.c_str());
|
||||
const bool conifer = species_is_conifer(hint);
|
||||
const bool palm = species_is_palm(species);
|
||||
const float H = std::max(2.0f, height_m);
|
||||
const bool atlas = textures.composite.is_valid();
|
||||
const std::vector<UVRect> rects = foliage_rects(species, textures.composite_name, atlas);
|
||||
|
||||
Buf wood, leaves;
|
||||
add_branches(wood, species, H, conifer, palm);
|
||||
add_leaf_cards(leaves, species, H, conifer, palm, rects);
|
||||
|
||||
Ref<ArrayMesh> mesh;
|
||||
mesh.instantiate();
|
||||
mesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, wood.arrays());
|
||||
mesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, leaves.arrays());
|
||||
|
||||
Ref<StandardMaterial3D> bark;
|
||||
bark.instantiate();
|
||||
bark->set_albedo(textures.bark.is_valid() ? Color(1, 1, 1) : Color(0.30f, 0.21f, 0.13f));
|
||||
bark->set_roughness(1.0f);
|
||||
bark->set_texture_filter(StandardMaterial3D::TEXTURE_FILTER_LINEAR_WITH_MIPMAPS_ANISOTROPIC);
|
||||
if (textures.bark.is_valid())
|
||||
bark->set_texture(StandardMaterial3D::TEXTURE_ALBEDO, textures.bark);
|
||||
mesh->surface_set_material(0, bark);
|
||||
|
||||
Ref<ShaderMaterial> leaf;
|
||||
leaf.instantiate();
|
||||
leaf->set_shader(leaf_shader());
|
||||
leaf->set_shader_parameter("leaf_tex",
|
||||
textures.composite.is_valid() ? textures.composite : fallback_leaf_texture(conifer));
|
||||
// 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;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void cleanup_tree_shader() {
|
||||
g_tree_mesh_cache.clear();
|
||||
g_tree_texture_cache.clear();
|
||||
g_fallback_broadleaf.unref();
|
||||
g_fallback_conifer.unref();
|
||||
g_leaf_shader.unref();
|
||||
}
|
||||
|
||||
bool species_is_conifer(const String &hint) {
|
||||
const String h = hint.to_lower();
|
||||
static const char *kw[] = {"cedar", "cypress", "pine", "fir", "spruce", "conifer", "juniper",
|
||||
"christmastree"};
|
||||
for (const char *k : kw)
|
||||
if (h.find(k) != -1)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
Ref<ArrayMesh> build_placeholder_tree(const String &species_hint, float height_m) {
|
||||
TreeTextures empty;
|
||||
return build_proxy_impl(std::string(species_hint.utf8().get_data()), height_m, empty);
|
||||
}
|
||||
|
||||
static Ref<ArrayMesh> load_native_tree(const std::string &treefile, const fmt::AssetResolver &resolver) {
|
||||
const String source(resolver.resolve(treefile, nullptr).c_str());
|
||||
if (source.is_empty()) return {};
|
||||
const String directory = String(resolver.assets_root.c_str()).path_join("TreeGeometry");
|
||||
const String manifest_path = directory.path_join("manifest.json");
|
||||
if (!FileAccess::file_exists(manifest_path)) return {};
|
||||
const Variant parsed = JSON::parse_string(FileAccess::get_file_as_string(manifest_path));
|
||||
if (parsed.get_type() != Variant::DICTIONARY) return {};
|
||||
const Dictionary manifest = parsed;
|
||||
if (int(manifest.get("schema_version", 0)) != 1 || String(manifest.get("coordinates", "")) != "godot_y_up_meters") return {};
|
||||
const Dictionary trees = manifest.get("trees", Dictionary());
|
||||
const String source_hash = FileAccess::get_sha256(source);
|
||||
if (!trees.has(source_hash)) return {};
|
||||
const Dictionary record = trees[source_hash];
|
||||
const String filename = record.get("glb", "");
|
||||
if (filename != source_hash + String(".glb")) return {};
|
||||
const String path = directory.path_join(filename);
|
||||
if (!FileAccess::file_exists(path) || FileAccess::get_sha256(path) != String(record.get("glb_sha256", ""))) return {};
|
||||
Ref<GLTFDocument> document;
|
||||
document.instantiate();
|
||||
Ref<GLTFState> state;
|
||||
state.instantiate();
|
||||
if (document->append_from_file(path, state) != OK || state->get_meshes().size() != 1) return {};
|
||||
Ref<GLTFMesh> gltf_mesh = state->get_meshes()[0];
|
||||
if (gltf_mesh.is_null() || gltf_mesh->get_mesh().is_null()) return {};
|
||||
Ref<ArrayMesh> mesh = gltf_mesh->get_mesh()->get_mesh();
|
||||
if (mesh.is_null() || mesh->get_surface_count() == 0) return {};
|
||||
mesh->set_meta("tree_geometry", "native_spt");
|
||||
mesh->set_meta("source_sha256", source_hash);
|
||||
mesh->set_meta("glb_sha256", record.get("glb_sha256", ""));
|
||||
mesh->set_meta("leaf_mode", "static_extracted_cards");
|
||||
// GLB vertices already use Godot metres. Never impose the proxy's 12m height.
|
||||
return mesh;
|
||||
}
|
||||
|
||||
Ref<ArrayMesh> get_tree_mesh(const std::string &treefile,
|
||||
const fmt::AssetResolver &resolver, float height_m) {
|
||||
const std::string key = resolver.assets_root + "|" + fmt::AssetResolver::normalize(treefile) +
|
||||
"#" + std::to_string(height_m);
|
||||
auto found = g_tree_mesh_cache.find(key);
|
||||
if (found != g_tree_mesh_cache.end())
|
||||
return found->second;
|
||||
Ref<ArrayMesh> native = load_native_tree(treefile, resolver);
|
||||
if (native.is_valid()) {
|
||||
g_tree_mesh_cache.emplace(key, native);
|
||||
return native;
|
||||
}
|
||||
UtilityFunctions::push_warning(String("Tree native geometry unavailable or hash mismatch; proxy: ") + String(treefile.c_str()));
|
||||
const TreeTextures textures = resolve_tree_textures(treefile, resolver);
|
||||
Ref<ArrayMesh> mesh = build_proxy_impl(treefile, height_m, textures);
|
||||
mesh->set_meta("tree_geometry", "proxy");
|
||||
g_tree_mesh_cache.emplace(key, mesh);
|
||||
return mesh;
|
||||
}
|
||||
|
||||
} // namespace mtgodot
|
||||
@@ -1,35 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <godot_cpp/classes/array_mesh.hpp>
|
||||
#include <godot_cpp/classes/ref.hpp>
|
||||
#include <godot_cpp/variant/string.hpp>
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace fmt {
|
||||
struct AssetResolver;
|
||||
}
|
||||
|
||||
// Native trees use verified offline GLBs; `.spt` is never linked to a proprietary
|
||||
// runtime on macOS. Deterministic procedural geometry remains a reported fallback.
|
||||
// 一份共享 mesh / treefile,逐实例只由 MultiMesh 承载原 AreaData 位置。
|
||||
namespace mtgodot {
|
||||
|
||||
// species_hint = treefile 名(判针叶/阔叶)。height_m ≈ 期望树高(米)。
|
||||
godot::Ref<godot::ArrayMesh> build_placeholder_tree(const godot::String &species_hint,
|
||||
float height_m = 12.0f);
|
||||
|
||||
// Cached per treefile for MultiMesh use. Missing or mismatched native resources
|
||||
// use a proxy with explicit metadata and a warning; package acceptance rejects it.
|
||||
// Prefer hash-matched native GLB geometry in assets/TreeGeometry. The procedural
|
||||
// implementation is an explicitly reported fallback for missing/invalid trees.
|
||||
godot::Ref<godot::ArrayMesh> get_tree_mesh(const std::string &treefile,
|
||||
const fmt::AssetResolver &resolver, float height_m = 12.0f);
|
||||
|
||||
// 从 treefile 名猜是否针叶(cedar / cypress / pine / fir / spruce…)。
|
||||
bool species_is_conifer(const godot::String &species_hint);
|
||||
|
||||
// 退出时清 shader、DDS 和 treefile->mesh 缓存。
|
||||
void cleanup_tree_shader();
|
||||
|
||||
} // namespace mtgodot
|
||||
@@ -1,218 +0,0 @@
|
||||
#include "water_builder.h"
|
||||
#include "water_motion.h"
|
||||
|
||||
#include "asset_io.h"
|
||||
#include "dxt.h"
|
||||
|
||||
#include <godot_cpp/classes/image.hpp>
|
||||
#include <godot_cpp/classes/shader.hpp>
|
||||
#include <godot_cpp/classes/shader_material.hpp>
|
||||
#include <godot_cpp/classes/texture2d_array.hpp>
|
||||
#include <godot_cpp/classes/time.hpp>
|
||||
#include <godot_cpp/variant/utility_functions.hpp>
|
||||
#include <godot_cpp/variant/packed_byte_array.hpp>
|
||||
#include <godot_cpp/variant/packed_color_array.hpp>
|
||||
#include <godot_cpp/variant/packed_int32_array.hpp>
|
||||
#include <godot_cpp/variant/packed_vector2_array.hpp>
|
||||
#include <godot_cpp/variant/packed_vector3_array.hpp>
|
||||
#include <godot_cpp/variant/typed_array.hpp>
|
||||
|
||||
#include <asset_resolver.h>
|
||||
#include <m2_coord.h>
|
||||
#include <terrain_mesh.h> // terrain_height_at
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
using namespace godot;
|
||||
|
||||
namespace mtgodot {
|
||||
|
||||
namespace {
|
||||
|
||||
// 30 帧序列 + 逐顶点水深 alpha(顶点 COLOR.a) + 轻微高度浮动。
|
||||
// UV = 世界米;平铺频率在 shader 里按 1/(CELLSCALE*4 cm) = 1/8m。
|
||||
const char *SRC_WATER = R"(shader_type spatial;
|
||||
render_mode blend_mix, cull_disabled, depth_draw_never, unshaded;
|
||||
|
||||
uniform sampler2DArray frames : source_color, filter_linear_mipmap, repeat_enable;
|
||||
uniform float uv_per_meter = 0.125; // 1/8m,= 原客户端 1/(CELLSCALE*4)
|
||||
uniform float height_offset = 0.0; // Shared reference water translation, metres.
|
||||
|
||||
void vertex() {
|
||||
VERTEX.y += height_offset;
|
||||
}
|
||||
|
||||
void fragment() {
|
||||
int f = int(mod(TIME * 1000.0 / 70.0, 30.0)); // 70ms/帧
|
||||
vec2 uv = UV * uv_per_meter;
|
||||
vec3 tex = texture(frames, vec3(uv, float(f))).rgb;
|
||||
// MapOutdoorWater selects texture RGB and vertex diffuse alpha directly.
|
||||
// Do not introduce PBR lighting, invented tint or view-dependent opacity.
|
||||
ALBEDO = tex;
|
||||
ALPHA = COLOR.a;
|
||||
}
|
||||
)";
|
||||
|
||||
Ref<Shader> g_water_shader;
|
||||
Ref<ShaderMaterial> g_water_mat; // 30 帧数组只建一次
|
||||
WaterMotion g_water_motion;
|
||||
|
||||
Ref<ShaderMaterial> water_material(const fmt::AssetResolver &res) {
|
||||
if (g_water_mat.is_valid())
|
||||
return g_water_mat;
|
||||
if (g_water_shader.is_null()) {
|
||||
g_water_shader.instantiate();
|
||||
g_water_shader->set_code(SRC_WATER);
|
||||
}
|
||||
Ref<ShaderMaterial> m;
|
||||
m.instantiate();
|
||||
m->set_shader(g_water_shader);
|
||||
|
||||
// special/water/01..30.dds
|
||||
TypedArray<godot::Image> imgs;
|
||||
int W = 0, H = 0;
|
||||
for (int i = 1; i <= 30; ++i) {
|
||||
char nm[64];
|
||||
std::snprintf(nm, sizeof(nm), "d:/ymir work/special/water/%02d.dds", i);
|
||||
std::string rp = res.resolve(nm, nullptr);
|
||||
mtgodot::Image d = rp.empty() ? mtgodot::Image{} : mtgodot::dds_from_file(godot::String(rp.c_str()));
|
||||
if (!d.ok())
|
||||
break;
|
||||
if (W == 0) {
|
||||
W = d.w;
|
||||
H = d.h;
|
||||
}
|
||||
PackedByteArray b;
|
||||
b.resize((int64_t)d.rgba.size());
|
||||
std::copy(d.rgba.begin(), d.rgba.end(), b.ptrw());
|
||||
Ref<godot::Image> img =
|
||||
godot::Image::create_from_data(d.w, d.h, false, godot::Image::FORMAT_RGBA8, b);
|
||||
if (img.is_valid() && (d.w != W || d.h != H))
|
||||
img->resize(W, H, godot::Image::INTERPOLATE_BILINEAR);
|
||||
if (img.is_valid())
|
||||
img->generate_mipmaps();
|
||||
imgs.push_back(img);
|
||||
}
|
||||
if (imgs.size() == 30) {
|
||||
Ref<Texture2DArray> arr;
|
||||
arr.instantiate();
|
||||
arr->create_from_images(imgs);
|
||||
m->set_shader_parameter("frames", arr);
|
||||
}
|
||||
g_water_mat = m;
|
||||
return m;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void cleanup_water_shader() {
|
||||
g_water_shader.unref();
|
||||
g_water_mat.unref();
|
||||
g_water_motion = WaterMotion{};
|
||||
}
|
||||
|
||||
void update_water_animation() {
|
||||
if (g_water_mat.is_null()) return;
|
||||
const uint64_t now = Time::get_singleton()->get_ticks_msec();
|
||||
uint64_t duration = 1000;
|
||||
double depth = 0;
|
||||
if (g_water_motion.initialized && now - g_water_motion.start_ms > g_water_motion.duration_ms) {
|
||||
duration = UtilityFunctions::randi_range(1000, 3000);
|
||||
if (g_water_motion.end == 0) depth = UtilityFunctions::randi_range(0, 15);
|
||||
}
|
||||
g_water_mat->set_shader_parameter("height_offset", g_water_motion.sample(now, duration, depth));
|
||||
}
|
||||
|
||||
std::vector<WaterPiece> build_chunk_water(const fmt::WaterMap &wm, const fmt::HeightMap &hm,
|
||||
int tile_x, int tile_y, double height_scale, const fmt::AssetResolver &res) {
|
||||
std::vector<WaterPiece> out;
|
||||
if (wm.layer_count == 0 || wm.ids.size() != size_t(fmt::WATERMAP_XY) * fmt::WATERMAP_XY)
|
||||
return out;
|
||||
|
||||
const int W = fmt::WATERMAP_XY; // 128
|
||||
const double CELL_M = double(fmt::m2coord::CELLSCALE) * fmt::m2coord::CM_TO_M; // 2m/texel
|
||||
const double X0 = double(tile_x) * fmt::m2coord::CHUNK_CM * fmt::m2coord::CM_TO_M;
|
||||
const double Z0 = double(tile_y) * fmt::m2coord::CHUNK_CM * fmt::m2coord::CM_TO_M;
|
||||
|
||||
auto depth_alpha = [&](int tex_x, int tex_y, double water_h_cm) -> float {
|
||||
// 水 texel (tex_x,tex_y) 对应的区块本地 cm(texel = 1 格 = CELLSCALE)
|
||||
double lx = tex_x * double(fmt::m2coord::CELLSCALE);
|
||||
double ly = tex_y * double(fmt::m2coord::CELLSCALE);
|
||||
double th = fmt::terrain_height_at(hm, lx, ly, height_scale); // cm
|
||||
double depth_cm = water_h_cm - th;
|
||||
// AreaTerrain uses RAW height differences, before HeightScale, with
|
||||
// OpaqueWaterDepth=400 and clamps at 80% (not a 12% shoreline floor).
|
||||
return water_depth_alpha(depth_cm, height_scale);
|
||||
};
|
||||
|
||||
for (int layer = 0; layer < wm.layer_count; ++layer) {
|
||||
double h_cm = double(layer < (int)wm.heights.size() ? wm.heights[layer] : 0) * height_scale;
|
||||
float gy = float(h_cm * fmt::m2coord::CM_TO_M);
|
||||
|
||||
PackedVector3Array v;
|
||||
PackedVector3Array n;
|
||||
PackedVector2Array uv;
|
||||
PackedColorArray col;
|
||||
PackedInt32Array idx;
|
||||
for (int y = 0; y < W; ++y) {
|
||||
int x = 0;
|
||||
while (x < W) {
|
||||
if (wm.ids[y * W + x] != layer) {
|
||||
++x;
|
||||
continue;
|
||||
}
|
||||
int xs = x;
|
||||
// Keep each cell's four shoreline samples. Merging a whole row
|
||||
// discarded interior terrain peaks and bridged dry banks with water.
|
||||
++x;
|
||||
float aLL = depth_alpha(xs, y, h_cm), aLR = depth_alpha(x, y, h_cm);
|
||||
float aUR = depth_alpha(x, y + 1, h_cm), aUL = depth_alpha(xs, y + 1, h_cm);
|
||||
// AreaTerrain skips the quad when every diffuse alpha is zero.
|
||||
if (aLL == 0 && aLR == 0 && aUR == 0 && aUL == 0)
|
||||
continue;
|
||||
double x0 = X0 + xs * CELL_M, x1 = X0 + x * CELL_M;
|
||||
double z0 = Z0 + y * CELL_M, z1 = Z0 + (y + 1) * CELL_M;
|
||||
int base = v.size();
|
||||
v.push_back(Vector3(x0, gy, z0));
|
||||
v.push_back(Vector3(x1, gy, z0));
|
||||
v.push_back(Vector3(x1, gy, z1));
|
||||
v.push_back(Vector3(x0, gy, z1));
|
||||
for (int k = 0; k < 4; ++k)
|
||||
n.push_back(Vector3(0, 1, 0));
|
||||
uv.push_back(Vector2(float(x0), float(z0)));
|
||||
uv.push_back(Vector2(float(x1), float(z0)));
|
||||
uv.push_back(Vector2(float(x1), float(z1)));
|
||||
uv.push_back(Vector2(float(x0), float(z1)));
|
||||
col.push_back(Color(1, 1, 1, aLL));
|
||||
col.push_back(Color(1, 1, 1, aLR));
|
||||
col.push_back(Color(1, 1, 1, aUR));
|
||||
col.push_back(Color(1, 1, 1, aUL));
|
||||
idx.push_back(base);
|
||||
idx.push_back(base + 2);
|
||||
idx.push_back(base + 1);
|
||||
idx.push_back(base);
|
||||
idx.push_back(base + 3);
|
||||
idx.push_back(base + 2);
|
||||
}
|
||||
}
|
||||
if (v.is_empty())
|
||||
continue;
|
||||
|
||||
Array arr;
|
||||
arr.resize(Mesh::ARRAY_MAX);
|
||||
arr[Mesh::ARRAY_VERTEX] = v;
|
||||
arr[Mesh::ARRAY_NORMAL] = n;
|
||||
arr[Mesh::ARRAY_TEX_UV] = uv;
|
||||
arr[Mesh::ARRAY_COLOR] = col;
|
||||
arr[Mesh::ARRAY_INDEX] = idx;
|
||||
|
||||
Ref<godot::ArrayMesh> mesh;
|
||||
mesh.instantiate();
|
||||
mesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, arr);
|
||||
mesh->surface_set_material(0, water_material(res));
|
||||
out.push_back({mesh, gy});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace mtgodot
|
||||
@@ -1,33 +0,0 @@
|
||||
#pragma once
|
||||
#include "water_depth.h"
|
||||
|
||||
#include <godot_cpp/classes/array_mesh.hpp>
|
||||
#include <godot_cpp/classes/ref.hpp>
|
||||
|
||||
#include <terrain_files.h>
|
||||
|
||||
#include <vector>
|
||||
|
||||
namespace fmt {
|
||||
struct AssetResolver;
|
||||
}
|
||||
|
||||
// W7 / PARITY §4 —— water.wtr -> 每层水面网格(texel 掩膜)+ 共享水材质。
|
||||
// 水材质用原客户端资产:`special/water/01..30.dds` 30 帧序列(`MapOutdoorWater.cpp:14/43`,
|
||||
// 70ms/帧),UV 平铺频率 = 1/(CELLSCALE*4)(每 4 格一循环),逐顶点水深 alpha,轻微高度浮动。
|
||||
namespace mtgodot {
|
||||
|
||||
|
||||
struct WaterPiece {
|
||||
godot::Ref<godot::ArrayMesh> mesh; // Godot 空间,已含区块原点
|
||||
float godot_y = 0;
|
||||
};
|
||||
|
||||
// hm/height_scale 用来算每个水面顶点下方的地形高度 -> 水深 -> alpha。
|
||||
std::vector<WaterPiece> build_chunk_water(const fmt::WaterMap &wm, const fmt::HeightMap &hm,
|
||||
int tile_x, int tile_y, double height_scale, const fmt::AssetResolver &res);
|
||||
|
||||
void cleanup_water_shader();
|
||||
void update_water_animation();
|
||||
|
||||
} // namespace mtgodot
|
||||
@@ -1,12 +0,0 @@
|
||||
#pragma once
|
||||
#include <cmath>
|
||||
|
||||
namespace mtgodot {
|
||||
// 40250 MapOutdoor.cpp / AreaTerrain.cpp. Arguments in scaled centimetres.
|
||||
inline float water_depth_alpha(double depth_cm, double height_scale) {
|
||||
if (!std::isfinite(depth_cm) || !std::isfinite(height_scale) ||
|
||||
height_scale <= 0.0 || depth_cm <= 0.0) return 0.0f;
|
||||
double alpha = depth_cm / (400.0 * height_scale);
|
||||
return static_cast<float>(alpha > 0.8 ? 0.8 : alpha);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
|
||||
namespace mtgodot {
|
||||
// MapOutdoorWater: one shared vertical translation, alternating a random
|
||||
// 0..-15cm endpoint and zero, linearly interpolated over 1000..3000ms.
|
||||
struct WaterMotion {
|
||||
uint64_t start_ms = 0, duration_ms = 300;
|
||||
double begin = 0, end = 0, current = 0;
|
||||
bool initialized = false;
|
||||
double sample(uint64_t now, uint64_t next_duration, double next_depth_cm) {
|
||||
if (!initialized) { start_ms = now; initialized = true; }
|
||||
if (now - start_ms > duration_ms) {
|
||||
begin = current;
|
||||
end = end == 0 ? -std::clamp(next_depth_cm, 0.0, 15.0) * 0.01 : 0;
|
||||
start_ms = now;
|
||||
duration_ms = std::clamp<uint64_t>(next_duration, 1000, 3000);
|
||||
}
|
||||
current = begin + (end - begin) * double(now - start_ms) / double(duration_ms);
|
||||
return current;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -13,8 +13,8 @@ Bytes header(uint32_t bits, uint32_t r, uint32_t g, uint32_t b, uint32_t a, size
|
||||
put(out, 92, r); put(out, 96, g); put(out, 100, b); put(out, 104, a);
|
||||
return out;
|
||||
}
|
||||
mtgodot::Image decode(const Bytes& b) { return mtgodot::load_dds(b.data(), b.size()); }
|
||||
void pixel(const mtgodot::Image& im, size_t n, uint8_t r, uint8_t g, uint8_t b, uint8_t a) {
|
||||
mtimage::Image decode(const Bytes& b) { return mtimage::load_dds(b.data(), b.size()); }
|
||||
void pixel(const mtimage::Image& im, size_t n, uint8_t r, uint8_t g, uint8_t b, uint8_t a) {
|
||||
assert(im.ok()); assert(im.rgba[n*4] == r && im.rgba[n*4+1] == g && im.rgba[n*4+2] == b && im.rgba[n*4+3] == a);
|
||||
}
|
||||
int main() {
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
#include "water_depth.h"
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <limits>
|
||||
#include <initializer_list>
|
||||
int main() {
|
||||
for (double scale : {0.25, 0.5, 1.0, 2.0}) {
|
||||
assert(mtgodot::water_depth_alpha(-1, scale) == 0);
|
||||
assert(mtgodot::water_depth_alpha(0, scale) == 0);
|
||||
assert(std::abs(mtgodot::water_depth_alpha(100 * scale, scale) - 0.25) < 1e-6);
|
||||
assert(std::abs(mtgodot::water_depth_alpha(320 * scale, scale) - 0.8) < 1e-6);
|
||||
assert(std::abs(mtgodot::water_depth_alpha(1000 * scale, scale) - 0.8) < 1e-6);
|
||||
}
|
||||
assert(mtgodot::water_depth_alpha(100, 0) == 0);
|
||||
assert(mtgodot::water_depth_alpha(std::numeric_limits<double>::quiet_NaN(), 1) == 0);
|
||||
std::puts("PASS: water depth reference (raw depth, shoreline, cap, height scale)");
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
#include "water_motion.h"
|
||||
#include <cassert>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
int main() {
|
||||
mtgodot::WaterMotion m;
|
||||
assert(m.sample(100, 2000, 15) == 0);
|
||||
assert(m.sample(400, 2000, 15) == 0);
|
||||
assert(m.sample(401, 2000, 15) == 0);
|
||||
assert(std::abs(m.sample(1401, 2000, 15) + .075) < 1e-9);
|
||||
assert(std::abs(m.sample(2401, 2000, 15) + .15) < 1e-9);
|
||||
assert(std::abs(m.sample(2402, 1000, 15) + .15) < 1e-9);
|
||||
assert(std::abs(m.sample(2902, 1000, 15) + .075) < 1e-9);
|
||||
assert(m.sample(3402, 1000, 15) == 0);
|
||||
// Large frame gaps start at the last rendered height, without overshoot.
|
||||
for (uint64_t t = 3403; t < 200000; t += 137) {
|
||||
const double y = m.sample(t, 1700, 9);
|
||||
assert(y <= 0 && y >= -.15);
|
||||
}
|
||||
std::puts("PASS: shared linear water motion, endpoints, timing, no overshoot");
|
||||
}
|
||||
Vendored
+5
-5
@@ -1,9 +1,9 @@
|
||||
# extension/third_party — vendored native dependencies for the GDExtension.
|
||||
# Vendored native dependencies for the 40250 port and SDL/Vulkan client.
|
||||
#
|
||||
# Phase 1 (macOS) used Homebrew for libsodium / libzstd / liblzo2. Those don't
|
||||
# exist on the Android NDK or iOS SDK sysroots, so the mobile bring-up (BACKLOG
|
||||
# F1/F2) needs them built from source as part of our own build. All three are
|
||||
# pinned here and produce static libs that link into libmtgodot:
|
||||
# pinned here and produce static libraries for port_platform:
|
||||
#
|
||||
# sodium <- libsodium-cmake submodule (wraps jedisct1/libsodium)
|
||||
# libzstd_static <- facebook/zstd submodule, its own build/cmake project
|
||||
@@ -13,7 +13,7 @@
|
||||
# GPL: fine for this internal, non-published project — same footing as
|
||||
# libgr2/src/oodle1.c — but must be swapped or re-licensed before any release.
|
||||
|
||||
# Everything here is archived into the SHARED libmtgodot, so it must be PIC.
|
||||
# Android links these static libraries into libmain.so, so they must be PIC.
|
||||
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
|
||||
|
||||
# --- libsodium ------------------------------------------------------------------
|
||||
@@ -57,7 +57,7 @@ if(WIN32)
|
||||
else()
|
||||
set(MT_EMBED_PYTHON_DEFAULT ON)
|
||||
endif()
|
||||
option(MTGODOT_EMBED_PYTHON "Build the embedded CPython 2.7.18 static library" ${MT_EMBED_PYTHON_DEFAULT})
|
||||
if(MTGODOT_EMBED_PYTHON)
|
||||
option(MT_EMBED_PYTHON "Build the embedded CPython 2.7.18 static library" ${MT_EMBED_PYTHON_DEFAULT})
|
||||
if(MT_EMBED_PYTHON)
|
||||
add_subdirectory(cpython-2.7.18)
|
||||
endif()
|
||||
|
||||
Reference in New Issue
Block a user