From 61272b75e96950bcc77d07d0e01c4d339a2206e2 Mon Sep 17 00:00:00 2001 From: shen <> Date: Mon, 7 Sep 2026 14:53:36 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=E9=97=AE=E9=A2=98?= =?UTF-8?q?=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + build-macos-client.sh | 16 +- extension/src/metin2_model.cpp | 16 + extension/src/metin2_model.h | 4 + extension/src/net/classic/classic_parser.cpp | 9 + extension/src/net/classic/classic_session.cpp | 50 +++- extension/src/net/classic/classic_session.h | 4 + extension/src/net/classic/classic_stream.cpp | 51 +++- extension/src/net/classic/classic_stream.h | 3 + extension/src/net/classic/wire_classic.h | 9 + extension/src/net/m2_client.cpp | 2 + extension/tools/net_classic_e2e.cpp | 11 +- libgr2/CMakeLists.txt | 4 + libgr2/src/gr2_anim.cpp | 6 +- libgr2/tests/animation.cpp | 28 ++ macos.entitlements | 8 + project/app_flow.gd | 11 + project/char_select_visual_test.gd | 43 +++ project/client_main.gd | 6 + project/game_camera.gd | 7 +- project/game_scene.gd | 22 +- project/live_smoke_test.gd | 276 ++++++++++++++++++ project/live_smoke_test.gd.uid | 1 + project/net_world.gd | 17 ++ project/skill_test.gd | 8 + project/ui/char_select_screen.gd | 218 +++++++++----- project/ui/mob_view.gd | 6 + project/ui/player_view.gd | 8 + project/ui/quickbar.gd | 107 ++++++- project/ui/ui_assets.gd | 20 +- script/live_smoke_test.sh | 143 +++++++++ test/compare.py | 3 +- 32 files changed, 1023 insertions(+), 95 deletions(-) create mode 100644 libgr2/tests/animation.cpp create mode 100644 macos.entitlements create mode 100644 project/char_select_visual_test.gd create mode 100644 project/live_smoke_test.gd create mode 100644 project/live_smoke_test.gd.uid create mode 100755 script/live_smoke_test.sh diff --git a/.gitignore b/.gitignore index 938c54b2..495f1411 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ compile_commands.json *.tmp *.o *.a +.env.live-smoke.local # clangd /.cache/ diff --git a/build-macos-client.sh b/build-macos-client.sh index d0c40486..d984da6e 100755 --- a/build-macos-client.sh +++ b/build-macos-client.sh @@ -56,9 +56,19 @@ test -f "$APP/Contents/Resources/assets/asset_index.txt" echo "== 4. 签名(默认 ad-hoc;CODESIGN_IDENTITY 可切正式证书)==" CODESIGN_IDENTITY="${CODESIGN_IDENTITY:--}" -find "$APP/Contents" -type f \( -name '*.dylib' -o -name '*.so' -o -name '*.a' \) -exec \ - codesign --force --options runtime --timestamp=none --sign "$CODESIGN_IDENTITY" {} \; 2>/dev/null || true -codesign --force --options runtime --timestamp=none --sign "$CODESIGN_IDENTITY" "$APP" 2>/dev/null || true +ENTITLEMENTS="$REPO/macos.entitlements" +test -f "$ENTITLEMENTS" + +# The exported Godot executable loads the GDExtension from Contents/Frameworks. +# Because this package uses hardened runtime, the main executable must carry +# disable-library-validation or macOS rejects the ad-hoc/formally signed dylib +# at runtime even when the bundle passes a static codesign verification. +find "$APP/Contents/Frameworks" -type f \( -name '*.dylib' -o -name '*.so' \) -exec \ + codesign --force --options runtime --timestamp=none --sign "$CODESIGN_IDENTITY" {} \; +codesign --force --options runtime --timestamp=none --entitlements "$ENTITLEMENTS" \ + --sign "$CODESIGN_IDENTITY" "$APP/Contents/MacOS/mtgodot-poc" +codesign --force --options runtime --timestamp=none --entitlements "$ENTITLEMENTS" \ + --sign "$CODESIGN_IDENTITY" "$APP" xattr -dr com.apple.quarantine "$APP" 2>/dev/null || true echo diff --git a/extension/src/metin2_model.cpp b/extension/src/metin2_model.cpp index f90a480f..c81f5c35 100644 --- a/extension/src/metin2_model.cpp +++ b/extension/src/metin2_model.cpp @@ -68,6 +68,7 @@ void Metin2Model::_bind_methods() { ClassDB::bind_method(D_METHOD("set_surface_texture", "surface", "path"), &Metin2Model::set_surface_texture); ClassDB::bind_method(D_METHOD("reload"), &Metin2Model::reload); ClassDB::bind_method(D_METHOD("get_info"), &Metin2Model::get_info); + ClassDB::bind_method(D_METHOD("get_ground_offset"), &Metin2Model::get_ground_offset); ClassDB::bind_method(D_METHOD("get_hair_options"), &Metin2Model::get_hair_options); ClassDB::bind_method(D_METHOD("set_hair_gr2", "p"), &Metin2Model::set_hair_gr2); ClassDB::bind_method(D_METHOD("get_hair_gr2"), &Metin2Model::get_hair_gr2); @@ -455,6 +456,10 @@ void Metin2Model::reload() { base_parts = parts; AABB bounds; Ref mesh = build_mesh(fi, parts, flip_winding, bounds); + // build_mesh keeps the source GR2 coordinate frame (Z-up, centimetres); + // make_conv() below maps it to Godot Y-up and applies unit_scale. Keep the + // actor root on the terrain while translating only the visual subtree. + ground_offset = -static_cast(bounds.position.z) * unit_scale; if (mesh->get_surface_count() > 0) { mi = memnew(MeshInstance3D); mi->set_name("MeshInstance3D"); @@ -1085,6 +1090,17 @@ void Metin2Model::_apply_materials() { return _load_dds(dir.path_join(dds_files[i])); } } + // Expansion armors share face textures with the base PC pack. + // Resolve that exact name before falling back to unrelated local DDS. + String cls = gr2_path.get_base_dir().get_file(); + for (const String &pc : { String("pc"), String("pc2") }) { + String spec = "d:/ymir work/" + pc + "/" + cls + "/" + want + ".dds"; + String resolved = resolve_rel_gr2(gr2_path, spec); + Ref shared = _load_dds(resolved); + if (shared.is_valid()) { + return shared; + } + } return Ref(); }; diff --git a/extension/src/metin2_model.h b/extension/src/metin2_model.h index 5a156088..dd5d077d 100644 --- a/extension/src/metin2_model.h +++ b/extension/src/metin2_model.h @@ -126,6 +126,9 @@ public: // One-line summary (bones / meshes / verts / bounds). godot::String get_info() const; + // 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; @@ -210,6 +213,7 @@ private: godot::Skeleton3D *skel = nullptr; godot::MeshInstance3D *mi = nullptr; godot::String last_info; + double ground_offset = 0.0; godot::HashMap> tex_cache; // §2.10 LOD diff --git a/extension/src/net/classic/classic_parser.cpp b/extension/src/net/classic/classic_parser.cpp index 9ca1d2f0..9f49d09e 100644 --- a/extension/src/net/classic/classic_parser.cpp +++ b/extension/src/net/classic/classic_parser.cpp @@ -1497,6 +1497,15 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) { return true; } + case HDR_GC_QUEST_SCRIPT: + // Optional extended quest UI data. Consume it even when the UI is absent. + return true; + case HDR_GC_EXTENDED_STATUS: + // Optional server status payload; framing is sufficient for connection safety. + return true; + case HDR_GC_EXTENDED_CHARACTER: + // Optional extended character payload; consume without requiring its UI. + return true; default: m_last_error = "phase " + m_phase_name + " does not handle GC header " + std::to_string(header); diff --git a/extension/src/net/classic/classic_session.cpp b/extension/src/net/classic/classic_session.cpp index 515b0fd5..b68e9a2b 100644 --- a/extension/src/net/classic/classic_session.cpp +++ b/extension/src/net/classic/classic_session.cpp @@ -21,13 +21,55 @@ std::string ipv4_from_slot_addr(int32_t addr) { std::to_string((value >> 16) & 0xffu) + "." + std::to_string((value >> 24) & 0xffu); } + +ClassicStream::RawPacketResult consume_mark_packet(uint8_t header, const uint8_t *packet, + size_t available) { + // 40250 guild-mark packets use a one-byte header followed by a u32 whole + // packet size. They can be pushed on the game connection while the map is + // loading, so they must be drained before the normal GC static-size table is + // consulted. The dedicated mark client owns decoding; the game session only + // needs to preserve framing here. + size_t head_size = 0; + if (header == HDR_GC_MARK_IDXLIST) { + head_size = sizeof(GCMarkIDXList); + } else if (header == HDR_GC_MARK_BLOCK) { + head_size = sizeof(GCMarkBlock); + } else { + return {ClassicStream::RawPacketStatus::NotHandled, 0}; + } + if (available < head_size) { + return {ClassicStream::RawPacketStatus::NeedMore, 0}; + } + uint32_t total = 0; + std::memcpy(&total, packet + 1, sizeof(total)); + constexpr uint32_t kMaxMarkPacket = 4u * 1024u * 1024u; + // Some 40250 builds emit an empty mark response as just the fixed head + // ([buf_size]=0, [count]=0) instead of filling buf_size with head_size. + // Consume that ten/seven-byte marker so the following GC packet remains + // aligned. + if (total == 0) { + return {ClassicStream::RawPacketStatus::Consumed, head_size}; + } + if (total < head_size || total > kMaxMarkPacket) { + return {ClassicStream::RawPacketStatus::Error, 0}; + } + if (available < total) { + return {ClassicStream::RawPacketStatus::NeedMore, 0}; + } + return {ClassicStream::RawPacketStatus::Consumed, total}; +} } // namespace ClassicSession::ClassicSession() : m_parser(m_world) { + m_stream.set_trace_name("game"); + m_auth_stream.set_trace_name("auth"); m_stream.on_phase = [this](uint8_t p) { on_phase(p); }; m_stream.on_packet = [this](uint8_t h, const uint8_t *b, uint32_t l) { return on_packet(h, b, l); }; + m_stream.on_raw_packet = [](uint8_t h, const uint8_t *packet, size_t available) { + return consume_mark_packet(h, packet, available); + }; m_stream.on_error = [this](const std::string &e) { if (m_auth_transition) { return; @@ -108,6 +150,7 @@ void ClassicSession::start_offline(const std::string &id, const std::string &pw) m_auth_login_sent = false; m_login_sent = false; m_entergame_sent = false; + m_loading_phase_seen = false; m_version_sent = false; m_game_connection_seen = false; m_direct_enter = false; @@ -206,6 +249,7 @@ bool ClassicSession::connect_direct_enter(int slot) { m_game_port = character.port; m_login_sent = false; m_entergame_sent = false; + m_loading_phase_seen = false; m_version_sent = false; m_stream.set_time_sync_mode(false); m_auth_transition = true; @@ -242,6 +286,7 @@ bool ClassicSession::connect_warp(const std::string &game_host, uint16_t game_po m_game_port = game_port; m_login_sent = false; m_entergame_sent = false; + m_loading_phase_seen = false; m_version_sent = false; m_stream.set_time_sync_mode(false); m_auth_transition = true; @@ -276,6 +321,7 @@ void ClassicSession::pump() { m_game_started = true; m_login_sent = false; m_entergame_sent = false; + m_loading_phase_seen = false; m_version_sent = false; m_stream.set_time_sync_mode(false); m_auth_transition = true; @@ -293,7 +339,8 @@ void ClassicSession::pump() { } // auto CG_ENTERGAME a beat into PHASE_LOADING (the m2dev client waits ~1.5s; // sending it too early makes the server drop us mid spawn-burst). - if (m_stage == Stage::Loading && !m_entergame_sent && m_entergame_delay > 0 && + if (m_stage == Stage::Loading && m_loading_phase_seen && !m_entergame_sent && + m_entergame_delay > 0 && m_now - m_loading_since >= m_entergame_delay) { if (!enter_game()) { m_last_error = m_stream.last_error().empty() ? "failed to send CG_ENTERGAME" : @@ -536,6 +583,7 @@ void ClassicSession::on_phase(uint8_t phase) { break; case PHASE_LOADING: set_stage(Stage::Loading); + m_loading_phase_seen = true; m_direct_enter = false; m_direct_enter_slot = -1; m_loading_since = m_now; diff --git a/extension/src/net/classic/classic_session.h b/extension/src/net/classic/classic_session.h index 4d9bdd07..8c124465 100644 --- a/extension/src/net/classic/classic_session.h +++ b/extension/src/net/classic/classic_session.h @@ -204,6 +204,10 @@ private: uint32_t m_now = 0; uint32_t m_loading_since = 0; uint32_t m_entergame_delay = 8000; + // DirectEnter reports a UI loading state while the server is still in + // PHASE_SELECT. Keep the auto-enter timer gated on the actual + // PHASE_LOADING packet so CG_ENTERGAME is never sent early. + bool m_loading_phase_seen = false; bool m_entergame_sent = false; bool m_login_sent = false; bool m_auth_login_sent = false; diff --git a/extension/src/net/classic/classic_stream.cpp b/extension/src/net/classic/classic_stream.cpp index ca5cfa08..e64cd9ab 100644 --- a/extension/src/net/classic/classic_stream.cpp +++ b/extension/src/net/classic/classic_stream.cpp @@ -235,7 +235,7 @@ bool ClassicStream::send_fixed(const void *struct_bytes, size_t n) { emit_bytes(struct_bytes, n); append_sequence_if_needed(header); if (m_trace) { - std::fprintf(stderr, "[classic] send hdr=%u n=%zu seq=%d\n", header, n, + std::fprintf(stderr, "[classic:%s] send hdr=%u n=%zu seq=%d\n", m_trace_name.c_str(), header, n, (m_seq_on && is_sequence_cg(header))); } return true; @@ -284,7 +284,7 @@ bool ClassicStream::handle_control(uint8_t header, bool &consumed) { on_server_handshake(); } if (m_trace) { - std::fprintf(stderr, "[classic] handshake echo time=%u\n", hs.time); + std::fprintf(stderr, "[classic:%s] handshake echo time=%u\n", m_trace_name.c_str(), hs.time); } return true; } @@ -309,7 +309,7 @@ bool ClassicStream::handle_control(uint8_t header, bool &consumed) { consumed = true; m_phase = p.phase; if (m_trace) { - std::fprintf(stderr, "[classic] phase -> %u\n", p.phase); + std::fprintf(stderr, "[classic:%s] phase -> %u\n", m_trace_name.c_str(), p.phase); } if (on_phase) { on_phase(p.phase); @@ -357,7 +357,7 @@ bool ClassicStream::handle_control(uint8_t header, bool &consumed) { m_cipher.decrypt(m_recv.mutable_unread(), m_recv.readable()); } if (m_trace) { - std::fprintf(stderr, "[classic] cipher activated\n"); + std::fprintf(stderr, "[classic:%s] cipher activated\n", m_trace_name.c_str()); } if (on_cipher_active) { on_cipher_active(); @@ -403,7 +403,7 @@ bool ClassicStream::handle_key_agreement() { // reply with our blob — still plaintext (peer activates on its own COMPLETED) emit_bytes(&out, sizeof(out)); if (m_trace) { - std::fprintf(stderr, "[classic] key agreement: replied, keys ready\n"); + std::fprintf(stderr, "[classic:%s] key agreement: replied, keys ready\n", m_trace_name.c_str()); } return true; } @@ -493,7 +493,7 @@ void ClassicStream::dispatch() { uint32_t body_len = dh.size - sizeof(DynHead); const uint8_t *body = m_recv.read_ptr(); if (m_trace) { - std::fprintf(stderr, "[classic] recv hdr=%u n=%u dynamic=1\n", header, dh.size); + std::fprintf(stderr, "[classic:%s] recv hdr=%u n=%u dynamic=1\n", m_trace_name.c_str(), header, dh.size); } bool ok = !on_packet || on_packet(header, body, body_len); discard_recv(body_len); @@ -517,6 +517,34 @@ void ClassicStream::dispatch() { m_last_error = "unknown GC header " + std::to_string(header) + " (last: " + std::to_string(m_last_pkt[0]) + "," + std::to_string(m_last_pkt[1]) + ")"; + if (m_trace) { + std::fprintf(stderr, "[classic:%s] unknown hdr=%u buffered=%zu bytes:", + m_trace_name.c_str(), header, m_recv.readable()); + const size_t dump = std::min(m_recv.readable(), 32); + for (size_t i = 0; i < dump; ++i) { + std::fprintf(stderr, " %02x", m_recv.read_ptr()[i]); + } + std::fprintf(stderr, "\n"); + if (m_recv.readable() >= 3) { + uint16_t candidate_size = 0; + std::memcpy(&candidate_size, m_recv.read_ptr() + 1, sizeof(candidate_size)); + const int after = candidate_size < m_recv.readable() ? m_recv.read_ptr()[candidate_size] : -1; + if (after < 0) { + std::fprintf(stderr, "[classic:%s] unknown candidate_size=%u after=none\n", + m_trace_name.c_str(), candidate_size); + } else { + std::fprintf(stderr, "[classic:%s] unknown candidate_size=%u after=0x%02x\n", + m_trace_name.c_str(), candidate_size, after); + } + if (candidate_size >= 3 && candidate_size <= m_recv.readable() && candidate_size <= 2048) { + std::fprintf(stderr, "[classic:%s] unknown candidate bytes:", m_trace_name.c_str()); + for (size_t i = 0; i < candidate_size; ++i) { + std::fprintf(stderr, " %02x", m_recv.read_ptr()[i]); + } + std::fprintf(stderr, "\n"); + } + } + } if (on_error) { on_error(m_last_error); } @@ -528,7 +556,16 @@ void ClassicStream::dispatch() { } const uint8_t *p = m_recv.read_ptr(); if (m_trace) { - std::fprintf(stderr, "[classic] recv hdr=%u n=%d dynamic=0\n", header, total); + std::fprintf(stderr, "[classic:%s] recv hdr=%u n=%d dynamic=0\n", m_trace_name.c_str(), header, total); + if (header == HDR_GC_LOGIN_SUCCESS_NEWSLOT || header == HDR_GC_SKILL_COOLTIME_END) { + const int next = m_recv.readable() > static_cast(total) ? p[total] : -1; + if (next < 0) { + std::fprintf(stderr, "[classic:%s] boundary hdr=%u next=none\n", m_trace_name.c_str(), header); + } else { + std::fprintf(stderr, "[classic:%s] boundary hdr=%u next=0x%02x\n", m_trace_name.c_str(), header, + next); + } + } } bool ok = !on_packet || on_packet(header, p + 1, static_cast(total - 1)); discard_recv(static_cast(total)); diff --git a/extension/src/net/classic/classic_stream.h b/extension/src/net/classic/classic_stream.h index 0579200a..ba3aca3d 100644 --- a/extension/src/net/classic/classic_stream.h +++ b/extension/src/net/classic/classic_stream.h @@ -23,6 +23,7 @@ #include #include #include +#include namespace mtnet::classic { @@ -94,6 +95,7 @@ public: void set_time_sync_mode(bool on) { m_time_sync_mode = on; } void set_wire_trace(bool on) { m_trace = on; } + void set_trace_name(std::string name) { m_trace_name = std::move(name); } // _IMPROVED_PACKET_ENCRYPTION_ (docs §3). Client polarity = true. bool cipher_active() const { return m_cipher.activated(); } @@ -135,6 +137,7 @@ private: bool m_handshake_seen = false; bool m_time_sync_mode = false; bool m_trace = false; + std::string m_trace_name = "stream"; uint8_t m_phase = PHASE_HANDSHAKE; uint8_t m_last_pkt[2] = {0, 0}; diff --git a/extension/src/net/classic/wire_classic.h b/extension/src/net/classic/wire_classic.h index edca7e00..fd3f5caa 100644 --- a/extension/src/net/classic/wire_classic.h +++ b/extension/src/net/classic/wire_classic.h @@ -276,6 +276,12 @@ enum : uint8_t { HDR_GC_EMPIRE = 90, HDR_GC_PARTY_LINK = 91, HDR_GC_PARTY_UNLINK = 92, + // 40250 extended quest/script notification; payload is dynamically framed. + HDR_GC_QUEST_SCRIPT = 148, + // Extended servers also emit a dynamically framed mount/status notice here. + HDR_GC_EXTENDED_STATUS = 59, + // 40250 extended character/status packet (dynamic framing). + HDR_GC_EXTENDED_CHARACTER = 105, HDR_GC_REFINE_INFORMATION_OLD = 95, HDR_GC_OBSERVER_ADD = 96, HDR_GC_OBSERVER_REMOVE = 97, @@ -1459,6 +1465,9 @@ constexpr bool is_dynamic_gc(uint8_t h) { case HDR_GC_GUILD: case HDR_GC_MESSENGER: case HDR_GC_QUEST_INFO: + case HDR_GC_QUEST_SCRIPT: + case HDR_GC_EXTENDED_STATUS: + case HDR_GC_EXTENDED_CHARACTER: case HDR_GC_DUEL_START: case HDR_GC_SYNC_POSITION: // client CMainPacketHeaderMap registers GC_WHISPER STATIC, but its diff --git a/extension/src/net/m2_client.cpp b/extension/src/net/m2_client.cpp index 6e528152..0ce66460 100644 --- a/extension/src/net/m2_client.cpp +++ b/extension/src/net/m2_client.cpp @@ -496,6 +496,7 @@ void M2Client::connect_to_server(const String &auth_host, int auth_port, const S // then connect to game_host with the returned login ticket. if (OS::get_singleton()->get_environment("MT_PROTOCOL") == "classic") { classic_sess = std::make_unique(); + classic_sess->set_wire_trace(OS::get_singleton()->get_environment("MT_NET_TRACE") == "1"); // §1.5 (W1 G1 wiring): CG_CLIENT_VERSION2 reports the running binary's // filename; feed it the real executable name rather than the built-in // default so the server-side version gate sees what ClientVS22 sends. @@ -2875,6 +2876,7 @@ void M2Client::warp_to_game_server(const String &host, int port) { mall_open_seen = false; dead_seen.clear(); classic_sess = std::make_unique(); + classic_sess->set_wire_trace(OS::get_singleton()->get_environment("MT_NET_TRACE") == "1"); bool connected = false; if (last_login_key != 0) { connected = classic_sess->connect_with_login_key(std::string(host.utf8().get_data()), diff --git a/extension/tools/net_classic_e2e.cpp b/extension/tools/net_classic_e2e.cpp index 54ff7aa4..1ae738af 100644 --- a/extension/tools/net_classic_e2e.cpp +++ b/extension/tools/net_classic_e2e.cpp @@ -76,6 +76,7 @@ int main(int argc, char **argv) { session.set_auto_entergame_delay(enter_delay_ms); session.set_wire_trace(std::getenv("MT_NET_TRACE") != nullptr); const bool use_auth = std::getenv("MT_CLASSIC_AUTH") != nullptr; + const bool use_direct_enter = std::getenv("MT_CLASSIC_DIRECT") != nullptr; const int auth_port = std::getenv("MT_CLASSIC_AUTH_PORT") ? std::atoi(std::getenv("MT_CLASSIC_AUTH_PORT")) : 11000; const bool connected = use_auth ? @@ -119,12 +120,16 @@ int main(int argc, char **argv) { session.disconnect(); return 3; } - if (!session.select_char(slot)) { - std::fprintf(stderr, "FAIL: select_char(%d)\n", slot); + const bool enter_ok = use_direct_enter ? session.connect_direct_enter(slot) : session.select_char(slot); + if (!enter_ok) { + std::fprintf(stderr, "FAIL: %s(%d): %s\n", + use_direct_enter ? "connect_direct_enter" : "select_char", slot, + session.last_error().c_str()); session.disconnect(); return 4; } - std::printf("[e2e] selected slot=%d\n", slot); + std::printf("[e2e] %s slot=%d\n", + use_direct_enter ? "direct-entered" : "selected", slot); selected = true; } diff --git a/libgr2/CMakeLists.txt b/libgr2/CMakeLists.txt index 526c3d1d..452998d1 100644 --- a/libgr2/CMakeLists.txt +++ b/libgr2/CMakeLists.txt @@ -22,4 +22,8 @@ if(BUILD_TESTING) add_executable(libgr2_loader_errors_test tests/loader_errors.cpp) target_link_libraries(libgr2_loader_errors_test PRIVATE xrender::libgr2) add_test(NAME libgr2.loader_errors COMMAND libgr2_loader_errors_test) + + add_executable(libgr2_animation_test tests/animation.cpp) + target_link_libraries(libgr2_animation_test PRIVATE xrender::libgr2) + add_test(NAME libgr2.animation COMMAND libgr2_animation_test) endif() diff --git a/libgr2/src/gr2_anim.cpp b/libgr2/src/gr2_anim.cpp index 14106ae5..49e47f0f 100644 --- a/libgr2/src/gr2_anim.cpp +++ b/libgr2/src/gr2_anim.cpp @@ -179,7 +179,11 @@ void Curve::eval(float t, float* out) const { const int first = (degree == 1) ? (i - 1) : (i - 2); // 拷本 span 的控制点(越界按端点 clamp),四元数做半球连续化 - float cp[3 * 4]; // 最多 3 点 × dim4 + // Metin2 的 ScaleShearCurve 是 9 维;旧的 3*4 缓冲区会在这里发生 + // 栈溢出(degree=2 时写入 27 个 float),随后被 __stack_chk_fail 终止。 + // 目前支持的曲线维度为 position=3、orientation=4、scale/shear=9。 + if (dim > 9) return; + float cp[3 * 9]; for (int s = 0; s < span; ++s) { int ci = first + s; ci = ci < 0 ? 0 : (ci >= (int)n ? (int)n - 1 : ci); diff --git a/libgr2/tests/animation.cpp b/libgr2/tests/animation.cpp new file mode 100644 index 00000000..7b110e10 --- /dev/null +++ b/libgr2/tests/animation.cpp @@ -0,0 +1,28 @@ +#include + +#include +#include + +int main() { + // ScaleShearCurve uses 9 values per control point. A quadratic span reads + // three control points, so this is the regression case for the former 3*4 + // stack buffer in Curve::eval(). + gr2::Curve curve; + curve.degree = 2; + curve.dim = 9; + curve.knots = {0.0f, 1.0f, 2.0f}; + curve.controls.resize(27); + for (size_t i = 0; i < curve.controls.size(); ++i) { + curve.controls[i] = 1.0f + static_cast(i) * 0.01f; + } + + float out[9] = {}; + curve.eval(0.5f, out); + for (float value : out) { + if (!std::isfinite(value)) { + std::fprintf(stderr, "non-finite 9D curve output\n"); + return 1; + } + } + return 0; +} diff --git a/macos.entitlements b/macos.entitlements new file mode 100644 index 00000000..8cc185af --- /dev/null +++ b/macos.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.cs.disable-library-validation + + + diff --git a/project/app_flow.gd b/project/app_flow.gd index 2bf105d9..1a49ff78 100644 --- a/project/app_flow.gd +++ b/project/app_flow.gd @@ -41,6 +41,12 @@ var _reconnecting_game := false var _mark_redl_after := 0 # guild-mark re-download cooldown (ticks_msec) # MT_AUTOLOGIN=1:进 LOGIN 自动连接,收到角色列表自动选第一个(冒烟 / CI 用)。 var auto_login := OS.get_environment("MT_AUTOLOGIN") == "1" and not _id.is_empty() and not _pw.is_empty() +var _auto_char_slot := -1 # MT_CHAR_SLOT:自动登录时优先选择的角色槽位。 + +func _init() -> void: + var raw_slot := OS.get_environment("MT_CHAR_SLOT").strip_edges() + if raw_slot.is_valid_int(): + _auto_char_slot = int(raw_slot) func start(assets_root: String = "", injected_client: Node = null) -> void: _assets = assets_root @@ -110,6 +116,11 @@ func _on_char_list(list: Array) -> void: _goto_select() if auto_login and not _chars.is_empty(): var idx := int(_chars[0].get("index", 0)) + if _auto_char_slot >= 0: + for character in _chars: + if int(character.get("index", -1)) == _auto_char_slot: + idx = _auto_char_slot + break if client.has_method("enter_game"): client.enter_game(idx) elif client.has_method("select_character"): diff --git a/project/char_select_visual_test.gd b/project/char_select_visual_test.gd new file mode 100644 index 00000000..f912e959 --- /dev/null +++ b/project/char_select_visual_test.gd @@ -0,0 +1,43 @@ +# Render the selection screen with deterministic reference data; no login needed. +extends SceneTree + +const Screen = preload("res://ui/char_select_screen.gd") + +class Client extends Node: + func get_empire() -> int: return 3 + func get_slot_count() -> int: return 4 + +func _initialize() -> void: + call_deferred("_run") + +func _run() -> void: + root.size = Vector2i(1472, 854) + var client := Client.new() + root.add_child(client) + var screen := Screen.new() + root.add_child(screen) + screen.setup(client, AssetRoot.path(), [{ + "index": 0, "name": "[SA]Admin", "job": 0, "level": 105, + "guild_name": "[GM-TEAM]", "play_minutes": 2180, + "ht": 90, "iq": 15, "st": 90, "dx": 90, + "main_part": 20009, "hair_part": 0, + }]) + for i in 40: + await process_frame + if screen._pv == null: + printerr("FAIL: selection model did not load") + quit(1) + return + print("Selected armor: ", screen._pv.model.get("gr2_path")) + for mi in screen._pv.find_children("*", "MeshInstance3D", true, false): + for surface in mi.mesh.get_surface_count(): + print("SURFACE ", surface, " ", mi.mesh.surface_get_name(surface)) + if not String(screen._pv.model.get("gr2_path")).to_lower().contains("warrior_5_1"): + printerr("FAIL: server armor was not applied") + quit(1) + return + if DisplayServer.get_name() != "headless": + await RenderingServer.frame_post_draw + root.get_texture().get_image().save_png("/tmp/metin2-char-select.png") + print("PASS: selection model, server armor and UI assembly") + quit() diff --git a/project/client_main.gd b/project/client_main.gd index 06c3a936..46ddc97c 100644 --- a/project/client_main.gd +++ b/project/client_main.gd @@ -6,6 +6,7 @@ extends Node const AppFlow = preload("res://app_flow.gd") +const LiveSmokeTest = preload("res://live_smoke_test.gd") func _ready() -> void: # 打包 app 从 Finder/open 启动时没有 shell 环境;40250 服务器使用 classic @@ -21,3 +22,8 @@ func _ready() -> void: af.name = "AppFlow" add_child(af) af.start(root) + if OS.get_environment("MT_TEST_MODE") == "smoke": + var smoke := LiveSmokeTest.new() + smoke.name = "LiveSmokeTest" + add_child(smoke) + smoke.setup(af, af.client) diff --git a/project/game_camera.gd b/project/game_camera.gd index fc5a1698..892c65e4 100644 --- a/project/game_camera.gd +++ b/project/game_camera.gd @@ -15,8 +15,8 @@ enum CameraMode { # ClientVS22 stores camera settings in centimetres/degrees. The Godot scene # is metres/radians, so keep the conversion at this boundary instead of # leaking mixed units into the orbit controls. -const DEFAULT_DISTANCE_CM := 1550.0 -const DEFAULT_PITCH_DEG := 27.0 +const DEFAULT_DISTANCE_CM := 2100.0 +const DEFAULT_PITCH_DEG := 34.0 const DEFAULT_ROTATION_DEG := 0.0 const DEFAULT_HEIGHT_CM := 100.0 @@ -74,7 +74,8 @@ const FADE_ALPHA := 0.72 # 挡住玩家时的透明度 var _faded: Array[Node] = [] # 上一帧被淡出的 GeometryInstance3D func _ready() -> void: - fov = 55.0 + # Match the original third-person framing: farther camera and narrower lens. + fov = 45.0 far = 4000.0 _apply_setting(_normal_setting()) if target: diff --git a/project/game_scene.gd b/project/game_scene.gd index 6260eaa6..809a0b3c 100644 --- a/project/game_scene.gd +++ b/project/game_scene.gd @@ -183,6 +183,11 @@ func setup(m2client: Node, assets_root: String, net_world.set_camera(cam) net_world.set_local_node(player) net_world.entity_added.connect(func(node: Node3D, _vid: int): + # Model-factory upgrades replace an earlier placeholder node in place. + # Drop queued-for-free entries before registering the rebuilt pick target. + for old in pc.pickables.duplicate(): + if old == null or not is_instance_valid(old): + pc.pickables.erase(old) if node not in pc.pickables: pc.pickables.append(node)) net_world.entity_removed.connect(func(vid: int): @@ -272,7 +277,7 @@ func setup(m2client: Node, assets_root: String, add_child(quickbar) quickbar.item_mouse = mouse_controller quickbar.net_play = net_play # §3.8 修改 1:技能三层校验的运行期上下文 - quickbar.setup(client, skill_table, ui, func() -> Node: return player) + quickbar.setup(client, skill_table, ui, func() -> Node: return player, assets_root) # §3.4 MODE_USE_SKILL:预约技能进射程后由 quickbar 执行实际施法。 if net_play: net_play.use_skill_hook = func(slot: int) -> bool: return quickbar.activate_reserved(slot) @@ -1333,12 +1338,25 @@ func _build_lighting() -> void: e.background_color = Color(0.55, 0.62, 0.72) e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR e.ambient_light_color = Color(0.5, 0.55, 0.62) - e.ambient_light_energy = 0.6 + e.ambient_light_energy = 0.42 + e.tonemap_mode = Environment.TONE_MAPPER_LINEAR + e.tonemap_exposure = 0.82 + e.adjustment_enabled = true + e.adjustment_saturation = 1.05 + e.adjustment_contrast = 1.08 + e.adjustment_brightness = 0.88 we.environment = e add_child(we) _env = e func _on_main_set(vid: int) -> void: + # GC_MAIN_CHARACTER can arrive after GameScene has subscribed. Mark this VID + # as local before building its view so NetWorld removes (and never recreates) + # the generic remote-player capsule for our own character. + if net_world and net_world.has_method("set_local_vid"): + net_world.set_local_vid(vid) + if net_world and net_world.has_method("set_local_node") and player: + net_world.set_local_node(player) var e: Dictionary = client.get_entity(vid) if e.is_empty() or player == null: return diff --git a/project/live_smoke_test.gd b/project/live_smoke_test.gd new file mode 100644 index 00000000..26ca9cde --- /dev/null +++ b/project/live_smoke_test.gd @@ -0,0 +1,276 @@ +# live_smoke_test —— 真实服务器端到端冒烟测试。 +# +# 由 script/live_smoke_test.sh 以 MT_TEST_MODE=smoke 启动。测试流程: +# 自动登录 → 角色列表 → 进入游戏 → 世界/属性读取 → 移动/停止 → 聊天 +# → 可选重连 → 输出 JSON 报告并以 PASS/FAIL 退出。 +# +# 只使用可逆的常规操作,不执行删角、丢弃、强化、交易等破坏性动作。 +extends Node + +const FUNC_WAIT := 0 +const FUNC_MOVE := 1 +const CHAT_TALKING := 0 +const SETTLE_SECONDS := 2.5 +const STEP_TIMEOUT_SECONDS := 25.0 +const TOTAL_TIMEOUT_SECONDS := 120.0 + +var _flow: Node +var _client: Node +var _state := "boot" +var _state_since_ms := 0 +var _started_ms := 0 +var _entered_count := 0 +var _phase_history: Array[String] = [] +var _checks: Array[Dictionary] = [] +var _failures: Array[String] = [] +var _events := { + "char_list": 0, + "entered_game": 0, + "entity_moved": 0, + "chat": 0, + "disconnected": 0, +} +var _origin := Vector3.ZERO +var _origin_valid := false +var _mutations := true +var _reconnect := true +var _finished := false +var _finish_queued := false +var _report_path := "" + +func setup(flow: Node, m2client: Node) -> void: + _flow = flow + _client = m2client + _started_ms = Time.get_ticks_msec() + _state_since_ms = _started_ms + _mutations = OS.get_environment("MT_SMOKE_MUTATIONS") != "0" + _reconnect = OS.get_environment("MT_SMOKE_RECONNECT") != "0" + _report_path = OS.get_environment("MT_TEST_REPORT").strip_edges() + if _report_path.is_empty(): + _report_path = "user://live_smoke_report.json" + + if _client == null: + _fail("M2Client 未创建") + _finish_later() + return + if _client.has_signal("stage_changed"): + _client.stage_changed.connect(_on_stage_changed) + if _client.has_signal("phase_changed"): + _client.phase_changed.connect(_on_phase_changed) + if _client.has_signal("char_list"): + _client.char_list.connect(_on_char_list) + if _client.has_signal("entered_game"): + _client.entered_game.connect(_on_entered_game) + if _client.has_signal("login_failed"): + _client.login_failed.connect(_on_login_failed) + if _client.has_signal("disconnected"): + _client.disconnected.connect(_on_disconnected) + if _client.has_signal("entity_moved"): + _client.entity_moved.connect(_on_entity_moved) + if _client.has_signal("chat"): + _client.chat.connect(_on_chat) + + _state = "waiting_login" + _log("START mutations=%s reconnect=%s report=%s" % [_mutations, _reconnect, _report_path]) + _check(OS.get_environment("MT_PROTOCOL") == "classic", "classic 协议已启用") + _check(OS.get_environment("MT_ACCOUNT") != "", "账号环境变量已提供") + _check(OS.get_environment("MT_PASSWORD") != "", "密码环境变量已提供") + +func _process(_delta: float) -> void: + if _finished: + return + var now := Time.get_ticks_msec() + var total_s := float(now - _started_ms) / 1000.0 + if total_s > TOTAL_TIMEOUT_SECONDS: + _fail("总超时,当前状态=%s" % _state) + _finish_later() + return + + match _state: + "settle_first": + if _seconds_in_state(now) >= SETTLE_SECONDS: + _run_world_checks() + "wait_move": + if _seconds_in_state(now) >= 1.5: + _send_stop() + "wait_chat": + if _seconds_in_state(now) >= 0.5: + _send_chat() + "wait_reconnect": + if _seconds_in_state(now) >= STEP_TIMEOUT_SECONDS: + _fail("重连后未重新进入游戏") + _finish_later() + "settle_second": + if _seconds_in_state(now) >= SETTLE_SECONDS: + _check(_client != null and _client.is_in_game(), "重连后客户端仍处于游戏状态") + _finish_later() + "waiting_login", "waiting_first_game": + if _seconds_in_state(now) >= STEP_TIMEOUT_SECONDS: + _fail("阶段超时:%s" % _state) + _finish_later() + +func _seconds_in_state(now: int) -> float: + return float(now - _state_since_ms) / 1000.0 + +func _set_state(next_state: String) -> void: + _state = next_state + _state_since_ms = Time.get_ticks_msec() + _log("STATE %s" % _state) + +func _on_stage_changed(stage: String) -> void: + _log("stage=%s" % stage) + +func _on_phase_changed(phase: String) -> void: + _phase_history.append(phase) + _log("phase=%s" % phase) + +func _on_char_list(characters: Array) -> void: + _events["char_list"] += 1 + _check(not characters.is_empty(), "角色列表非空") + if characters.is_empty(): + _fail("服务器返回空角色列表") + _finish_later() + else: + _log("char_list count=%d" % characters.size()) + +func _on_entered_game() -> void: + _entered_count += 1 + _events["entered_game"] = _entered_count + _check(true, "第 %d 次进入游戏" % _entered_count) + if _entered_count == 1: + _set_state("settle_first") + elif _state == "wait_reconnect": + _set_state("settle_second") + +func _on_login_failed(reason: String) -> void: + _fail("登录失败:%s" % reason) + _finish_later() + +func _on_disconnected(reason: String) -> void: + _events["disconnected"] += 1 + _log("disconnected=%s" % reason) + if _state != "wait_reconnect" and _state != "settle_second": + _fail("测试期间意外断线:%s" % reason) + _finish_later() + +func _on_entity_moved(_vid: int) -> void: + _events["entity_moved"] += 1 + +func _on_chat(_type: int, _vid: int, _text: String) -> void: + _events["chat"] += 1 + +func _run_world_checks() -> void: + var in_game: bool = _client != null and _client.has_method("is_in_game") and _client.is_in_game() + _check(in_game, "客户端处于 in_game") + var main_vid := int(_client.get_main_vid()) if _client.has_method("get_main_vid") else 0 + _check(main_vid != 0, "主角色 VID 有效") + if main_vid == 0: + _fail("主角色不存在,无法继续常规测试") + _finish_later() + return + + var main_entity: Dictionary = _client.get_entity(main_vid) + _check(not main_entity.is_empty(), "主角色实体可读取") + var entities: Array = _client.get_entities() + _check(not entities.is_empty(), "世界实体列表可读取") + var points: Dictionary = _client.get_points() + _check(not points.is_empty(), "属性数据可读取") + _check(_has_method_and_array("get_inventory"), "背包接口可读取") + _check(_has_method_and_array("get_skills"), "技能接口可读取") + _check(_has_method_and_array("get_quickslots"), "快捷栏接口可读取") + _check(_has_method_and_array("get_affects"), "状态接口可读取") + + var p: Variant = main_entity.get("pos_cm", null) + if p is Vector3: + _origin = p + _origin_valid = true + _log("main_vid=%d pos_cm=%s entities=%d" % [main_vid, p, entities.size()]) + else: + _fail("主角色缺少 pos_cm") + + if not _mutations: + _finish_later() + return + _send_move() + +func _has_method_and_array(method_name: String) -> bool: + if _client == null or not _client.has_method(method_name): + return false + var value: Variant = _client.call(method_name) + return value is Array + +func _send_move() -> void: + if not _origin_valid: + _finish_later() + return + var ok := bool(_client.move(FUNC_MOVE, 0, 0.0, int(_origin.x) + 100, int(_origin.y))) + _check(ok, "发送 1 米移动包") + _set_state("wait_move") + +func _send_stop() -> void: + var ok := bool(_client.move(FUNC_WAIT, 0, 0.0, int(_origin.x), int(_origin.y))) + _check(ok, "发送停止移动包") + _set_state("wait_chat") + +func _send_chat() -> void: + var ok := bool(_client.say(CHAT_TALKING, "[smoke] client live smoke test")) + _check(ok, "发送普通聊天包") + if not _reconnect: + _finish_later() + return + var reconnect_ok := bool(_client.reconnect()) + _check(reconnect_ok, "调用客户端重连") + if reconnect_ok: + _set_state("wait_reconnect") + else: + _finish_later() + +func _check(ok: bool, name: String) -> void: + _checks.append({"name": name, "ok": ok}) + if ok: + print("LIVE_SMOKE PASS: " + name) + else: + _fail(name) + +func _fail(message: String) -> void: + if not _failures.has(message): + _failures.append(message) + printerr("LIVE_SMOKE FAIL: " + message) + +func _log(message: String) -> void: + print("LIVE_SMOKE: " + message) + +func _finish_later() -> void: + if _finish_queued or _finished: + return + _finish_queued = true + call_deferred("_finish") + +func _finish() -> void: + if _finished: + return + _finished = true + var passed := _failures.is_empty() + var report := { + "status": "PASS" if passed else "FAIL", + "started_at_ms": _started_ms, + "duration_seconds": float(Time.get_ticks_msec() - _started_ms) / 1000.0, + "entered_game_count": _entered_count, + "phase_history": _phase_history, + "events": _events, + "checks": _checks, + "failures": _failures, + "mutations": _mutations, + "reconnect": _reconnect, + } + var file := FileAccess.open(_report_path, FileAccess.WRITE) + if file: + file.store_string(JSON.stringify(report, " ")) + file.close() + else: + printerr("LIVE_SMOKE: 无法写入报告:" + _report_path) + if passed: + print("LIVE_SMOKE RESULT: PASS") + else: + printerr("LIVE_SMOKE RESULT: FAIL (%d failures)" % _failures.size()) + get_tree().quit(0 if passed else 1) diff --git a/project/live_smoke_test.gd.uid b/project/live_smoke_test.gd.uid new file mode 100644 index 00000000..5b04b146 --- /dev/null +++ b/project/live_smoke_test.gd.uid @@ -0,0 +1 @@ +uid://d4kxv31wxrhj5 diff --git a/project/net_world.gd b/project/net_world.gd index 00ee59c8..1eec351d 100644 --- a/project/net_world.gd +++ b/project/net_world.gd @@ -213,6 +213,23 @@ func catch_up() -> void: func set_model_factory(f: Callable) -> void: _model_factory = f + # The game server can send the initial actor burst before GameScene has + # finished loading mob/item proto tables. Those actors were necessarily made + # as capsules; once the real factory is available, replace them from the + # authoritative EntityStore rows instead of leaving mixed placeholder/GR2 + # models on screen. + _refresh_existing_model_views() + +func _refresh_existing_model_views() -> void: + if not _model_factory.is_valid() or client == null or not client.has_method("get_entity"): + return + for vid in _by_vid.keys().duplicate(): + var id := int(vid) + if id == _local_vid: + continue + var d: Dictionary = client.get_entity(id) + if not d.is_empty(): + _on_spawn(d) # 本地玩家用 player_controller 的节点代表,这里就不要再画一个。 func set_local_vid(vid: int) -> void: diff --git a/project/skill_test.gd b/project/skill_test.gd index 46e75751..faaa42b9 100644 --- a/project/skill_test.gd +++ b/project/skill_test.gd @@ -5,6 +5,7 @@ extends SceneTree const SkillTable = preload("res://ui/skill_table.gd") const SkillUI = preload("res://ui/skill_ui.gd") const Quickbar = preload("res://ui/quickbar.gd") +const UiAssets = preload("res://ui/ui_assets.gd") const UiManager = preload("res://ui/ui_manager.gd") class FakeClient extends Node: @@ -74,6 +75,11 @@ func _run() -> void: # 列偏移修正后:skill 1 'Three-Way Cut' motion_name=samyeon motion_idx=1, ATTACK_SKILL _ck(st.motion_idx_of(1) == 1, "skill 1 motion_idx == 1 (列偏移已修)") _ck(String(s1.get("motion", "")) == "samyeon", "skill 1 motion_name == samyeon") + var skill_icon := UiAssets.load_tex(assets, "ETC/ymir work/ui/skill/warrior/samyeon_01.sub") + _ck(skill_icon != null, "skill 1 icon loads from .sub + shared SkillWarrior.dds") + if skill_icon: + _ck(skill_icon.get_width() == 32 and skill_icon.get_height() == 32, + "skill 1 icon region is 32x32") _ck(st.is_attack(1), "skill 1 是 ATTACK_SKILL") _ck(st.is_ranged(46), "skill 46 bow skill is ranged") _ck(not st.is_ranged(1), "skill 1 melee skill is not ranged") @@ -261,6 +267,8 @@ func _run() -> void: get_root().add_child(qb) qb.setup(fc, st, ui, func() -> Node: return pl) qb.assign(0, "skill", 1) + _ck(qb._slots[0].icon.texture != null, "quickbar skill slot shows skill icon") + _ck(String(qb._slots[0].lbl.text) == "", "quickbar hides text when skill icon is available") _ck(fc.quickslot_ops == [["add", 0, 2, 1]], "assign -> CG_QUICKSLOT_ADD slot 0") qb.activate(0) _ck(fc.skill_uses.size() == 1 and fc.skill_uses[0] == [1, 2000], diff --git a/project/ui/char_select_screen.gd b/project/ui/char_select_screen.gd index 8b6cb7af..562dce55 100644 --- a/project/ui/char_select_screen.gd +++ b/project/ui/char_select_screen.gd @@ -15,6 +15,8 @@ extends Control const UiAssets = preload("res://ui/ui_assets.gd") const UiKit = preload("res://ui_kit.gd") const PlayerView = preload("res://ui/player_view.gd") +const EquipModel = preload("res://ui/equip_model.gd") +const DESIGN_SIZE := Vector2(1472, 854) signal select_requested(index: int) signal delete_requested(index: int, private_code: String) @@ -61,9 +63,11 @@ var _class_img: TextureRect var _class_lbl: Label var _status: Label var _slot_lbl: Label -var _spin := 0.0 var _last_empire := -1 var _rename_pending := false +var _layout: Control +var _board: Control +var _proto: Node func setup(client: Node, assets_root: String, chars: Array) -> void: _client = client @@ -72,9 +76,15 @@ func setup(client: Node, assets_root: String, chars: Array) -> void: set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) _build_background() _build_stage() + _layout = Control.new() + _layout.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(_layout) _build_class_name() _build_panel() + _build_navigation() _build_status() + resized.connect(_resize_layout) + _resize_layout() _last_empire = _empire_id() set_process(true) _select(_first_occupied()) @@ -181,7 +191,8 @@ func _build_background() -> void: tr.texture = tex tr.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) tr.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - tr.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED + # The original stretches the 1024x768 atlas region to the window. + tr.stretch_mode = TextureRect.STRETCH_SCALE tr.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(tr) @@ -206,12 +217,12 @@ func _build_stage() -> void: var key := DirectionalLight3D.new() key.rotation_degrees = Vector3(-32, 28, 0) - key.light_energy = 1.7 + key.light_energy = 0.9 key.light_color = Color(1.0, 0.96, 0.90) root.add_child(key) var rim := DirectionalLight3D.new() rim.rotation_degrees = Vector3(-6, 200, 0) - rim.light_energy = 0.7 + rim.light_energy = 0.25 rim.light_color = Color(0.75, 0.83, 1.0) root.add_child(rim) @@ -220,17 +231,18 @@ func _build_stage() -> void: e.background_mode = Environment.BG_COLOR e.background_color = Color(0, 0, 0, 0) e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR - e.ambient_light_color = Color(0.42, 0.45, 0.55) - e.ambient_light_energy = 0.9 - e.tonemap_mode = Environment.TONE_MAPPER_FILMIC + e.ambient_light_color = Color(0.8, 0.8, 0.8) + e.ambient_light_energy = 0.65 + e.tonemap_mode = Environment.TONE_MAPPER_LINEAR we.environment = e root.add_child(we) _pivot = Node3D.new() + _pivot.rotation.y = -PI * 0.5 root.add_child(_pivot) _cam = Camera3D.new() - _cam.fov = 30.0 + _cam.fov = 10.0 _cam.position = Vector3(0.0, 1.0, 4.0) root.add_child(_cam) _cam.make_current() @@ -251,29 +263,56 @@ func _frame_model() -> void: first = false if first: return - var c := aabb.get_center() - var r: float = maxf(aabb.size.length() * 0.5, 0.9) - var dist: float = r / sin(deg_to_rad(_cam.fov * 0.5)) * 1.12 # 12% 余量 - var off := Vector3(-r * 0.5, 0.0, 0.0) - _cam.position = c + off + Vector3(0.0, r * 0.04, dist) - _cam.look_at(c + off, Vector3.UP) + var height := maxf(aabb.size.y, 1.0) + # Reference: head at 14%, feet at 85%, center at 59% of the window. + var view_height := height / 0.71 + var dist := view_height / (2.0 * tan(deg_to_rad(_cam.fov * 0.5))) + var aspect := size.x / maxf(size.y, 1.0) + var target := aabb.get_center() + target.x -= view_height * aspect * 0.09 + target.y -= height * 0.007 + _cam.position = target + Vector3(0, dist * sin(deg_to_rad(6.0)), dist * cos(deg_to_rad(6.0))) + _cam.look_at(target, Vector3.UP) func _rebuild_model() -> void: if _pivot == null: return if _pv != null and is_instance_valid(_pv): + _pv.hide() _pv.queue_free() _pv = null var c := _cur() if c.is_empty() or String(c.get("name", "")) == "": return - var job := clampi(int(c.get("job", 0)), 0, 3) + var job := clampi(int(c.get("job", 0)), 0, 7) var pump: Callable = _client.net_poll if _client and _client.has_method("net_poll") else Callable() var pv := PlayerView.new() _pivot.add_child(pv) if pv.build(_assets, job, pump): _pv = pv - pv.set_anim_state("wait") + if _proto == null and ClassDB.class_exists("Metin2Proto"): + _proto = ClassDB.instantiate("Metin2Proto") + add_child(_proto) + for lang in ["en", "common"]: + var ip := _assets.path_join("locale/locale/%s/item_proto" % lang) + if FileAccess.file_exists(ip): + _proto.call("load_item_proto", ip) + break + var equip := EquipModel.new() + pv.add_child(equip) + equip.setup_remote(null, func() -> Node: return pv, _assets, job, + [int(c.get("main_part", 0)), 0, 0, int(c.get("hair_part", 0))], _proto) + # Expansion armor textures live beside their GR2, outside the base PC pack. + var body_path := String(pv.model.get("gr2_path")) + pv.model.set("texture_dir", body_path.get_base_dir()) + # Shape zero uses the head/hair already included in the armor mesh. + if int(c.get("hair_part", 0)) == 0: + pv.model.set("hair_gr2", "") + pv._ground_model() + var intro := pv.motion_dir.get_base_dir().path_join("intro/wait.msa") + if pv.anim and FileAccess.file_exists(intro): + pv.anim.set("anim_path", intro) + pv.anim.set("loop", true) # 蒙皮 / LOD 定型要几帧,多 fit 两次取稳定包围盒 for i in 4: await get_tree().process_frame @@ -287,22 +326,22 @@ func _rebuild_model() -> void: func _build_class_name() -> void: _class_img = TextureRect.new() - _class_img.position = Vector2(48, 60) + _class_img.position = Vector2(92, 164) _class_img.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT _class_img.mouse_filter = Control.MOUSE_FILTER_IGNORE - add_child(_class_img) + _layout.add_child(_class_img) _class_lbl = Label.new() - _class_lbl.position = Vector2(50, 54) + _class_lbl.position = Vector2(120, 180) _class_lbl.add_theme_font_size_override("font_size", 84) _class_lbl.add_theme_color_override("font_color", Color(0.86, 0.11, 0.09)) _class_lbl.add_theme_color_override("font_outline_color", Color(0.15, 0.05, 0.02)) _class_lbl.add_theme_constant_override("outline_size", 10) _class_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE - add_child(_class_lbl) + _layout.add_child(_class_lbl) func _refresh_class_name() -> void: - var job := clampi(int(_cur().get("job", 0)), 0, 3) + var job := int(_cur().get("job", 0)) & 3 var has_char := not _cur().is_empty() and String(_cur().get("name", "")) != "" var tex: Texture2D = null if has_char: @@ -319,27 +358,29 @@ func _refresh_class_name() -> void: # --- 左侧信息板 -------------------------------------------------------------- func _build_panel() -> void: - var board := UiKit.board(_assets, "board", 24, 96) - board.position = Vector2(56, 168) - board.size = Vector2(300, 396) + var board := UiKit.board(_assets, "thinboard", 16, 16) + _board = board + board.position = Vector2(119, 313) + board.size = Vector2(208, 323) board.mouse_filter = Control.MOUSE_FILTER_IGNORE - add_child(board) + _layout.add_child(board) # 板底纹太透就垫一层 var tint := ColorRect.new() tint.color = Color(0.03, 0.05, 0.06, 0.62) tint.position = board.position + Vector2(10, 10) tint.size = board.size - Vector2(20, 20) tint.mouse_filter = Control.MOUSE_FILTER_IGNORE - add_child(tint) + _layout.add_child(tint) _panel_box = VBoxContainer.new() - _panel_box.position = board.position + Vector2(22, 22) - _panel_box.custom_minimum_size = Vector2(256, 0) - _panel_box.add_theme_constant_override("separation", 9) - add_child(_panel_box) + _panel_box.position = board.position + Vector2(14, 12) + _panel_box.custom_minimum_size = Vector2(180, 0) + _panel_box.add_theme_constant_override("separation", 6) + _layout.add_child(_panel_box) func _refresh_panel() -> void: for c in _panel_box.get_children(): + _panel_box.remove_child(c) c.queue_free() var d := _cur() var has_char := not d.is_empty() and String(d.get("name", "")) != "" @@ -349,7 +390,8 @@ func _refresh_panel() -> void: head.add_theme_constant_override("separation", 10) _panel_box.add_child(head) var crest := TextureRect.new() - crest.custom_minimum_size = Vector2(56, 56) + crest.custom_minimum_size = Vector2(70, 40) + crest.expand_mode = TextureRect.EXPAND_IGNORE_SIZE crest.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT var gid := int(d.get("guild_id", 0)) if has_char and gid != 0 and _client and _client.has_method("get_guild_mark_image"): @@ -365,9 +407,9 @@ func _refresh_panel() -> void: head.add_child(crest) var gbox := VBoxContainer.new() head.add_child(gbox) - gbox.add_child(_plain(_empire_name() if has_char else "—", 14, Color(0.95, 0.9, 0.7))) + gbox.add_child(_plain(_empire_name() if has_char else "—", 10, Color(0.95, 0.9, 0.7))) var gname := String(d.get("guild_name", "")).strip_edges() - gbox.add_child(_plain(gname if gname != "" else "没有所属帮会", 13, Color(0.8, 0.82, 0.86))) + gbox.add_child(_plain(gname if gname != "" else "没有所属帮会", 10, Color(0.8, 0.82, 0.86))) _panel_box.add_child(_sep()) @@ -413,21 +455,6 @@ func _refresh_panel() -> void: ) erow.add_child(eb) - # 槽位切换 - if _chars.size() > 1: - var nav := HBoxContainer.new() - nav.alignment = BoxContainer.ALIGNMENT_CENTER - nav.add_theme_constant_override("separation", 12) - _panel_box.add_child(nav) - var prev := Button.new(); prev.text = "◀"; prev.custom_minimum_size = Vector2(38, 28) - prev.pressed.connect(func(): _select(wrapi(_sel - 1, 0, _chars.size()))) - nav.add_child(prev) - _slot_lbl = _plain("%d / %d" % [_sel + 1, _chars.size()], 14, Color(1, 1, 1)) - nav.add_child(_slot_lbl) - var nxt := Button.new(); nxt.text = "▶"; nxt.custom_minimum_size = Vector2(38, 28) - nxt.pressed.connect(func(): _select(wrapi(_sel + 1, 0, _chars.size()))) - nav.add_child(nxt) - # 按钮 var brow := GridContainer.new() brow.columns = 2 @@ -436,28 +463,78 @@ func _refresh_panel() -> void: _panel_box.add_child(brow) var start := Button.new() start.text = "开始" - start.custom_minimum_size = Vector2(122, 34) + start.custom_minimum_size = Vector2(180, 22) + _style_button(start) start.disabled = not has_char or empire_required start.pressed.connect(_do_start) - brow.add_child(start) + _panel_box.add_child(start) + _panel_box.move_child(start, _panel_box.get_child_count() - 2) + start.visible = has_char var create := Button.new() create.text = "创建" - create.custom_minimum_size = Vector2(122, 34) + create.custom_minimum_size = Vector2(180, 22) + _style_button(create) create.disabled = has_char or empire_required create.pressed.connect(_open_create_dialog) - brow.add_child(create) + _panel_box.add_child(create) + _panel_box.move_child(create, _panel_box.get_child_count() - 2) + create.visible = not has_char var del := Button.new() del.text = "删除" - del.custom_minimum_size = Vector2(122, 34) + del.custom_minimum_size = Vector2(86, 20) + _style_button(del) del.disabled = not has_char or empire_required del.pressed.connect(_open_delete_dialog) brow.add_child(del) var quit := Button.new() quit.text = "退出" - quit.custom_minimum_size = Vector2(122, 34) + quit.custom_minimum_size = Vector2(86, 20) + _style_button(quit) quit.pressed.connect(func(): back_requested.emit()) brow.add_child(quit) +func _style_button(button: Button) -> void: + button.add_theme_font_size_override("font_size", 10) + for state in ["normal", "hover", "pressed", "disabled"]: + var suffix := "02" if state == "hover" else "03" if state == "pressed" else "01" + var prefix := "XLarge" if button.custom_minimum_size.x > 100 else "Large" + var texture := UiAssets.load_tex(_assets, + "d:/ymir work/ui/public/%s_Button_%s.sub" % [prefix, suffix]) + if texture: + var textured := StyleBoxTexture.new() + textured.texture = texture + textured.set_texture_margin_all(3) + button.add_theme_stylebox_override(state, textured) + continue + var style := StyleBoxFlat.new() + style.bg_color = Color(0.22, 0.22, 0.20, 0.95) + if state == "hover": + style.bg_color = Color(0.32, 0.31, 0.27) + style.border_color = Color(0.48, 0.46, 0.39) + style.set_border_width_all(1) + style.set_corner_radius_all(3) + button.add_theme_stylebox_override(state, style) + +func _build_navigation() -> void: + for direction in [-1, 1]: + var button := TextureButton.new() + var side := "left" if direction < 0 else "right" + var base := "d:/ymir work/ui/intro/select/dragon_%s_button_" % side + button.texture_normal = UiAssets.load_tex(_assets, base + "01.sub") + button.texture_hover = UiAssets.load_tex(_assets, base + "02.sub") + button.texture_pressed = UiAssets.load_tex(_assets, base + "03.sub") + button.position = Vector2(714 if direction < 0 else 1024, 712) + button.custom_minimum_size = Vector2(160, 44) + button.tooltip_text = "上一个角色" if direction < 0 else "下一个角色" + button.pressed.connect(func(): _select(wrapi(_sel + direction, 0, _chars.size()))) + _layout.add_child(button) + +func _resize_layout() -> void: + if _layout: + _layout.size = DESIGN_SIZE + _layout.scale = size / DESIGN_SIZE + _frame_model() + # --- 底部状态 -------------------------------------------------------------- func _build_status() -> void: @@ -746,15 +823,11 @@ func _empire_flag_tex() -> Texture2D: return null return UiAssets.load_tex(_assets, "d:/ymir work/ui/intro/empire/empireflag_%s.sub" % f) -func _process(dt: float) -> void: - _spin += dt +func _process(_dt: float) -> void: var empire := _empire_id() if empire != _last_empire: _last_empire = empire _refresh_panel() - if _pivot: - # 正面朝相机 + 轻微来回摆 - _pivot.rotation.y = PI + sin(_spin * 0.3) * 0.35 # --- 小工具 -------------------------------------------------------------- @@ -780,21 +853,32 @@ func _sep() -> Control: func _kv(k: String, v: String) -> HBoxContainer: var row := HBoxContainer.new() row.add_theme_constant_override("separation", 8) - var kl := _plain(k, 13, Color(0.72, 0.78, 0.85)) - kl.custom_minimum_size = Vector2(64, 0) + row.custom_minimum_size.y = 20 + var kl := _plain(k, 10, Color(0.82, 0.82, 0.80)) + kl.custom_minimum_size = Vector2(42, 0) row.add_child(kl) - row.add_child(_plain(v, 14, Color(1, 1, 1))) + var value := _plain(v, 10, Color(1, 1, 1)) + value.size_flags_horizontal = Control.SIZE_EXPAND_FILL + value.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + var style := StyleBoxFlat.new() + style.bg_color = Color(0, 0, 0, 0.85) + style.border_color = Color(0.4, 0.4, 0.37) + style.set_border_width_all(1) + value.add_theme_stylebox_override("normal", style) + row.add_child(value) return row func _stat(label: String, value: int, col: Color) -> HBoxContainer: var row := HBoxContainer.new() row.add_theme_constant_override("separation", 8) - var kl := _plain(label, 13, Color(0.72, 0.78, 0.85)) - kl.custom_minimum_size = Vector2(44, 0) + row.custom_minimum_size.y = 20 + var kl := _plain(label, 10, Color(0.82, 0.82, 0.80)) + kl.custom_minimum_size = Vector2(26, 0) row.add_child(kl) var track := ColorRect.new() track.color = Color(0, 0, 0, 0.5) - track.custom_minimum_size = Vector2(150, 14) + track.custom_minimum_size = Vector2(98, 8) + track.size_flags_vertical = Control.SIZE_SHRINK_CENTER row.add_child(track) var fill := ColorRect.new() fill.color = col @@ -804,7 +888,7 @@ func _stat(label: String, value: int, col: Color) -> HBoxContainer: fill.offset_left = 1 fill.offset_top = 1 fill.offset_bottom = -1 - fill.offset_right = 1 + int(clampf(value / 25.0, 0.0, 1.0) * 148.0) + fill.offset_right = 1 + int(clampf(value / 90.0, 0.0, 1.0) * 96.0) track.add_child(fill) - row.add_child(_plain(str(value), 13, Color(1, 1, 1))) + row.add_child(_plain(str(value), 10, Color(1, 1, 1))) return row diff --git a/project/ui/mob_view.gd b/project/ui/mob_view.gd index a09714a0..c92a9f8c 100644 --- a/project/ui/mob_view.gd +++ b/project/ui/mob_view.gd @@ -102,6 +102,8 @@ func build(assets_root: String, proto: Node, race: int, pump := Callable()) -> b model.set("gr2_path", gr2) # 重:解 gr2 + 建网格 if pump.is_valid(): pump.call() add_child(model) + model.ready.connect(_ground_model) + _ground_model() _load_motlist() if ClassDB.class_exists("Metin2AnimPlayer"): anim = ClassDB.instantiate("Metin2AnimPlayer") @@ -160,6 +162,10 @@ func set_display_name(n: String) -> void: add_child(tag) tag.text = n +func _ground_model() -> void: + if model and model.has_method("get_ground_offset"): + model.position.y = float(model.call("get_ground_offset")) + # --- 内部 ------------------------------------------------------------------- func _folder_candidates(proto: Node, race: int) -> Array: diff --git a/project/ui/player_view.gd b/project/ui/player_view.gd index 6f06c6f5..f1fd4982 100644 --- a/project/ui/player_view.gd +++ b/project/ui/player_view.gd @@ -54,6 +54,8 @@ func build(assets_root: String, race: int, pump := Callable()) -> bool: model.set("hair_gr2", hair) # 重:解 hair gr2 + 折进网格 if pump.is_valid(): pump.call() add_child(model) + model.ready.connect(_ground_model) + _ground_model() if ClassDB.class_exists("Metin2AnimPlayer"): anim = ClassDB.instantiate("Metin2AnimPlayer") @@ -196,9 +198,15 @@ func _refresh_sound_script(motion_path: String) -> void: func _set(prop: StringName, val: Variant) -> bool: if String(prop) in _forward and model: model.set(prop, val) + if String(prop) == "gr2_path": + call_deferred("_ground_model") return true return false +func _ground_model() -> void: + if model and model.has_method("get_ground_offset"): + model.position.y = float(model.call("get_ground_offset")) + # --- 路径解析 ------------------------------------------------------------- func _resolve_dir(assets_root: String, rel: String) -> String: diff --git a/project/ui/quickbar.gd b/project/ui/quickbar.gd index 0baadd98..ca76a435 100644 --- a/project/ui/quickbar.gd +++ b/project/ui/quickbar.gd @@ -15,6 +15,14 @@ extends Node const PlayerSkill := preload("res://player_skill.gd") +const UiAssets := preload("res://ui/ui_assets.gd") + +const JOB_DIR := { + "WARRIOR": "warrior", + "ASSASSIN": "assassin", + "SURA": "sura", + "SHAMAN": "shaman", +} signal skill_activated(skill_id: int) # game_scene 接它播技能特效 # §3.8 修改 1:三层校验挡下(code = OnCannotUseSkill 字符串码),game_scene 弹文案 @@ -33,16 +41,19 @@ var _skill_gate: RefCounted # PlayerSkill(§3.8 修改 1) var _player_getter: Callable var _root: Control var item_mouse: Node -var _slots := [] # 当前页 UI:[{btn, cd, lbl}] +var _assets_root := "" +var _slots := [] # 当前页 UI:[{btn, cd, lbl, icon, grade}] var _state := [] # 36 个服务端槽:[{kind, id, cd_end:float}] var _page := 0 var _move_from := -1 var _mobile_mode := false -func setup(m2client: Node, skill_table: RefCounted, parent: Node, player_getter: Callable) -> void: +func setup(m2client: Node, skill_table: RefCounted, parent: Node, player_getter: Callable, + assets_root_override := "") -> void: client = m2client table = skill_table _player_getter = player_getter + _assets_root = assets_root_override if assets_root_override != "" else AssetRoot.path() _skill_gate = PlayerSkill.new() _skill_gate.setup(client, table) for _i in SLOT_COUNT: @@ -52,6 +63,8 @@ func setup(m2client: Node, skill_table: RefCounted, parent: Node, player_getter: client.skill_cooldown_end.connect(_on_cd_end) if client.has_signal("quickslots_changed"): client.quickslots_changed.connect(restore_from_server) + if client.has_signal("skills_changed"): + client.skills_changed.connect(_refresh_page) restore_from_server() # 从服务器 GC_QUICKSLOT_* 恢复全部 36 个快捷栏槽位。 @@ -314,17 +327,40 @@ func _build(parent: Node) -> void: num.text = str(i + 1) num.position = Vector2(3, 1) num.add_theme_font_size_override("font_size", 10) + num.mouse_filter = Control.MOUSE_FILTER_IGNORE slot.add_child(num) + var icon := TextureRect.new() + icon.name = "icon" + icon.position = Vector2(4, 4) + icon.size = Vector2(32, 32) + icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + icon.mouse_filter = Control.MOUSE_FILTER_IGNORE + slot.add_child(icon) var lbl := Label.new() lbl.name = "lbl" lbl.position = Vector2(3, 15) lbl.add_theme_font_size_override("font_size", 9) + lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE slot.add_child(lbl) + var grade := Label.new() + grade.name = "grade" + grade.position = Vector2(25, 25) + grade.size = Vector2(13, 13) + grade.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + grade.add_theme_font_size_override("font_size", 9) + grade.add_theme_color_override("font_color", Color(1.0, 0.9, 0.45)) + grade.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.95)) + grade.add_theme_constant_override("shadow_offset_x", 1) + grade.add_theme_constant_override("shadow_offset_y", 1) + grade.mouse_filter = Control.MOUSE_FILTER_IGNORE + slot.add_child(grade) var cd := ColorRect.new() cd.name = "cd" cd.color = Color(0, 0, 0, 0.55) cd.set_anchors_preset(Control.PRESET_FULL_RECT) cd.visible = false + cd.mouse_filter = Control.MOUSE_FILTER_IGNORE slot.add_child(cd) var idx := i slot.gui_input.connect(func(e: InputEvent): @@ -334,7 +370,7 @@ func _build(parent: Node) -> void: elif e.button_index == MOUSE_BUTTON_RIGHT: _clear(idx)) row.add_child(slot) - _slots.append({"btn": slot, "cd": cd, "lbl": lbl}) + _slots.append({"btn": slot, "cd": cd, "lbl": lbl, "icon": icon, "grade": grade}) if item_mouse and item_mouse.has_method("register_target"): var local_idx: int = i item_mouse.register_target(slot, @@ -378,11 +414,74 @@ func _refresh_slot(slot: int) -> void: return var s: Dictionary = _state[_global_slot(slot)] var txt := "" + var tex: Texture2D = null + var grade_text := "" if s.kind == "skill" and table: txt = table.name_of(s.id).substr(0, 5) + tex = _skill_icon(int(s.id)) + grade_text = _skill_grade_text(int(s.id)) elif s.kind == "item": txt = "#%d" % s.id - _slots[slot].lbl.text = txt + var icon: TextureRect = _slots[slot].icon + icon.texture = tex + icon.visible = tex != null + _slots[slot].lbl.text = txt if tex == null else "" + _slots[slot].grade.text = grade_text + _slots[slot].grade.visible = grade_text != "" and tex != null + +func _skill_grade(skill_id: int) -> int: + if client and client.has_method("get_skills"): + for skill in client.get_skills(): + if int(skill.get("id", 0)) == skill_id: + return clampi(int(skill.get("master", 0)), 0, 3) + return 0 + +func _skill_grade_text(skill_id: int) -> String: + match _skill_grade(skill_id): + 1: return "M" + 2: return "G" + 3: return "P" + _: return "" + +func _skill_icon(skill_id: int) -> Texture2D: + if _assets_root == "" or table == null or not table.has_method("entry"): + return null + var data: Dictionary = table.entry(skill_id) + var job := String(data.get("job", "")).to_upper() + var motion := String(data.get("motion", "")).strip_edges() + if motion == "": + return null + var candidates: Array[String] = [] + if JOB_DIR.has(job): + var dir := String(JOB_DIR[job]) + for suffix in _skill_icon_suffixes(skill_id): + candidates.append("ETC/ymir work/ui/skill/%s/%s%s.sub" % [dir, motion, suffix]) + elif job == "SUPPORT": + for suffix in _skill_icon_suffixes(skill_id): + candidates.append("ETC/ymir work/ui/skill/common/support/%s%s.sub" % [motion, suffix]) + candidates.append("ETC/ymir work/ui/skill/common/support/%s.sub" % motion) + elif job == "HORSE": + candidates.append("ETC/ymir work/ui/skill/common/horse/%s.sub" % motion) + elif job == "GUILD": + candidates.append("ETC/ymir work/ui/skill/common/guild/%s.sub" % motion) + for rel in candidates: + var tex: Texture2D = UiAssets.load_tex(_assets_root, rel) + if tex != null: + return tex + return null + +func _skill_icon_suffixes(skill_id: int) -> Array[String]: + var grade := _skill_grade(skill_id) + var first := "_01" + if grade == 1: + first = "_02" + elif grade >= 2: + first = "_03" + var out: Array[String] = [first] + for suffix in ["_03", "_02", "_01"]: + if not out.has(suffix): + out.append(suffix) + return out func _refresh_page() -> void: if _root and _root.has_node("page"): diff --git a/project/ui/ui_assets.gd b/project/ui/ui_assets.gd index 50a3d502..a1fff624 100644 --- a/project/ui/ui_assets.gd +++ b/project/ui/ui_assets.gd @@ -100,9 +100,23 @@ static func _load_sub(path: String) -> Texture2D: "bottom": b = int(parts[1]) if image_name == "": return null - var img_path := path.get_base_dir().path_join(image_name) - if not FileAccess.file_exists(img_path): - img_path = path.get_base_dir().path_join(image_name.get_basename() + ".tga") + # Skill .sub files live below ui/skill/, while their shared DDS + # lives in ui/. Search the containing directory and its parents. + var img_path := "" + var search_dir := path.get_base_dir() + for _i in 6: + for candidate in [image_name, image_name.get_basename() + ".tga", + image_name.get_basename() + ".png", image_name.get_basename() + ".dds"]: + var candidate_path := search_dir.path_join(candidate) + if FileAccess.file_exists(candidate_path): + img_path = candidate_path + break + if img_path != "": + break + var parent_dir := search_dir.get_base_dir() + if parent_dir == search_dir: + break + search_dir = parent_dir if not FileAccess.file_exists(img_path): return null var base := _load_image_file(img_path) diff --git a/script/live_smoke_test.sh b/script/live_smoke_test.sh new file mode 100755 index 00000000..c09a5c80 --- /dev/null +++ b/script/live_smoke_test.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# 真实 40250 服务器端到端冒烟测试。 +# +# 用法: +# ./script/live_smoke_test.sh +# ./script/live_smoke_test.sh --no-build +# ./script/live_smoke_test.sh --no-mutations +# ./script/live_smoke_test.sh --no-reconnect +# +# 账号和密码优先从环境变量读取;未提供时安全地交互输入,不写入脚本。 +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO="$(cd "$SCRIPT_DIR/.." && pwd)" +APP="$REPO/build/export/mtgodot-poc.app" +EXECUTABLE="$APP/Contents/MacOS/mtgodot-poc" +ARTIFACT_DIR="$REPO/build/live-smoke" +LOG="$ARTIFACT_DIR/live-smoke.log" +REPORT="$ARTIFACT_DIR/live-smoke-report.json" +CONFIG_FILE="${MT_SMOKE_CONFIG:-$REPO/.env.live-smoke.local}" + +BUILD=1 +MUTATIONS=1 +RECONNECT=1 + +usage() { + cat <<'EOF' +用法: ./script/live_smoke_test.sh [选项] + +选项: + --no-build 使用现有 APP,不重新打包 + --no-mutations 只做登录、进游戏和数据读取,不发送移动/聊天/重连包 + --no-reconnect 做移动和聊天,但跳过重连 + --help 显示帮助 + +环境变量: + MT_ACCOUNT / MT_PASSWORD 可预先提供账号密码,未提供时交互输入 + MT_CHAR_SLOT=N 指定角色槽位;不设置时自动选择第一个角色 +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --no-build) BUILD=0 ;; + --no-mutations) MUTATIONS=0 ;; + --no-reconnect) RECONNECT=0 ;; + --help) usage; exit 0 ;; + *) echo "未知选项: $1" >&2; usage >&2; exit 2 ;; + esac + shift +done + +cd "$REPO" +mkdir -p "$ARTIFACT_DIR" + +if [ -f "$CONFIG_FILE" ]; then + # shellcheck disable=SC1090 + . "$CONFIG_FILE" +fi + +if [ -z "${MT_ACCOUNT:-}" ]; then + read -r -p "账号: " MT_ACCOUNT +fi +if [ -z "${MT_PASSWORD:-}" ]; then + read -r -s -p "密码: " MT_PASSWORD + echo +fi + +if [ -z "${MT_ACCOUNT:-}" ] || [ -z "${MT_PASSWORD:-}" ]; then + echo "账号和密码不能为空" >&2 + exit 2 +fi + +if [ "$BUILD" -eq 1 ]; then + echo "== build release ==" + ./build-macos-client.sh release +fi + +if [ ! -x "$EXECUTABLE" ]; then + echo "找不到可执行 APP:$EXECUTABLE" >&2 + echo "请先运行 ./build-macos-client.sh release" >&2 + exit 2 +fi + +echo "== check server ports ==" +for port in 11000 13002; do + if ! nc -G 3 -z 192.168.21.203 "$port" >/dev/null 2>&1; then + echo "服务器端口不可达:192.168.21.203:$port" >&2 + exit 3 + fi +done + +pkill -x mtgodot-poc 2>/dev/null || true + +export MT_PROTOCOL=classic +export MT_AUTOLOGIN=1 +export MT_TEST_MODE=smoke +export MT_ACCOUNT +export MT_PASSWORD +export MT_SMOKE_MUTATIONS="$MUTATIONS" +export MT_SMOKE_RECONNECT="$RECONNECT" +export MT_TEST_REPORT="$REPORT" +rm -f "$REPORT" + +echo "== run live smoke ==" +echo "log: $LOG" +echo "report: $REPORT" +set +e +"$EXECUTABLE" 2>&1 | tee "$LOG" +APP_STATUS=${PIPESTATUS[0]} +set -e + +if [ ! -f "$REPORT" ]; then + echo "未生成本次测试报告,APP 可能在测试状态机启动前崩溃。" >&2 + echo "LIVE_SMOKE RESULT: FAIL (missing report)" >&2 + exit 4 +fi + +echo "== report ==" +if command -v jq >/dev/null 2>&1; then + REPORT_STATUS=$(jq -r '.status // empty' "$REPORT") + jq -r '"status=" + .status, "duration=" + (.duration_seconds|tostring) + "s", ( .failures[]? | "failure=" + . )' "$REPORT" +else + REPORT_STATUS="" + if grep -q '"status"[[:space:]]*:[[:space:]]*"PASS"' "$REPORT"; then + REPORT_STATUS=PASS + fi + grep -E '"status"|"failures"' "$REPORT" || true +fi + +if [ "$APP_STATUS" -ne 0 ]; then + echo "LIVE_SMOKE RESULT: FAIL (APP exit=$APP_STATUS)" >&2 + echo "最近的崩溃报告:" + ls -1t "$HOME/Library/Logs/DiagnosticReports/mtgodot-poc-"*.ips 2>/dev/null | head -n 3 || true + exit "$APP_STATUS" +fi + +if [ "$REPORT_STATUS" != "PASS" ]; then + echo "LIVE_SMOKE RESULT: FAIL (report status=$REPORT_STATUS)" >&2 + exit 1 +fi + +echo "LIVE_SMOKE RESULT: PASS" diff --git a/test/compare.py b/test/compare.py index 167cfbc8..384809c3 100755 --- a/test/compare.py +++ b/test/compare.py @@ -47,7 +47,8 @@ def run_godot(env_extra, timeout=120): env = {**os.environ, **env_extra} try: p = subprocess.run([godot, "--path", str(REPO / "project"), - "--rendering-driver", "metal", "--quit-after", "45"], + "res://main.tscn", "--rendering-driver", "metal", + "--quit-after", "45"], capture_output=True, text=True, env=env, timeout=timeout) except subprocess.TimeoutExpired: return None, "timeout", None