客户端问题修改
This commit is contained in:
@@ -33,6 +33,7 @@ compile_commands.json
|
|||||||
*.tmp
|
*.tmp
|
||||||
*.o
|
*.o
|
||||||
*.a
|
*.a
|
||||||
|
.env.live-smoke.local
|
||||||
|
|
||||||
# clangd
|
# clangd
|
||||||
/.cache/
|
/.cache/
|
||||||
|
|||||||
+13
-3
@@ -56,9 +56,19 @@ test -f "$APP/Contents/Resources/assets/asset_index.txt"
|
|||||||
|
|
||||||
echo "== 4. 签名(默认 ad-hoc;CODESIGN_IDENTITY 可切正式证书)=="
|
echo "== 4. 签名(默认 ad-hoc;CODESIGN_IDENTITY 可切正式证书)=="
|
||||||
CODESIGN_IDENTITY="${CODESIGN_IDENTITY:--}"
|
CODESIGN_IDENTITY="${CODESIGN_IDENTITY:--}"
|
||||||
find "$APP/Contents" -type f \( -name '*.dylib' -o -name '*.so' -o -name '*.a' \) -exec \
|
ENTITLEMENTS="$REPO/macos.entitlements"
|
||||||
codesign --force --options runtime --timestamp=none --sign "$CODESIGN_IDENTITY" {} \; 2>/dev/null || true
|
test -f "$ENTITLEMENTS"
|
||||||
codesign --force --options runtime --timestamp=none --sign "$CODESIGN_IDENTITY" "$APP" 2>/dev/null || true
|
|
||||||
|
# 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
|
xattr -dr com.apple.quarantine "$APP" 2>/dev/null || true
|
||||||
|
|
||||||
echo
|
echo
|
||||||
|
|||||||
@@ -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("set_surface_texture", "surface", "path"), &Metin2Model::set_surface_texture);
|
||||||
ClassDB::bind_method(D_METHOD("reload"), &Metin2Model::reload);
|
ClassDB::bind_method(D_METHOD("reload"), &Metin2Model::reload);
|
||||||
ClassDB::bind_method(D_METHOD("get_info"), &Metin2Model::get_info);
|
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("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("set_hair_gr2", "p"), &Metin2Model::set_hair_gr2);
|
||||||
ClassDB::bind_method(D_METHOD("get_hair_gr2"), &Metin2Model::get_hair_gr2);
|
ClassDB::bind_method(D_METHOD("get_hair_gr2"), &Metin2Model::get_hair_gr2);
|
||||||
@@ -455,6 +456,10 @@ void Metin2Model::reload() {
|
|||||||
base_parts = parts;
|
base_parts = parts;
|
||||||
AABB bounds;
|
AABB bounds;
|
||||||
Ref<ArrayMesh> mesh = build_mesh(fi, parts, flip_winding, bounds);
|
Ref<ArrayMesh> 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<double>(bounds.position.z) * unit_scale;
|
||||||
if (mesh->get_surface_count() > 0) {
|
if (mesh->get_surface_count() > 0) {
|
||||||
mi = memnew(MeshInstance3D);
|
mi = memnew(MeshInstance3D);
|
||||||
mi->set_name("MeshInstance3D");
|
mi->set_name("MeshInstance3D");
|
||||||
@@ -1085,6 +1090,17 @@ void Metin2Model::_apply_materials() {
|
|||||||
return _load_dds(dir.path_join(dds_files[i]));
|
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<ImageTexture> shared = _load_dds(resolved);
|
||||||
|
if (shared.is_valid()) {
|
||||||
|
return shared;
|
||||||
|
}
|
||||||
|
}
|
||||||
return Ref<ImageTexture>();
|
return Ref<ImageTexture>();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -126,6 +126,9 @@ public:
|
|||||||
|
|
||||||
// One-line summary (bones / meshes / verts / bounds).
|
// One-line summary (bones / meshes / verts / bounds).
|
||||||
godot::String get_info() const;
|
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) ---
|
// --- C++ accessors for Metin2AnimPlayer (same extension) ---
|
||||||
const gr2::Skeleton *gr2_skeleton() const;
|
const gr2::Skeleton *gr2_skeleton() const;
|
||||||
@@ -210,6 +213,7 @@ private:
|
|||||||
godot::Skeleton3D *skel = nullptr;
|
godot::Skeleton3D *skel = nullptr;
|
||||||
godot::MeshInstance3D *mi = nullptr;
|
godot::MeshInstance3D *mi = nullptr;
|
||||||
godot::String last_info;
|
godot::String last_info;
|
||||||
|
double ground_offset = 0.0;
|
||||||
godot::HashMap<godot::String, godot::Ref<godot::ImageTexture>> tex_cache;
|
godot::HashMap<godot::String, godot::Ref<godot::ImageTexture>> tex_cache;
|
||||||
|
|
||||||
// §2.10 LOD
|
// §2.10 LOD
|
||||||
|
|||||||
@@ -1497,6 +1497,15 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
|
|||||||
return true;
|
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:
|
default:
|
||||||
m_last_error = "phase " + m_phase_name + " does not handle GC header " +
|
m_last_error = "phase " + m_phase_name + " does not handle GC header " +
|
||||||
std::to_string(header);
|
std::to_string(header);
|
||||||
|
|||||||
@@ -21,13 +21,55 @@ std::string ipv4_from_slot_addr(int32_t addr) {
|
|||||||
std::to_string((value >> 16) & 0xffu) + "." +
|
std::to_string((value >> 16) & 0xffu) + "." +
|
||||||
std::to_string((value >> 24) & 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
|
} // namespace
|
||||||
|
|
||||||
ClassicSession::ClassicSession() : m_parser(m_world) {
|
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_phase = [this](uint8_t p) { on_phase(p); };
|
||||||
m_stream.on_packet = [this](uint8_t h, const uint8_t *b, uint32_t l) {
|
m_stream.on_packet = [this](uint8_t h, const uint8_t *b, uint32_t l) {
|
||||||
return on_packet(h, b, 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) {
|
m_stream.on_error = [this](const std::string &e) {
|
||||||
if (m_auth_transition) {
|
if (m_auth_transition) {
|
||||||
return;
|
return;
|
||||||
@@ -108,6 +150,7 @@ void ClassicSession::start_offline(const std::string &id, const std::string &pw)
|
|||||||
m_auth_login_sent = false;
|
m_auth_login_sent = false;
|
||||||
m_login_sent = false;
|
m_login_sent = false;
|
||||||
m_entergame_sent = false;
|
m_entergame_sent = false;
|
||||||
|
m_loading_phase_seen = false;
|
||||||
m_version_sent = false;
|
m_version_sent = false;
|
||||||
m_game_connection_seen = false;
|
m_game_connection_seen = false;
|
||||||
m_direct_enter = false;
|
m_direct_enter = false;
|
||||||
@@ -206,6 +249,7 @@ bool ClassicSession::connect_direct_enter(int slot) {
|
|||||||
m_game_port = character.port;
|
m_game_port = character.port;
|
||||||
m_login_sent = false;
|
m_login_sent = false;
|
||||||
m_entergame_sent = false;
|
m_entergame_sent = false;
|
||||||
|
m_loading_phase_seen = false;
|
||||||
m_version_sent = false;
|
m_version_sent = false;
|
||||||
m_stream.set_time_sync_mode(false);
|
m_stream.set_time_sync_mode(false);
|
||||||
m_auth_transition = true;
|
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_game_port = game_port;
|
||||||
m_login_sent = false;
|
m_login_sent = false;
|
||||||
m_entergame_sent = false;
|
m_entergame_sent = false;
|
||||||
|
m_loading_phase_seen = false;
|
||||||
m_version_sent = false;
|
m_version_sent = false;
|
||||||
m_stream.set_time_sync_mode(false);
|
m_stream.set_time_sync_mode(false);
|
||||||
m_auth_transition = true;
|
m_auth_transition = true;
|
||||||
@@ -276,6 +321,7 @@ void ClassicSession::pump() {
|
|||||||
m_game_started = true;
|
m_game_started = true;
|
||||||
m_login_sent = false;
|
m_login_sent = false;
|
||||||
m_entergame_sent = false;
|
m_entergame_sent = false;
|
||||||
|
m_loading_phase_seen = false;
|
||||||
m_version_sent = false;
|
m_version_sent = false;
|
||||||
m_stream.set_time_sync_mode(false);
|
m_stream.set_time_sync_mode(false);
|
||||||
m_auth_transition = true;
|
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;
|
// 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).
|
// 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) {
|
m_now - m_loading_since >= m_entergame_delay) {
|
||||||
if (!enter_game()) {
|
if (!enter_game()) {
|
||||||
m_last_error = m_stream.last_error().empty() ? "failed to send CG_ENTERGAME" :
|
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;
|
break;
|
||||||
case PHASE_LOADING:
|
case PHASE_LOADING:
|
||||||
set_stage(Stage::Loading);
|
set_stage(Stage::Loading);
|
||||||
|
m_loading_phase_seen = true;
|
||||||
m_direct_enter = false;
|
m_direct_enter = false;
|
||||||
m_direct_enter_slot = -1;
|
m_direct_enter_slot = -1;
|
||||||
m_loading_since = m_now;
|
m_loading_since = m_now;
|
||||||
|
|||||||
@@ -204,6 +204,10 @@ private:
|
|||||||
uint32_t m_now = 0;
|
uint32_t m_now = 0;
|
||||||
uint32_t m_loading_since = 0;
|
uint32_t m_loading_since = 0;
|
||||||
uint32_t m_entergame_delay = 8000;
|
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_entergame_sent = false;
|
||||||
bool m_login_sent = false;
|
bool m_login_sent = false;
|
||||||
bool m_auth_login_sent = false;
|
bool m_auth_login_sent = false;
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ bool ClassicStream::send_fixed(const void *struct_bytes, size_t n) {
|
|||||||
emit_bytes(struct_bytes, n);
|
emit_bytes(struct_bytes, n);
|
||||||
append_sequence_if_needed(header);
|
append_sequence_if_needed(header);
|
||||||
if (m_trace) {
|
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)));
|
(m_seq_on && is_sequence_cg(header)));
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -284,7 +284,7 @@ bool ClassicStream::handle_control(uint8_t header, bool &consumed) {
|
|||||||
on_server_handshake();
|
on_server_handshake();
|
||||||
}
|
}
|
||||||
if (m_trace) {
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -309,7 +309,7 @@ bool ClassicStream::handle_control(uint8_t header, bool &consumed) {
|
|||||||
consumed = true;
|
consumed = true;
|
||||||
m_phase = p.phase;
|
m_phase = p.phase;
|
||||||
if (m_trace) {
|
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) {
|
if (on_phase) {
|
||||||
on_phase(p.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());
|
m_cipher.decrypt(m_recv.mutable_unread(), m_recv.readable());
|
||||||
}
|
}
|
||||||
if (m_trace) {
|
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) {
|
if (on_cipher_active) {
|
||||||
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)
|
// reply with our blob — still plaintext (peer activates on its own COMPLETED)
|
||||||
emit_bytes(&out, sizeof(out));
|
emit_bytes(&out, sizeof(out));
|
||||||
if (m_trace) {
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -493,7 +493,7 @@ void ClassicStream::dispatch() {
|
|||||||
uint32_t body_len = dh.size - sizeof(DynHead);
|
uint32_t body_len = dh.size - sizeof(DynHead);
|
||||||
const uint8_t *body = m_recv.read_ptr();
|
const uint8_t *body = m_recv.read_ptr();
|
||||||
if (m_trace) {
|
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);
|
bool ok = !on_packet || on_packet(header, body, body_len);
|
||||||
discard_recv(body_len);
|
discard_recv(body_len);
|
||||||
@@ -517,6 +517,34 @@ void ClassicStream::dispatch() {
|
|||||||
m_last_error = "unknown GC header " + std::to_string(header) +
|
m_last_error = "unknown GC header " + std::to_string(header) +
|
||||||
" (last: " + std::to_string(m_last_pkt[0]) + "," +
|
" (last: " + std::to_string(m_last_pkt[0]) + "," +
|
||||||
std::to_string(m_last_pkt[1]) + ")";
|
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<size_t>(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) {
|
if (on_error) {
|
||||||
on_error(m_last_error);
|
on_error(m_last_error);
|
||||||
}
|
}
|
||||||
@@ -528,7 +556,16 @@ void ClassicStream::dispatch() {
|
|||||||
}
|
}
|
||||||
const uint8_t *p = m_recv.read_ptr();
|
const uint8_t *p = m_recv.read_ptr();
|
||||||
if (m_trace) {
|
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<size_t>(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<uint32_t>(total - 1));
|
bool ok = !on_packet || on_packet(header, p + 1, static_cast<uint32_t>(total - 1));
|
||||||
discard_recv(static_cast<size_t>(total));
|
discard_recv(static_cast<size_t>(total));
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
namespace mtnet::classic {
|
namespace mtnet::classic {
|
||||||
|
|
||||||
@@ -94,6 +95,7 @@ public:
|
|||||||
void set_time_sync_mode(bool on) { m_time_sync_mode = on; }
|
void set_time_sync_mode(bool on) { m_time_sync_mode = on; }
|
||||||
|
|
||||||
void set_wire_trace(bool on) { m_trace = 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.
|
// _IMPROVED_PACKET_ENCRYPTION_ (docs §3). Client polarity = true.
|
||||||
bool cipher_active() const { return m_cipher.activated(); }
|
bool cipher_active() const { return m_cipher.activated(); }
|
||||||
@@ -135,6 +137,7 @@ private:
|
|||||||
bool m_handshake_seen = false;
|
bool m_handshake_seen = false;
|
||||||
bool m_time_sync_mode = false;
|
bool m_time_sync_mode = false;
|
||||||
bool m_trace = false;
|
bool m_trace = false;
|
||||||
|
std::string m_trace_name = "stream";
|
||||||
uint8_t m_phase = PHASE_HANDSHAKE;
|
uint8_t m_phase = PHASE_HANDSHAKE;
|
||||||
uint8_t m_last_pkt[2] = {0, 0};
|
uint8_t m_last_pkt[2] = {0, 0};
|
||||||
|
|
||||||
|
|||||||
@@ -276,6 +276,12 @@ enum : uint8_t {
|
|||||||
HDR_GC_EMPIRE = 90,
|
HDR_GC_EMPIRE = 90,
|
||||||
HDR_GC_PARTY_LINK = 91,
|
HDR_GC_PARTY_LINK = 91,
|
||||||
HDR_GC_PARTY_UNLINK = 92,
|
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_REFINE_INFORMATION_OLD = 95,
|
||||||
HDR_GC_OBSERVER_ADD = 96,
|
HDR_GC_OBSERVER_ADD = 96,
|
||||||
HDR_GC_OBSERVER_REMOVE = 97,
|
HDR_GC_OBSERVER_REMOVE = 97,
|
||||||
@@ -1459,6 +1465,9 @@ constexpr bool is_dynamic_gc(uint8_t h) {
|
|||||||
case HDR_GC_GUILD:
|
case HDR_GC_GUILD:
|
||||||
case HDR_GC_MESSENGER:
|
case HDR_GC_MESSENGER:
|
||||||
case HDR_GC_QUEST_INFO:
|
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_DUEL_START:
|
||||||
case HDR_GC_SYNC_POSITION:
|
case HDR_GC_SYNC_POSITION:
|
||||||
// client CMainPacketHeaderMap registers GC_WHISPER STATIC, but its
|
// client CMainPacketHeaderMap registers GC_WHISPER STATIC, but its
|
||||||
|
|||||||
@@ -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.
|
// then connect to game_host with the returned login ticket.
|
||||||
if (OS::get_singleton()->get_environment("MT_PROTOCOL") == "classic") {
|
if (OS::get_singleton()->get_environment("MT_PROTOCOL") == "classic") {
|
||||||
classic_sess = std::make_unique<mtnet::classic::ClassicSession>();
|
classic_sess = std::make_unique<mtnet::classic::ClassicSession>();
|
||||||
|
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
|
// §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
|
// filename; feed it the real executable name rather than the built-in
|
||||||
// default so the server-side version gate sees what ClientVS22 sends.
|
// 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;
|
mall_open_seen = false;
|
||||||
dead_seen.clear();
|
dead_seen.clear();
|
||||||
classic_sess = std::make_unique<mtnet::classic::ClassicSession>();
|
classic_sess = std::make_unique<mtnet::classic::ClassicSession>();
|
||||||
|
classic_sess->set_wire_trace(OS::get_singleton()->get_environment("MT_NET_TRACE") == "1");
|
||||||
bool connected = false;
|
bool connected = false;
|
||||||
if (last_login_key != 0) {
|
if (last_login_key != 0) {
|
||||||
connected = classic_sess->connect_with_login_key(std::string(host.utf8().get_data()),
|
connected = classic_sess->connect_with_login_key(std::string(host.utf8().get_data()),
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ int main(int argc, char **argv) {
|
|||||||
session.set_auto_entergame_delay(enter_delay_ms);
|
session.set_auto_entergame_delay(enter_delay_ms);
|
||||||
session.set_wire_trace(std::getenv("MT_NET_TRACE") != nullptr);
|
session.set_wire_trace(std::getenv("MT_NET_TRACE") != nullptr);
|
||||||
const bool use_auth = std::getenv("MT_CLASSIC_AUTH") != 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") ?
|
const int auth_port = std::getenv("MT_CLASSIC_AUTH_PORT") ?
|
||||||
std::atoi(std::getenv("MT_CLASSIC_AUTH_PORT")) : 11000;
|
std::atoi(std::getenv("MT_CLASSIC_AUTH_PORT")) : 11000;
|
||||||
const bool connected = use_auth ?
|
const bool connected = use_auth ?
|
||||||
@@ -119,12 +120,16 @@ int main(int argc, char **argv) {
|
|||||||
session.disconnect();
|
session.disconnect();
|
||||||
return 3;
|
return 3;
|
||||||
}
|
}
|
||||||
if (!session.select_char(slot)) {
|
const bool enter_ok = use_direct_enter ? session.connect_direct_enter(slot) : session.select_char(slot);
|
||||||
std::fprintf(stderr, "FAIL: select_char(%d)\n", 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();
|
session.disconnect();
|
||||||
return 4;
|
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;
|
selected = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,4 +22,8 @@ if(BUILD_TESTING)
|
|||||||
add_executable(libgr2_loader_errors_test tests/loader_errors.cpp)
|
add_executable(libgr2_loader_errors_test tests/loader_errors.cpp)
|
||||||
target_link_libraries(libgr2_loader_errors_test PRIVATE xrender::libgr2)
|
target_link_libraries(libgr2_loader_errors_test PRIVATE xrender::libgr2)
|
||||||
add_test(NAME libgr2.loader_errors COMMAND libgr2_loader_errors_test)
|
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()
|
endif()
|
||||||
|
|||||||
@@ -179,7 +179,11 @@ void Curve::eval(float t, float* out) const {
|
|||||||
const int first = (degree == 1) ? (i - 1) : (i - 2);
|
const int first = (degree == 1) ? (i - 1) : (i - 2);
|
||||||
|
|
||||||
// 拷本 span 的控制点(越界按端点 clamp),四元数做半球连续化
|
// 拷本 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) {
|
for (int s = 0; s < span; ++s) {
|
||||||
int ci = first + s;
|
int ci = first + s;
|
||||||
ci = ci < 0 ? 0 : (ci >= (int)n ? (int)n - 1 : ci);
|
ci = ci < 0 ? 0 : (ci >= (int)n ? (int)n - 1 : ci);
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#include <gr2/types.h>
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
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<float>(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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -41,6 +41,12 @@ var _reconnecting_game := false
|
|||||||
var _mark_redl_after := 0 # guild-mark re-download cooldown (ticks_msec)
|
var _mark_redl_after := 0 # guild-mark re-download cooldown (ticks_msec)
|
||||||
# MT_AUTOLOGIN=1:进 LOGIN 自动连接,收到角色列表自动选第一个(冒烟 / CI 用)。
|
# 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_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:
|
func start(assets_root: String = "", injected_client: Node = null) -> void:
|
||||||
_assets = assets_root
|
_assets = assets_root
|
||||||
@@ -110,6 +116,11 @@ func _on_char_list(list: Array) -> void:
|
|||||||
_goto_select()
|
_goto_select()
|
||||||
if auto_login and not _chars.is_empty():
|
if auto_login and not _chars.is_empty():
|
||||||
var idx := int(_chars[0].get("index", 0))
|
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"):
|
if client.has_method("enter_game"):
|
||||||
client.enter_game(idx)
|
client.enter_game(idx)
|
||||||
elif client.has_method("select_character"):
|
elif client.has_method("select_character"):
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
extends Node
|
extends Node
|
||||||
|
|
||||||
const AppFlow = preload("res://app_flow.gd")
|
const AppFlow = preload("res://app_flow.gd")
|
||||||
|
const LiveSmokeTest = preload("res://live_smoke_test.gd")
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
# 打包 app 从 Finder/open 启动时没有 shell 环境;40250 服务器使用 classic
|
# 打包 app 从 Finder/open 启动时没有 shell 环境;40250 服务器使用 classic
|
||||||
@@ -21,3 +22,8 @@ func _ready() -> void:
|
|||||||
af.name = "AppFlow"
|
af.name = "AppFlow"
|
||||||
add_child(af)
|
add_child(af)
|
||||||
af.start(root)
|
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)
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ enum CameraMode {
|
|||||||
# ClientVS22 stores camera settings in centimetres/degrees. The Godot scene
|
# ClientVS22 stores camera settings in centimetres/degrees. The Godot scene
|
||||||
# is metres/radians, so keep the conversion at this boundary instead of
|
# is metres/radians, so keep the conversion at this boundary instead of
|
||||||
# leaking mixed units into the orbit controls.
|
# leaking mixed units into the orbit controls.
|
||||||
const DEFAULT_DISTANCE_CM := 1550.0
|
const DEFAULT_DISTANCE_CM := 2100.0
|
||||||
const DEFAULT_PITCH_DEG := 27.0
|
const DEFAULT_PITCH_DEG := 34.0
|
||||||
const DEFAULT_ROTATION_DEG := 0.0
|
const DEFAULT_ROTATION_DEG := 0.0
|
||||||
const DEFAULT_HEIGHT_CM := 100.0
|
const DEFAULT_HEIGHT_CM := 100.0
|
||||||
|
|
||||||
@@ -74,7 +74,8 @@ const FADE_ALPHA := 0.72 # 挡住玩家时的透明度
|
|||||||
var _faded: Array[Node] = [] # 上一帧被淡出的 GeometryInstance3D
|
var _faded: Array[Node] = [] # 上一帧被淡出的 GeometryInstance3D
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
fov = 55.0
|
# Match the original third-person framing: farther camera and narrower lens.
|
||||||
|
fov = 45.0
|
||||||
far = 4000.0
|
far = 4000.0
|
||||||
_apply_setting(_normal_setting())
|
_apply_setting(_normal_setting())
|
||||||
if target:
|
if target:
|
||||||
|
|||||||
+20
-2
@@ -183,6 +183,11 @@ func setup(m2client: Node, assets_root: String,
|
|||||||
net_world.set_camera(cam)
|
net_world.set_camera(cam)
|
||||||
net_world.set_local_node(player)
|
net_world.set_local_node(player)
|
||||||
net_world.entity_added.connect(func(node: Node3D, _vid: int):
|
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:
|
if node not in pc.pickables:
|
||||||
pc.pickables.append(node))
|
pc.pickables.append(node))
|
||||||
net_world.entity_removed.connect(func(vid: int):
|
net_world.entity_removed.connect(func(vid: int):
|
||||||
@@ -272,7 +277,7 @@ func setup(m2client: Node, assets_root: String,
|
|||||||
add_child(quickbar)
|
add_child(quickbar)
|
||||||
quickbar.item_mouse = mouse_controller
|
quickbar.item_mouse = mouse_controller
|
||||||
quickbar.net_play = net_play # §3.8 修改 1:技能三层校验的运行期上下文
|
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 执行实际施法。
|
# §3.4 MODE_USE_SKILL:预约技能进射程后由 quickbar 执行实际施法。
|
||||||
if net_play:
|
if net_play:
|
||||||
net_play.use_skill_hook = func(slot: int) -> bool: return quickbar.activate_reserved(slot)
|
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.background_color = Color(0.55, 0.62, 0.72)
|
||||||
e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||||
e.ambient_light_color = Color(0.5, 0.55, 0.62)
|
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
|
we.environment = e
|
||||||
add_child(we)
|
add_child(we)
|
||||||
_env = e
|
_env = e
|
||||||
|
|
||||||
func _on_main_set(vid: int) -> void:
|
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)
|
var e: Dictionary = client.get_entity(vid)
|
||||||
if e.is_empty() or player == null:
|
if e.is_empty() or player == null:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://d4kxv31wxrhj5
|
||||||
@@ -213,6 +213,23 @@ func catch_up() -> void:
|
|||||||
|
|
||||||
func set_model_factory(f: Callable) -> void:
|
func set_model_factory(f: Callable) -> void:
|
||||||
_model_factory = f
|
_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 的节点代表,这里就不要再画一个。
|
# 本地玩家用 player_controller 的节点代表,这里就不要再画一个。
|
||||||
func set_local_vid(vid: int) -> void:
|
func set_local_vid(vid: int) -> void:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ extends SceneTree
|
|||||||
const SkillTable = preload("res://ui/skill_table.gd")
|
const SkillTable = preload("res://ui/skill_table.gd")
|
||||||
const SkillUI = preload("res://ui/skill_ui.gd")
|
const SkillUI = preload("res://ui/skill_ui.gd")
|
||||||
const Quickbar = preload("res://ui/quickbar.gd")
|
const Quickbar = preload("res://ui/quickbar.gd")
|
||||||
|
const UiAssets = preload("res://ui/ui_assets.gd")
|
||||||
const UiManager = preload("res://ui/ui_manager.gd")
|
const UiManager = preload("res://ui/ui_manager.gd")
|
||||||
|
|
||||||
class FakeClient extends Node:
|
class FakeClient extends Node:
|
||||||
@@ -74,6 +75,11 @@ func _run() -> void:
|
|||||||
# 列偏移修正后:skill 1 'Three-Way Cut' motion_name=samyeon motion_idx=1, ATTACK_SKILL
|
# 列偏移修正后: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(st.motion_idx_of(1) == 1, "skill 1 motion_idx == 1 (列偏移已修)")
|
||||||
_ck(String(s1.get("motion", "")) == "samyeon", "skill 1 motion_name == samyeon")
|
_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_attack(1), "skill 1 是 ATTACK_SKILL")
|
||||||
_ck(st.is_ranged(46), "skill 46 bow skill is ranged")
|
_ck(st.is_ranged(46), "skill 46 bow skill is ranged")
|
||||||
_ck(not st.is_ranged(1), "skill 1 melee skill is not 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)
|
get_root().add_child(qb)
|
||||||
qb.setup(fc, st, ui, func() -> Node: return pl)
|
qb.setup(fc, st, ui, func() -> Node: return pl)
|
||||||
qb.assign(0, "skill", 1)
|
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")
|
_ck(fc.quickslot_ops == [["add", 0, 2, 1]], "assign -> CG_QUICKSLOT_ADD slot 0")
|
||||||
qb.activate(0)
|
qb.activate(0)
|
||||||
_ck(fc.skill_uses.size() == 1 and fc.skill_uses[0] == [1, 2000],
|
_ck(fc.skill_uses.size() == 1 and fc.skill_uses[0] == [1, 2000],
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ extends Control
|
|||||||
const UiAssets = preload("res://ui/ui_assets.gd")
|
const UiAssets = preload("res://ui/ui_assets.gd")
|
||||||
const UiKit = preload("res://ui_kit.gd")
|
const UiKit = preload("res://ui_kit.gd")
|
||||||
const PlayerView = preload("res://ui/player_view.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 select_requested(index: int)
|
||||||
signal delete_requested(index: int, private_code: String)
|
signal delete_requested(index: int, private_code: String)
|
||||||
@@ -61,9 +63,11 @@ var _class_img: TextureRect
|
|||||||
var _class_lbl: Label
|
var _class_lbl: Label
|
||||||
var _status: Label
|
var _status: Label
|
||||||
var _slot_lbl: Label
|
var _slot_lbl: Label
|
||||||
var _spin := 0.0
|
|
||||||
var _last_empire := -1
|
var _last_empire := -1
|
||||||
var _rename_pending := false
|
var _rename_pending := false
|
||||||
|
var _layout: Control
|
||||||
|
var _board: Control
|
||||||
|
var _proto: Node
|
||||||
|
|
||||||
func setup(client: Node, assets_root: String, chars: Array) -> void:
|
func setup(client: Node, assets_root: String, chars: Array) -> void:
|
||||||
_client = client
|
_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)
|
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
_build_background()
|
_build_background()
|
||||||
_build_stage()
|
_build_stage()
|
||||||
|
_layout = Control.new()
|
||||||
|
_layout.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
add_child(_layout)
|
||||||
_build_class_name()
|
_build_class_name()
|
||||||
_build_panel()
|
_build_panel()
|
||||||
|
_build_navigation()
|
||||||
_build_status()
|
_build_status()
|
||||||
|
resized.connect(_resize_layout)
|
||||||
|
_resize_layout()
|
||||||
_last_empire = _empire_id()
|
_last_empire = _empire_id()
|
||||||
set_process(true)
|
set_process(true)
|
||||||
_select(_first_occupied())
|
_select(_first_occupied())
|
||||||
@@ -181,7 +191,8 @@ func _build_background() -> void:
|
|||||||
tr.texture = tex
|
tr.texture = tex
|
||||||
tr.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
tr.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
tr.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
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
|
tr.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
add_child(tr)
|
add_child(tr)
|
||||||
|
|
||||||
@@ -206,12 +217,12 @@ func _build_stage() -> void:
|
|||||||
|
|
||||||
var key := DirectionalLight3D.new()
|
var key := DirectionalLight3D.new()
|
||||||
key.rotation_degrees = Vector3(-32, 28, 0)
|
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)
|
key.light_color = Color(1.0, 0.96, 0.90)
|
||||||
root.add_child(key)
|
root.add_child(key)
|
||||||
var rim := DirectionalLight3D.new()
|
var rim := DirectionalLight3D.new()
|
||||||
rim.rotation_degrees = Vector3(-6, 200, 0)
|
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)
|
rim.light_color = Color(0.75, 0.83, 1.0)
|
||||||
root.add_child(rim)
|
root.add_child(rim)
|
||||||
|
|
||||||
@@ -220,17 +231,18 @@ func _build_stage() -> void:
|
|||||||
e.background_mode = Environment.BG_COLOR
|
e.background_mode = Environment.BG_COLOR
|
||||||
e.background_color = Color(0, 0, 0, 0)
|
e.background_color = Color(0, 0, 0, 0)
|
||||||
e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||||
e.ambient_light_color = Color(0.42, 0.45, 0.55)
|
e.ambient_light_color = Color(0.8, 0.8, 0.8)
|
||||||
e.ambient_light_energy = 0.9
|
e.ambient_light_energy = 0.65
|
||||||
e.tonemap_mode = Environment.TONE_MAPPER_FILMIC
|
e.tonemap_mode = Environment.TONE_MAPPER_LINEAR
|
||||||
we.environment = e
|
we.environment = e
|
||||||
root.add_child(we)
|
root.add_child(we)
|
||||||
|
|
||||||
_pivot = Node3D.new()
|
_pivot = Node3D.new()
|
||||||
|
_pivot.rotation.y = -PI * 0.5
|
||||||
root.add_child(_pivot)
|
root.add_child(_pivot)
|
||||||
|
|
||||||
_cam = Camera3D.new()
|
_cam = Camera3D.new()
|
||||||
_cam.fov = 30.0
|
_cam.fov = 10.0
|
||||||
_cam.position = Vector3(0.0, 1.0, 4.0)
|
_cam.position = Vector3(0.0, 1.0, 4.0)
|
||||||
root.add_child(_cam)
|
root.add_child(_cam)
|
||||||
_cam.make_current()
|
_cam.make_current()
|
||||||
@@ -251,29 +263,56 @@ func _frame_model() -> void:
|
|||||||
first = false
|
first = false
|
||||||
if first:
|
if first:
|
||||||
return
|
return
|
||||||
var c := aabb.get_center()
|
var height := maxf(aabb.size.y, 1.0)
|
||||||
var r: float = maxf(aabb.size.length() * 0.5, 0.9)
|
# Reference: head at 14%, feet at 85%, center at 59% of the window.
|
||||||
var dist: float = r / sin(deg_to_rad(_cam.fov * 0.5)) * 1.12 # 12% 余量
|
var view_height := height / 0.71
|
||||||
var off := Vector3(-r * 0.5, 0.0, 0.0)
|
var dist := view_height / (2.0 * tan(deg_to_rad(_cam.fov * 0.5)))
|
||||||
_cam.position = c + off + Vector3(0.0, r * 0.04, dist)
|
var aspect := size.x / maxf(size.y, 1.0)
|
||||||
_cam.look_at(c + off, Vector3.UP)
|
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:
|
func _rebuild_model() -> void:
|
||||||
if _pivot == null:
|
if _pivot == null:
|
||||||
return
|
return
|
||||||
if _pv != null and is_instance_valid(_pv):
|
if _pv != null and is_instance_valid(_pv):
|
||||||
|
_pv.hide()
|
||||||
_pv.queue_free()
|
_pv.queue_free()
|
||||||
_pv = null
|
_pv = null
|
||||||
var c := _cur()
|
var c := _cur()
|
||||||
if c.is_empty() or String(c.get("name", "")) == "":
|
if c.is_empty() or String(c.get("name", "")) == "":
|
||||||
return
|
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 pump: Callable = _client.net_poll if _client and _client.has_method("net_poll") else Callable()
|
||||||
var pv := PlayerView.new()
|
var pv := PlayerView.new()
|
||||||
_pivot.add_child(pv)
|
_pivot.add_child(pv)
|
||||||
if pv.build(_assets, job, pump):
|
if pv.build(_assets, job, pump):
|
||||||
_pv = pv
|
_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 两次取稳定包围盒
|
# 蒙皮 / LOD 定型要几帧,多 fit 两次取稳定包围盒
|
||||||
for i in 4:
|
for i in 4:
|
||||||
await get_tree().process_frame
|
await get_tree().process_frame
|
||||||
@@ -287,22 +326,22 @@ func _rebuild_model() -> void:
|
|||||||
|
|
||||||
func _build_class_name() -> void:
|
func _build_class_name() -> void:
|
||||||
_class_img = TextureRect.new()
|
_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.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT
|
||||||
_class_img.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
_class_img.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
add_child(_class_img)
|
_layout.add_child(_class_img)
|
||||||
|
|
||||||
_class_lbl = Label.new()
|
_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_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_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_color_override("font_outline_color", Color(0.15, 0.05, 0.02))
|
||||||
_class_lbl.add_theme_constant_override("outline_size", 10)
|
_class_lbl.add_theme_constant_override("outline_size", 10)
|
||||||
_class_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
_class_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
add_child(_class_lbl)
|
_layout.add_child(_class_lbl)
|
||||||
|
|
||||||
func _refresh_class_name() -> void:
|
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 has_char := not _cur().is_empty() and String(_cur().get("name", "")) != ""
|
||||||
var tex: Texture2D = null
|
var tex: Texture2D = null
|
||||||
if has_char:
|
if has_char:
|
||||||
@@ -319,27 +358,29 @@ func _refresh_class_name() -> void:
|
|||||||
# --- 左侧信息板 --------------------------------------------------------------
|
# --- 左侧信息板 --------------------------------------------------------------
|
||||||
|
|
||||||
func _build_panel() -> void:
|
func _build_panel() -> void:
|
||||||
var board := UiKit.board(_assets, "board", 24, 96)
|
var board := UiKit.board(_assets, "thinboard", 16, 16)
|
||||||
board.position = Vector2(56, 168)
|
_board = board
|
||||||
board.size = Vector2(300, 396)
|
board.position = Vector2(119, 313)
|
||||||
|
board.size = Vector2(208, 323)
|
||||||
board.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
board.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
add_child(board)
|
_layout.add_child(board)
|
||||||
# 板底纹太透就垫一层
|
# 板底纹太透就垫一层
|
||||||
var tint := ColorRect.new()
|
var tint := ColorRect.new()
|
||||||
tint.color = Color(0.03, 0.05, 0.06, 0.62)
|
tint.color = Color(0.03, 0.05, 0.06, 0.62)
|
||||||
tint.position = board.position + Vector2(10, 10)
|
tint.position = board.position + Vector2(10, 10)
|
||||||
tint.size = board.size - Vector2(20, 20)
|
tint.size = board.size - Vector2(20, 20)
|
||||||
tint.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
tint.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
add_child(tint)
|
_layout.add_child(tint)
|
||||||
|
|
||||||
_panel_box = VBoxContainer.new()
|
_panel_box = VBoxContainer.new()
|
||||||
_panel_box.position = board.position + Vector2(22, 22)
|
_panel_box.position = board.position + Vector2(14, 12)
|
||||||
_panel_box.custom_minimum_size = Vector2(256, 0)
|
_panel_box.custom_minimum_size = Vector2(180, 0)
|
||||||
_panel_box.add_theme_constant_override("separation", 9)
|
_panel_box.add_theme_constant_override("separation", 6)
|
||||||
add_child(_panel_box)
|
_layout.add_child(_panel_box)
|
||||||
|
|
||||||
func _refresh_panel() -> void:
|
func _refresh_panel() -> void:
|
||||||
for c in _panel_box.get_children():
|
for c in _panel_box.get_children():
|
||||||
|
_panel_box.remove_child(c)
|
||||||
c.queue_free()
|
c.queue_free()
|
||||||
var d := _cur()
|
var d := _cur()
|
||||||
var has_char := not d.is_empty() and String(d.get("name", "")) != ""
|
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)
|
head.add_theme_constant_override("separation", 10)
|
||||||
_panel_box.add_child(head)
|
_panel_box.add_child(head)
|
||||||
var crest := TextureRect.new()
|
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
|
crest.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT
|
||||||
var gid := int(d.get("guild_id", 0))
|
var gid := int(d.get("guild_id", 0))
|
||||||
if has_char and gid != 0 and _client and _client.has_method("get_guild_mark_image"):
|
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)
|
head.add_child(crest)
|
||||||
var gbox := VBoxContainer.new()
|
var gbox := VBoxContainer.new()
|
||||||
head.add_child(gbox)
|
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()
|
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())
|
_panel_box.add_child(_sep())
|
||||||
|
|
||||||
@@ -413,21 +455,6 @@ func _refresh_panel() -> void:
|
|||||||
)
|
)
|
||||||
erow.add_child(eb)
|
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()
|
var brow := GridContainer.new()
|
||||||
brow.columns = 2
|
brow.columns = 2
|
||||||
@@ -436,28 +463,78 @@ func _refresh_panel() -> void:
|
|||||||
_panel_box.add_child(brow)
|
_panel_box.add_child(brow)
|
||||||
var start := Button.new()
|
var start := Button.new()
|
||||||
start.text = "开始"
|
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.disabled = not has_char or empire_required
|
||||||
start.pressed.connect(_do_start)
|
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()
|
var create := Button.new()
|
||||||
create.text = "创建"
|
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.disabled = has_char or empire_required
|
||||||
create.pressed.connect(_open_create_dialog)
|
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()
|
var del := Button.new()
|
||||||
del.text = "删除"
|
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.disabled = not has_char or empire_required
|
||||||
del.pressed.connect(_open_delete_dialog)
|
del.pressed.connect(_open_delete_dialog)
|
||||||
brow.add_child(del)
|
brow.add_child(del)
|
||||||
var quit := Button.new()
|
var quit := Button.new()
|
||||||
quit.text = "退出"
|
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())
|
quit.pressed.connect(func(): back_requested.emit())
|
||||||
brow.add_child(quit)
|
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:
|
func _build_status() -> void:
|
||||||
@@ -746,15 +823,11 @@ func _empire_flag_tex() -> Texture2D:
|
|||||||
return null
|
return null
|
||||||
return UiAssets.load_tex(_assets, "d:/ymir work/ui/intro/empire/empireflag_%s.sub" % f)
|
return UiAssets.load_tex(_assets, "d:/ymir work/ui/intro/empire/empireflag_%s.sub" % f)
|
||||||
|
|
||||||
func _process(dt: float) -> void:
|
func _process(_dt: float) -> void:
|
||||||
_spin += dt
|
|
||||||
var empire := _empire_id()
|
var empire := _empire_id()
|
||||||
if empire != _last_empire:
|
if empire != _last_empire:
|
||||||
_last_empire = empire
|
_last_empire = empire
|
||||||
_refresh_panel()
|
_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:
|
func _kv(k: String, v: String) -> HBoxContainer:
|
||||||
var row := HBoxContainer.new()
|
var row := HBoxContainer.new()
|
||||||
row.add_theme_constant_override("separation", 8)
|
row.add_theme_constant_override("separation", 8)
|
||||||
var kl := _plain(k, 13, Color(0.72, 0.78, 0.85))
|
row.custom_minimum_size.y = 20
|
||||||
kl.custom_minimum_size = Vector2(64, 0)
|
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(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
|
return row
|
||||||
|
|
||||||
func _stat(label: String, value: int, col: Color) -> HBoxContainer:
|
func _stat(label: String, value: int, col: Color) -> HBoxContainer:
|
||||||
var row := HBoxContainer.new()
|
var row := HBoxContainer.new()
|
||||||
row.add_theme_constant_override("separation", 8)
|
row.add_theme_constant_override("separation", 8)
|
||||||
var kl := _plain(label, 13, Color(0.72, 0.78, 0.85))
|
row.custom_minimum_size.y = 20
|
||||||
kl.custom_minimum_size = Vector2(44, 0)
|
var kl := _plain(label, 10, Color(0.82, 0.82, 0.80))
|
||||||
|
kl.custom_minimum_size = Vector2(26, 0)
|
||||||
row.add_child(kl)
|
row.add_child(kl)
|
||||||
var track := ColorRect.new()
|
var track := ColorRect.new()
|
||||||
track.color = Color(0, 0, 0, 0.5)
|
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)
|
row.add_child(track)
|
||||||
var fill := ColorRect.new()
|
var fill := ColorRect.new()
|
||||||
fill.color = col
|
fill.color = col
|
||||||
@@ -804,7 +888,7 @@ func _stat(label: String, value: int, col: Color) -> HBoxContainer:
|
|||||||
fill.offset_left = 1
|
fill.offset_left = 1
|
||||||
fill.offset_top = 1
|
fill.offset_top = 1
|
||||||
fill.offset_bottom = -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)
|
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
|
return row
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ func build(assets_root: String, proto: Node, race: int, pump := Callable()) -> b
|
|||||||
model.set("gr2_path", gr2) # 重:解 gr2 + 建网格
|
model.set("gr2_path", gr2) # 重:解 gr2 + 建网格
|
||||||
if pump.is_valid(): pump.call()
|
if pump.is_valid(): pump.call()
|
||||||
add_child(model)
|
add_child(model)
|
||||||
|
model.ready.connect(_ground_model)
|
||||||
|
_ground_model()
|
||||||
_load_motlist()
|
_load_motlist()
|
||||||
if ClassDB.class_exists("Metin2AnimPlayer"):
|
if ClassDB.class_exists("Metin2AnimPlayer"):
|
||||||
anim = ClassDB.instantiate("Metin2AnimPlayer")
|
anim = ClassDB.instantiate("Metin2AnimPlayer")
|
||||||
@@ -160,6 +162,10 @@ func set_display_name(n: String) -> void:
|
|||||||
add_child(tag)
|
add_child(tag)
|
||||||
tag.text = n
|
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:
|
func _folder_candidates(proto: Node, race: int) -> Array:
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ func build(assets_root: String, race: int, pump := Callable()) -> bool:
|
|||||||
model.set("hair_gr2", hair) # 重:解 hair gr2 + 折进网格
|
model.set("hair_gr2", hair) # 重:解 hair gr2 + 折进网格
|
||||||
if pump.is_valid(): pump.call()
|
if pump.is_valid(): pump.call()
|
||||||
add_child(model)
|
add_child(model)
|
||||||
|
model.ready.connect(_ground_model)
|
||||||
|
_ground_model()
|
||||||
|
|
||||||
if ClassDB.class_exists("Metin2AnimPlayer"):
|
if ClassDB.class_exists("Metin2AnimPlayer"):
|
||||||
anim = ClassDB.instantiate("Metin2AnimPlayer")
|
anim = ClassDB.instantiate("Metin2AnimPlayer")
|
||||||
@@ -196,9 +198,15 @@ func _refresh_sound_script(motion_path: String) -> void:
|
|||||||
func _set(prop: StringName, val: Variant) -> bool:
|
func _set(prop: StringName, val: Variant) -> bool:
|
||||||
if String(prop) in _forward and model:
|
if String(prop) in _forward and model:
|
||||||
model.set(prop, val)
|
model.set(prop, val)
|
||||||
|
if String(prop) == "gr2_path":
|
||||||
|
call_deferred("_ground_model")
|
||||||
return true
|
return true
|
||||||
return false
|
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:
|
func _resolve_dir(assets_root: String, rel: String) -> String:
|
||||||
|
|||||||
+103
-4
@@ -15,6 +15,14 @@
|
|||||||
extends Node
|
extends Node
|
||||||
|
|
||||||
const PlayerSkill := preload("res://player_skill.gd")
|
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 接它播技能特效
|
signal skill_activated(skill_id: int) # game_scene 接它播技能特效
|
||||||
# §3.8 修改 1:三层校验挡下(code = OnCannotUseSkill 字符串码),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 _player_getter: Callable
|
||||||
var _root: Control
|
var _root: Control
|
||||||
var item_mouse: Node
|
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 _state := [] # 36 个服务端槽:[{kind, id, cd_end:float}]
|
||||||
var _page := 0
|
var _page := 0
|
||||||
var _move_from := -1
|
var _move_from := -1
|
||||||
var _mobile_mode := false
|
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
|
client = m2client
|
||||||
table = skill_table
|
table = skill_table
|
||||||
_player_getter = player_getter
|
_player_getter = player_getter
|
||||||
|
_assets_root = assets_root_override if assets_root_override != "" else AssetRoot.path()
|
||||||
_skill_gate = PlayerSkill.new()
|
_skill_gate = PlayerSkill.new()
|
||||||
_skill_gate.setup(client, table)
|
_skill_gate.setup(client, table)
|
||||||
for _i in SLOT_COUNT:
|
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)
|
client.skill_cooldown_end.connect(_on_cd_end)
|
||||||
if client.has_signal("quickslots_changed"):
|
if client.has_signal("quickslots_changed"):
|
||||||
client.quickslots_changed.connect(restore_from_server)
|
client.quickslots_changed.connect(restore_from_server)
|
||||||
|
if client.has_signal("skills_changed"):
|
||||||
|
client.skills_changed.connect(_refresh_page)
|
||||||
restore_from_server()
|
restore_from_server()
|
||||||
|
|
||||||
# 从服务器 GC_QUICKSLOT_* 恢复全部 36 个快捷栏槽位。
|
# 从服务器 GC_QUICKSLOT_* 恢复全部 36 个快捷栏槽位。
|
||||||
@@ -314,17 +327,40 @@ func _build(parent: Node) -> void:
|
|||||||
num.text = str(i + 1)
|
num.text = str(i + 1)
|
||||||
num.position = Vector2(3, 1)
|
num.position = Vector2(3, 1)
|
||||||
num.add_theme_font_size_override("font_size", 10)
|
num.add_theme_font_size_override("font_size", 10)
|
||||||
|
num.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
slot.add_child(num)
|
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()
|
var lbl := Label.new()
|
||||||
lbl.name = "lbl"
|
lbl.name = "lbl"
|
||||||
lbl.position = Vector2(3, 15)
|
lbl.position = Vector2(3, 15)
|
||||||
lbl.add_theme_font_size_override("font_size", 9)
|
lbl.add_theme_font_size_override("font_size", 9)
|
||||||
|
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
slot.add_child(lbl)
|
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()
|
var cd := ColorRect.new()
|
||||||
cd.name = "cd"
|
cd.name = "cd"
|
||||||
cd.color = Color(0, 0, 0, 0.55)
|
cd.color = Color(0, 0, 0, 0.55)
|
||||||
cd.set_anchors_preset(Control.PRESET_FULL_RECT)
|
cd.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
cd.visible = false
|
cd.visible = false
|
||||||
|
cd.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
slot.add_child(cd)
|
slot.add_child(cd)
|
||||||
var idx := i
|
var idx := i
|
||||||
slot.gui_input.connect(func(e: InputEvent):
|
slot.gui_input.connect(func(e: InputEvent):
|
||||||
@@ -334,7 +370,7 @@ func _build(parent: Node) -> void:
|
|||||||
elif e.button_index == MOUSE_BUTTON_RIGHT:
|
elif e.button_index == MOUSE_BUTTON_RIGHT:
|
||||||
_clear(idx))
|
_clear(idx))
|
||||||
row.add_child(slot)
|
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"):
|
if item_mouse and item_mouse.has_method("register_target"):
|
||||||
var local_idx: int = i
|
var local_idx: int = i
|
||||||
item_mouse.register_target(slot,
|
item_mouse.register_target(slot,
|
||||||
@@ -378,11 +414,74 @@ func _refresh_slot(slot: int) -> void:
|
|||||||
return
|
return
|
||||||
var s: Dictionary = _state[_global_slot(slot)]
|
var s: Dictionary = _state[_global_slot(slot)]
|
||||||
var txt := ""
|
var txt := ""
|
||||||
|
var tex: Texture2D = null
|
||||||
|
var grade_text := ""
|
||||||
if s.kind == "skill" and table:
|
if s.kind == "skill" and table:
|
||||||
txt = table.name_of(s.id).substr(0, 5)
|
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":
|
elif s.kind == "item":
|
||||||
txt = "#%d" % s.id
|
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:
|
func _refresh_page() -> void:
|
||||||
if _root and _root.has_node("page"):
|
if _root and _root.has_node("page"):
|
||||||
|
|||||||
+17
-3
@@ -100,9 +100,23 @@ static func _load_sub(path: String) -> Texture2D:
|
|||||||
"bottom": b = int(parts[1])
|
"bottom": b = int(parts[1])
|
||||||
if image_name == "":
|
if image_name == "":
|
||||||
return null
|
return null
|
||||||
var img_path := path.get_base_dir().path_join(image_name)
|
# Skill .sub files live below ui/skill/<job>, while their shared DDS
|
||||||
if not FileAccess.file_exists(img_path):
|
# lives in ui/. Search the containing directory and its parents.
|
||||||
img_path = path.get_base_dir().path_join(image_name.get_basename() + ".tga")
|
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):
|
if not FileAccess.file_exists(img_path):
|
||||||
return null
|
return null
|
||||||
var base := _load_image_file(img_path)
|
var base := _load_image_file(img_path)
|
||||||
|
|||||||
Executable
+143
@@ -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"
|
||||||
+2
-1
@@ -47,7 +47,8 @@ def run_godot(env_extra, timeout=120):
|
|||||||
env = {**os.environ, **env_extra}
|
env = {**os.environ, **env_extra}
|
||||||
try:
|
try:
|
||||||
p = subprocess.run([godot, "--path", str(REPO / "project"),
|
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)
|
capture_output=True, text=True, env=env, timeout=timeout)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return None, "timeout", None
|
return None, "timeout", None
|
||||||
|
|||||||
Reference in New Issue
Block a user