diff --git a/extension/src/net/entity_store.cpp b/extension/src/net/entity_store.cpp index 3cd222fb..4d41b820 100644 --- a/extension/src/net/entity_store.cpp +++ b/extension/src/net/entity_store.cpp @@ -44,6 +44,17 @@ void dbg_ignored(const char *what, uint32_t id) { } } +// Server GetDegreeFromPosition (game/src/vector.cpp): compass heading of a +// server-frame direction, 0 = +Y, clockwise toward +X, in [0, 360). Same frame +// as the decoded GC_MOVE bRot, i.e. Entity::angle. +float heading_deg(float dx, float dy) { + float deg = std::atan2(dx, dy) * (180.0f / 3.14159265f); + if (deg < 0.0f) { + deg += 360.0f; + } + return deg; +} + } // namespace Entity &EntityStore::touch(uint32_t vid, bool &created) { @@ -240,7 +251,11 @@ void EntityStore::begin_state_walk(Entity &e, const StateCmd &c, uint8_t after_f if (dur == 0) { dur = 1; // never a zero-length walk; tick() finishes it next frame } - e.angle = c.rot; + // Reference MovementProcess faces a walking remote actor along Src->Dst + // (SetAdvancingRotation from the pixel positions); the packet's bRot is only + // applied once it stops (m_fDstRot). Server Follow() can send a bRot that + // faces the victim while Dst is a flank point, so using bRot here slides. + e.angle = dist > 0.0f ? heading_deg(dx, dy) : c.rot; e.moving = true; e.func = FUNC_MOVE; e.sx = e.x; @@ -3555,37 +3570,135 @@ void EntityStore::tick() { // CLIENT-GAP §3.2: release any TCP state commands that have come due, then // advance the walk. A queued command released here starts its walk this tick. process_states(); + uint32_t dt_ms = m_ticked && m_now > m_last_tick_ms ? m_now - m_last_tick_ms : 0; + if (dt_ms > MOVE_MAX_TICK_MS) { + dt_ms = MOVE_MAX_TICK_MS; + } + m_last_tick_ms = m_now; + m_ticked = true; for (auto &kv : m_ents) { Entity &e = kv.second; if (!e.moving) { continue; } - float t = e.move_dur_ms == 0 - ? 1.0f - : (float)(m_now - e.move_start_ms) / (float)e.move_dur_ms; - if (t <= 0.0f) { - t = 0.0f; + const float speed = motion_move_speed(e); + if (speed > 0.0f) { + advance_walk_by_motion(e, speed, dt_ms); + } else { + advance_walk_by_duration(e); } - if (t >= 1.0f) { - e.x = e.tx; - e.y = e.ty; + } +} + +void EntityStore::set_motion_speed(uint32_t vid, float walk_cm_s, float run_cm_s) { + auto it = m_ents.find(vid); + if (it == m_ents.end()) { + dbg_ignored("set_motion_speed", vid); + return; + } + it->second.walk_motion_speed = walk_cm_s > 0.0f ? walk_cm_s : 0.0f; + it->second.run_motion_speed = run_cm_s > 0.0f ? run_cm_s : 0.0f; +} + +// Reference CActorInstance::Move(): SetLoopMotion(m_isWalking ? WALK : RUN, 0.15, +// m_fMovSpd) and CInstanceBase::SetMoveSpeed: m_fMovSpd = movSpd / 100 (> 1100 -> +// 0). The walk then advances by that motion's accumulation. Deviations: movSpd 0 +// (not sent yet) counts as 100, and a zero factor (> 1100) falls back to the +// dwDuration lerp instead of freezing the actor mid-walk. The pushed speeds are +// the on-foot motions, so a mounted actor (horse motion set not measured) also +// uses the lerp. +float EntityStore::motion_move_speed(const Entity &e) { + const float base = e.walk_mode == WALKMODE_WALK ? e.walk_motion_speed : e.run_motion_speed; + if (base <= 0.0f || e.moving_speed > 1100 || e.mount_vnum != 0) { + return 0.0f; + } + const float factor = e.moving_speed == 0 ? 1.0f : (float)e.moving_speed / 100.0f; + return base * factor; +} + +// Reference m_kMovAfterFunc switch on arrival: COMBO / ATTACK / MOB_SKILL snap +// to Dst and act; everything else snaps to Dst, faces m_fDstRot and stops. +void EntityStore::finish_walk(Entity &e, bool snap_to_dst) { + if (snap_to_dst) { + e.x = e.tx; + e.y = e.ty; + } + e.moving = false; + e.skip_collision = false; + if (e.mov_after_func == FUNC_COMBO || e.mov_after_func == FUNC_ATTACK || + e.mov_after_func == FUNC_MOB_SKILL) { + e.func = e.mov_after_func; + } else { + e.func = FUNC_WAIT; + } + e.angle = e.mov_after_rot; + e.mov_after_func = FUNC_WAIT; +} + +// Reference CInstanceBase::MovementProcess (non-main branch). Src/Dst are the +// server-frame points from the state command; Cur advances along Src->Dst by +// the motion speed and "arrives" on the frame whose step crosses Dst. +void EntityStore::advance_walk_by_motion(Entity &e, float speed, uint32_t dt_ms) { + float dir_x = e.tx - e.sx; + float dir_y = e.ty - e.sy; + float total = std::sqrt(dir_x * dir_x + dir_y * dir_y); + if (total > 0.0f) { + e.angle = heading_deg(dir_x, dir_y); + dir_x /= total; + dir_y /= total; + } + const float step = speed * (float)dt_ms / 1000.0f; + const float next_x = e.x + dir_x * step; + const float next_y = e.y + dir_y * step; + const float cx = e.x - e.sx, cy = e.y - e.sy; + const float nx = next_x - e.sx, ny = next_y - e.sy; + const float cur_len = std::sqrt(cx * cx + cy * cy); + const float next_len = std::sqrt(nx * nx + ny * ny); + + if (total - cur_len < -MOVE_OVERSHOOT_CM) { + // latency overran Dst: re-source here, turn back toward Dst, and stop at + // the next arrival instead of carrying on with FUNC_MOVE. + e.sx = e.x; + e.sy = e.y; + e.angle = heading_deg(e.tx - e.x, e.ty - e.y); + if (e.mov_after_func == FUNC_MOVE) { + e.mov_after_func = FUNC_WAIT; + } + } else if (cur_len <= total && total <= next_len) { + if (e.dead || e.knock_down) { e.moving = false; e.skip_collision = false; - // reference m_kMovAfterFunc: once the walk reaches Dst, run the - // action the state packet asked for (COMBO / ATTACK / MOB_SKILL); - // otherwise settle to WAIT. - if (e.mov_after_func == FUNC_COMBO || e.mov_after_func == FUNC_ATTACK || - e.mov_after_func == FUNC_MOB_SKILL) { - e.func = e.mov_after_func; - e.angle = e.mov_after_rot; - } else { - e.func = FUNC_WAIT; - } - e.mov_after_func = FUNC_WAIT; - } else { - e.x = e.sx + (e.tx - e.sx) * t; - e.y = e.sy + (e.ty - e.sy) * t; + return; } + if (e.mov_after_func == FUNC_MOVE) { + // FUNC_MOVE: keep walking past Dst until the next state command (or + // the overshoot guard above) — this is what keeps a moving remote PC + // from stop-starting between its periodic move packets. + e.x = next_x; + e.y = next_y; + return; + } + finish_walk(e, true); + return; + } + e.x = next_x; + e.y = next_y; +} + +// Fallback when the actor's motion speed is unknown: lerp Src->Dst over the +// server-supplied dwDuration and stop on time. +void EntityStore::advance_walk_by_duration(Entity &e) { + float t = e.move_dur_ms == 0 + ? 1.0f + : (float)(m_now - e.move_start_ms) / (float)e.move_dur_ms; + if (t <= 0.0f) { + t = 0.0f; + } + if (t >= 1.0f) { + finish_walk(e, true); + } else { + e.x = e.sx + (e.tx - e.sx) * t; + e.y = e.sy + (e.ty - e.sy) * t; } } diff --git a/extension/src/net/entity_store.h b/extension/src/net/entity_store.h index b59cf07f..8e3f8193 100644 --- a/extension/src/net/entity_store.h +++ b/extension/src/net/entity_store.h @@ -51,6 +51,20 @@ struct StateCmd { // CLIENT-GAP-FIX.md §3.2. static constexpr uint32_t STATE_QUEUE_MAX_WAIT_MS = 1000; +// GC_WALK_MODE / TPacketGCWalkMode::mode (40250 Packet.h:2327, server packet.h:1946). +enum : uint8_t { + WALKMODE_RUN = 0, + WALKMODE_WALK = 1, +}; + +// Reference CInstanceBase::MovementProcess (non-main branch): a remote actor that +// has walked more than this far past its Dst re-sources the walk at its current +// position, turns back toward Dst and downgrades a pending FUNC_MOVE to FUNC_WAIT. +static constexpr float MOVE_OVERSHOOT_CM = 100.0f; +// Cap on one tick's walk step so a hitch (or the first tick after a long pause) +// cannot teleport an actor far past its Dst in a single frame. +static constexpr uint32_t MOVE_MAX_TICK_MS = 250; + struct Entity { uint32_t vid = 0; uint16_t race = 0; @@ -65,7 +79,7 @@ struct Entity { uint8_t func = FUNC_WAIT; uint8_t position = 0; - uint8_t walk_mode = 0; // 0 walk, 1 run (server's WALKMODE_*) + uint8_t walk_mode = WALKMODE_RUN; // server WALKMODE_* (0 run, 1 walk) uint32_t fly_target_vid = 0; int32_t fly_target_x = 0, fly_target_y = 0; bool fly_target_set = false; @@ -74,6 +88,14 @@ struct Entity { float sx = 0, sy = 0, tx = 0, ty = 0; uint32_t move_start_ms = 0; uint32_t move_dur_ms = 0; + // Root-motion speed of the race's WALK / RUN loop at movSpd 100, in cm/s + // (.msa Accumulation length / MotionDuration). Pushed by the presentation + // layer once the model is built (set_motion_speed). While known, a remote + // walk advances like the reference CActorInstance::AccumulationMovement + // (motion speed * movSpd/100) and arrives by crossing Dst; while 0 (no model / + // headless) it falls back to lerping over the server dwDuration. + float walk_motion_speed = 0; + float run_motion_speed = 0; // CLIENT-GAP §3.2: TCP state queue + the "do this once the walk reaches Dst" // latch (reference m_kMovAfterFunc / m_dwMovAfterArg). `skip_collision` mirrors @@ -516,6 +538,9 @@ public: // and again right after mut_move() enqueues, so an already-due command applies // synchronously. void process_states(); + // Root-motion speeds (cm/s at movSpd 100) of this actor's WALK / RUN loops; + // 0 = unknown. See Entity::walk_motion_speed. No-op for an unknown VID. + void set_motion_speed(uint32_t vid, float walk_cm_s, float run_cm_s); // Feed one complete game-phase packet. `body` points at the packet start // (header/length included); `len` == that length. Unknown headers ignored. @@ -1176,11 +1201,18 @@ private: void begin_state_walk(Entity &e, const StateCmd &c, uint8_t after_func); // Src/Dst + skip-collision static bool can_process_network_state(const Entity &e); // ~ __CanProcessNetworkStatePacket static bool is_enable_tcp_process(const Entity &e, uint8_t func); // ~ __IsEnableTCPProcess + // Remote-walk advance (reference CInstanceBase::MovementProcess, non-main). + static float motion_move_speed(const Entity &e); // cm/s, 0 = use dwDuration lerp + static void finish_walk(Entity &e, bool snap_to_dst); // m_kMovAfterFunc on arrival + void advance_walk_by_motion(Entity &e, float speed, uint32_t dt_ms); + void advance_walk_by_duration(Entity &e); std::unordered_map m_ents; uint32_t m_main_vid = 0; uint32_t m_now = 0; uint32_t m_server_frame_ms = 0; + uint32_t m_last_tick_ms = 0; // m_now at the previous tick() (motion-driven walk dt) + bool m_ticked = false; std::vector m_changes; std::vector m_dirty; std::string m_bgm_name; diff --git a/extension/src/net/m2_client.cpp b/extension/src/net/m2_client.cpp index 2ac2896f..07432173 100644 --- a/extension/src/net/m2_client.cpp +++ b/extension/src/net/m2_client.cpp @@ -100,6 +100,8 @@ void M2Client::_bind_methods() { ClassDB::bind_method(D_METHOD("quest_cancel"), &M2Client::quest_cancel); ClassDB::bind_method(D_METHOD("get_quests"), &M2Client::get_quests); ClassDB::bind_method(D_METHOD("get_entity", "vid"), &M2Client::get_entity); + ClassDB::bind_method(D_METHOD("set_entity_motion_speed", "vid", "walk_cm_s", "run_cm_s"), + &M2Client::set_entity_motion_speed); ClassDB::bind_method(D_METHOD("get_entities"), &M2Client::get_entities); ClassDB::bind_method(D_METHOD("get_main_vid"), &M2Client::get_main_vid); ClassDB::bind_method(D_METHOD("get_main_pid"), &M2Client::get_main_pid); @@ -2523,6 +2525,13 @@ const mtnet::EntityStore *M2Client::active_world() const { return game ? &game->world() : nullptr; } +void M2Client::set_entity_motion_speed(int vid, double walk_cm_s, double run_cm_s) { + mtnet::EntityStore *w = classic_sess ? &classic_sess->world() : (game ? &game->world() : nullptr); + if (w) { + w->set_motion_speed((uint32_t)vid, (float)walk_cm_s, (float)run_cm_s); + } +} + Dictionary M2Client::get_entity(int vid) const { const mtnet::EntityStore *w = active_world(); if (!w) { diff --git a/extension/src/net/m2_client.h b/extension/src/net/m2_client.h index 45930bcc..208bd50f 100644 --- a/extension/src/net/m2_client.h +++ b/extension/src/net/m2_client.h @@ -288,6 +288,9 @@ public: // --- networked world snapshot (positions already Godot-space, metres) --- godot::Dictionary get_entity(int vid) const; + // Root-motion speeds (cm/s at movSpd 100) of a remote actor's WALK / RUN loop, + // measured by the view from its .msa; drives the reference-style remote walk. + void set_entity_motion_speed(int vid, double walk_cm_s, double run_cm_s); godot::Array get_entities() const; int get_main_vid() const; int get_main_pid() const; diff --git a/extension/tests/net_state_queue_test.cpp b/extension/tests/net_state_queue_test.cpp index 2761fff4..8d4fa69c 100644 --- a/extension/tests/net_state_queue_test.cpp +++ b/extension/tests/net_state_queue_test.cpp @@ -152,6 +152,89 @@ int main() { "chk_time past cap: released immediately (staleness valve)"); } + // --- 7) motion-driven remote walk (reference MovementProcess) -------------- + // Speed comes from the WALK/RUN root motion * movSpd/100, not dwDuration; + // heading follows Src->Dst while walking and takes bRot on arrival. + { + EntityStore es; + es.set_now(0); + es.mut_spawn(70, 101, 2, "Wolf", 0, 0, 0, 0, 100 /*moving_speed*/, 0); + es.set_motion_speed(70, 87.5f, 425.0f); + es.tick(); + // server says 100 ms (it prices NPC walks at RUN speed); ignored here. + es.mut_move(70, 45.0f, FUNC_WAIT, 1000.0f, 0.0f, 100, 0, 0); + CHECK(es.get(70)->moving && std::abs(es.get(70)->angle - 90.0f) < 1e-3f, + "motion walk: faces Src->Dst, not bRot"); + uint32_t now = 0; + for (int i = 0; i < 10; ++i) { + now += 100; + es.set_now(now); + es.tick(); + } + CHECK(es.get(70)->moving && std::abs(es.get(70)->x - 425.0f) < 1.0f, + "motion walk: run mode advances at RUN motion speed, ignores dwDuration"); + es.mut_walk_mode(70, WALKMODE_WALK); + for (int i = 0; i < 10; ++i) { + now += 100; + es.set_now(now); + es.tick(); + } + CHECK(std::abs(es.get(70)->x - 512.5f) < 1.0f, "motion walk: walk mode uses WALK motion speed"); + es.mut_change_speed(70, 200); // movSpd 200 doubles the step + now += 1000; + es.set_now(now); + es.tick(); // capped at MOVE_MAX_TICK_MS + CHECK(std::abs(es.get(70)->x - (512.5f + 175.0f * 0.25f)) < 1.0f, + "motion walk: movSpd scales speed; tick step capped"); + for (int i = 0; i < 100 && es.get(70)->moving; ++i) { + now += 100; + es.set_now(now); + es.tick(); + } + CHECK(!es.get(70)->moving && es.get(70)->func == FUNC_WAIT, "motion walk: stops on arrival"); + CHECK(es.get(70)->x == 1000.0f && es.get(70)->y == 0.0f, "motion walk: snaps to Dst"); + CHECK(std::abs(es.get(70)->angle - 45.0f) < 1e-3f, "motion walk: bRot applied on arrival"); + } + + // --- 8) FUNC_MOVE walks past Dst, overshoot guard turns back and stops ------ + { + EntityStore es; + es.set_now(0); + es.mut_spawn(80, 0, 0, "Pc", 0, 0, 0, 0, 100, 0); + es.set_motion_speed(80, 0.0f, 1000.0f); + es.tick(); + es.mut_move(80, 90.0f, FUNC_MOVE, 100.0f, 0.0f, 100, 0, 0); + uint32_t now = 0; + for (int i = 0; i < 3; ++i) { + now += 50; + es.set_now(now); + es.tick(); + } + CHECK(es.get(80)->moving && es.get(80)->x > 100.0f, "FUNC_MOVE: keeps walking past Dst"); + bool turned = false; + for (int i = 0; i < 40 && es.get(80)->moving; ++i) { + now += 50; + es.set_now(now); + es.tick(); + turned = turned || std::abs(es.get(80)->angle - 270.0f) < 1e-3f; + } + CHECK(turned, "FUNC_MOVE overshoot: turns back toward Dst"); + CHECK(!es.get(80)->moving && es.get(80)->x == 100.0f && es.get(80)->func == FUNC_WAIT, + "FUNC_MOVE overshoot: returns to Dst and stops"); + } + + // --- 9) unknown motion speed keeps the dwDuration lerp, heading Src->Dst ----- + { + EntityStore es; + es.set_now(0); + spawn(es, 90, 0, 0); + es.mut_move(90, 0.0f, FUNC_WAIT, 0.0f, -500.0f, 1000, 0, 0); + CHECK(std::abs(es.get(90)->angle - 180.0f) < 1e-3f, "duration walk: faces Src->Dst"); + es.set_now(500); + es.tick(); + CHECK(std::abs(es.get(90)->y + 250.0f) < 1e-3f, "duration walk: lerps over dwDuration"); + } + if (g_fail == 0) { std::printf("PASS: net_state_queue_test (§3.2 TCP state queue / thresholds / gates)\n"); } diff --git a/project/mob_view_test.gd b/project/mob_view_test.gd index d9ab604e..56449798 100644 --- a/project/mob_view_test.gd +++ b/project/mob_view_test.gd @@ -5,9 +5,11 @@ extends SceneTree const MobView = preload("res://ui/mob_view.gd") const Audio = preload("res://audio.gd") +const MsaMotion = preload("res://ui/msa_motion.gd") var _fail := 0 var _resolution_done := false +var _move_speed_done := false func _ck(c: bool, m: String) -> void: if not c: _fail += 1 @@ -26,6 +28,7 @@ class FakeAnim: extends Node var anim_path := "" var loop := false + var time_scale := 1.0 # 纯解析:不需要扩展和资产。旧客户端 CRaceData::GetMotionKey 找不到动作时 # SetLoopMotion/InterceptMotion 直接返回、保留当前动作;不能静默换成 WAIT。 @@ -57,8 +60,49 @@ func _check_motion_resolution() -> void: mv.free() _resolution_done = true +# CActorInstance::Move:走 / 跑按 m_fMovSpd = movSpd/100 播放;根运动速度 +# = |.msa Accumulation| / MotionDuration(wolf 03.msa:255.45 / 0.6 = 425.75 cm/s)。 +func _write_msa(path: String, duration: float, ay: float) -> void: + var f := FileAccess.open(path, FileAccess.WRITE) + f.store_string("ScriptType MotionData\n\nMotionFileName \"x.gr2\"\n" + + "MotionDuration %f\nAccumulation 0.00\t%.2f\t0.00\n" % [duration, ay]) + f.close() + +func _check_move_speed() -> void: + var walk_p := "user://__mob_view_test_walk.msa" + var run_p := "user://__mob_view_test_run.msa" + _write_msa(walk_p, 0.866667, -75.81) + _write_msa(run_p, 0.6, -255.45) + _ck(absf(MsaMotion.move_speed(run_p) - 425.75) < 0.01, "run msa 速度 425.75(%f)" % MsaMotion.move_speed(run_p)) + _ck(absf(MsaMotion.move_speed(walk_p) - 87.47) < 0.01, "walk msa 速度 87.47(%f)" % MsaMotion.move_speed(walk_p)) + _ck(MsaMotion.move_speed("user://__no_such.msa") == 0.0, "缺文件 -> 0") + var mv: Node3D = MobView.new() + mv._dir = "res://__no_such_mob_dir__" + mv._motions = {"WAIT": "wait.msa", "WALK": walk_p, "RUN": run_p} + var sp: Vector2 = mv.get_move_motion_speeds() + _ck(absf(sp.x - 87.47) < 0.01 and absf(sp.y - 425.75) < 0.01, "get_move_motion_speeds(%s)" % sp) + var anim := FakeAnim.new() + mv.anim = anim + mv.set_move_speed(200) + mv.set_anim_state("wait") + _ck(anim.time_scale == 1.0, "wait 不受移动速度影响") + mv.set_anim_state("run") + _ck(anim.anim_path == run_p and anim.time_scale == 2.0, "run 按 movSpd/100 播放(%f)" % anim.time_scale) + mv.set_move_speed(150) + _ck(anim.time_scale == 1.5, "跑动中改速立即生效(%f)" % anim.time_scale) + mv.set_move_speed(1200) + _ck(anim.time_scale == 1.0, ">1100 保持原速(%f)" % anim.time_scale) + mv.anim = null + anim.free() + mv.free() + DirAccess.remove_absolute(ProjectSettings.globalize_path(walk_p)) + DirAccess.remove_absolute(ProjectSettings.globalize_path(run_p)) + _move_speed_done = true + func _run() -> void: _check_motion_resolution() + _check_move_speed() + _ck(_move_speed_done, "移动速度断言全部执行") # 脚本运行时错误只会中止函数、不计失败;用哨兵保证整段断言都执行过。 _ck(_resolution_done, "动作解析断言全部执行") if not ClassDB.class_exists("Metin2Model") or not ClassDB.class_exists("Metin2Proto"): diff --git a/project/net_world.gd b/project/net_world.gd index 6b9eae0f..f644d022 100644 --- a/project/net_world.gd +++ b/project/net_world.gd @@ -48,6 +48,12 @@ const FUNC_COMBO := 3 const FUNC_MOB_SKILL := 4 const FUNC_EMOTION := 5 const FUNC_SKILL := 0x80 +# packet.h EWalkModes(GC_WALK_MODE / GC_CHARACTER_ADD_INFO.bWalkMode) +const WALKMODE_RUN := 0 +const WALKMODE_WALK := 1 +# CActorInstance c_fDefaultRotationSpeed / c_fDefaultHorseRotationSpeed(度/秒) +const ROTATION_SPEED := 1200.0 +const ROTATION_SPEED_HORSE := 300.0 # EDamageFlag / IsShowDamage / ProcessDamage 分类见 damage_effect.gd(DamageEffect.DAMAGE_*)。 @@ -423,12 +429,26 @@ func _on_spawn(d: Dictionary) -> void: _refresh_shop_sign(node, str(d.get("shop_sign", ""))) _refresh_pvp_tag(vid) _apply_field_updates(node, d) + _push_motion_speeds(vid, node) _apply_name_color(vid) _apply_text_tail(vid) entity_added.emit(node, vid) if vid == _main_vid: main_entity_ready.emit(node, vid) +# CActorInstance::Move -> SetLoopMotion(WALK/RUN, .., m_fMovSpd):远端位移按动作根运动 +# 累计(.msa Accumulation / MotionDuration × movSpd/100)推进。模型建好后把走 / 跑 +# 速度交给 EntityStore;占位胶囊 / 骑马(马的动作不在 general 目录)不推,EntityStore +# 退回按服务端 duration 线性插值。 +func _push_motion_speeds(vid: int, n: Node3D) -> void: + if not n.has_method("get_move_motion_speeds") or not client.has_method("set_entity_motion_speed"): + return + if int(n.get_meta("mount_vnum", 0)) != 0: + return + var sp: Vector2 = n.call("get_move_motion_speeds") + if sp.x > 0.0 or sp.y > 0.0: + client.call("set_entity_motion_speed", vid, sp.x, sp.y) + # GC_CHAR_ADD_INFO 到了:刷新头顶名字 / HP 上限(节点已存在)。 func _on_info(vid: int, d: Dictionary) -> void: var n: Node3D = _by_vid.get(vid, null) @@ -1287,12 +1307,15 @@ func _process(dt: float) -> void: want = _pushed_position(vid, e, dt, want) n.position = want # 朝向:Metin2 heading(度) -> Godot yaw。经验换算,真机再校准。 + # CActorInstance::RotationProcess:匀角速度转向 c_fDefaultRotationSpeed 1200°/s, + # 骑马 c_fDefaultHorseRotationSpeed 300°/s(而不是指数逼近)。 var yaw := MapCoord.heading_to_yaw(float(e.get("angle_deg", 0.0))) - n.rotation.y = lerp_angle(n.rotation.y, yaw, clampf(10.0 * dt, 0.0, 1.0)) + var turn_rate := ROTATION_SPEED_HORSE if int(n.get_meta("mount_vnum", 0)) != 0 else ROTATION_SPEED + n.rotation.y = rotate_toward(n.rotation.y, yaw, deg_to_rad(turn_rate) * dt) # 动画状态 var f := int(e.get("func", FUNC_WAIT)) var moving := bool(e.get("moving", false)) - var walk_mode := int(e.get("walk_mode", 1)) + var walk_mode := int(e.get("walk_mode", WALKMODE_RUN)) var dead := bool(e.get("dead", false)) if f != int(n.get_meta("func", -1)) or moving != bool(n.get_meta("moving", false)) \ or walk_mode != int(n.get_meta("walk_mode", -1)) or dead != bool(n.get_meta("dead", false)): @@ -1303,13 +1326,13 @@ func _process(dt: float) -> void: _apply_anim(n, f, moving, walk_mode, dead) _update_hp_bars() -func _apply_anim(n: Node3D, f: int, moving: bool, walk_mode := 1, dead := false) -> void: +func _apply_anim(n: Node3D, f: int, moving: bool, walk_mode := WALKMODE_RUN, dead := false) -> void: var state := "wait" if dead: # CActorInstance::__SetMotion:IsDead() 时拒绝 DEAD/DAMAGE_FLYING 以外的动作。 state = "dead" elif f == FUNC_MOVE or (f == FUNC_WAIT and moving): - state = "walk" if walk_mode == 0 else "run" + state = "walk" if walk_mode == WALKMODE_WALK else "run" elif f == FUNC_ATTACK or f == FUNC_COMBO: state = "attack" elif f == FUNC_MOB_SKILL or (f & FUNC_SKILL) != 0: diff --git a/project/net_world_vis_test.gd b/project/net_world_vis_test.gd index f652c215..28de2f51 100644 --- a/project/net_world_vis_test.gd +++ b/project/net_world_vis_test.gd @@ -24,6 +24,9 @@ class FakeClient extends Node: signal entity_dead(vid: int) signal damage(vid: int, amount: int, flag: int) var ents := {} + var motion_speeds := {} # vid -> Vector2(walk, run) + func set_entity_motion_speed(vid: int, walk: float, run: float) -> void: + motion_speeds[vid] = Vector2(walk, run) func get_entity(vid: int) -> Dictionary: return ents.get(vid, {}) func get_entities() -> Array: return ents.values() func get_pvp_relations() -> Array: return [] @@ -126,6 +129,7 @@ func _run() -> void: _ck(int(added.get(2, 0)) == 2, "entity 2 rebuilt exactly once on re-entry") await _dead_motion(fc, nw) + await _walk_mode_motion(fc, nw) class AnimView extends Node3D: var states: Array[String] = [] @@ -159,3 +163,38 @@ func _dead_motion(fc: FakeClient, nw: Node) -> void: fc.entity_dead.emit(7) await create_tween().tween_interval(0.4).finished _ck(placeholder != null and placeholder.rotation.x < -1.0, "placeholder capsule (no motions) still falls over as its only death cue") + +class MoveView extends AnimView: + func get_move_motion_speeds() -> Vector2: + return Vector2(87.5, 425.75) + +# packet.h WALKMODE_RUN = 0 / WALKMODE_WALK = 1;CActorInstance::Move 按 m_isWalking +# 选 WALK / RUN。模型建好后把走 / 跑根运动速度推给 EntityStore(骑马不推)。 +func _walk_mode_motion(fc: FakeClient, nw: Node) -> void: + nw.set_model_factory(func(_d: Dictionary) -> Node3D: return MoveView.new()) + var walker := fc.add(8, NEAR_CM + Vector2(900, 0), {"race": 101, "ch_type": 2, "func": 1, "walk_mode": 1}) + fc.entity_spawned.emit(walker) + var runner := fc.add(9, NEAR_CM + Vector2(1200, 0), {"race": 101, "ch_type": 2, "func": 1, "walk_mode": 0}) + fc.entity_spawned.emit(runner) + var rider := fc.add(10, NEAR_CM + Vector2(1500, 0), {"race": 101, "ch_type": 0, "mount_vnum": 20030}) + fc.entity_spawned.emit(rider) + await process_frame + await process_frame # process_frame 在节点 _process 之前发 + var wv := nw.node_for(8) as AnimView + var rv := nw.node_for(9) as AnimView + _ck(wv != null and wv.states.back() == "walk", "walk_mode 1 (WALKMODE_WALK) -> walk: %s" % [wv.states if wv else []]) + _ck(rv != null and rv.states.back() == "run", "walk_mode 0 (WALKMODE_RUN) -> run: %s" % [rv.states if rv else []]) + _ck(fc.motion_speeds.get(8, Vector2.ZERO) == Vector2(87.5, 425.75), "spawn pushes walk/run motion speeds: %s" % [fc.motion_speeds]) + _ck(not fc.motion_speeds.has(10), "mounted actor keeps the duration lerp (no push)") + walker["walk_mode"] = 0 + await process_frame + await process_frame + _ck(wv.states.back() == "run", "GC_WALK_MODE flip to run re-selects RUN: %s" % [wv.states]) + # 匀角速度转向:一帧转角不超过 1200°/s × dt。 + var before := wv.rotation.y + walker["angle_deg"] = 180.0 + await process_frame + var t0 := Time.get_ticks_usec() + await process_frame + var dt := float(Time.get_ticks_usec() - t0) / 1e6 + _ck(absf(angle_difference(before, wv.rotation.y)) <= deg_to_rad(1200.0) * (dt + 0.05), "turn limited to 1200deg/s") diff --git a/project/ui/mob_view.gd b/project/ui/mob_view.gd index e67affd8..24ae83b7 100644 --- a/project/ui/mob_view.gd +++ b/project/ui/mob_view.gd @@ -30,6 +30,8 @@ const STATE_MOTIONS := { "standup_back": ["BACK_STANDUP"], } const HitReaction = preload("res://ui/hit_reaction.gd") +const MsaMotion = preload("res://ui/msa_motion.gd") +const MOVE_STATES := ["walk", "run"] const HitCollision = preload("res://hit_collision.gd") # CActorInstance::__SetMotion 尾:动作绑定完成。 @@ -42,6 +44,7 @@ var _mesh_stem := "" var _hit := HitReaction.new() var _motions := {} # MOTION_NAME(String) -> 绝对 .msa 路径 var _state := "" +var _move_speed_ratio := 1.0 var _last_resolution := {} var _audio: Node var _sound_instances: Array = [] @@ -156,7 +159,25 @@ func set_anim_state(s: String) -> void: if msa == "": # 旧客户端 GetMotionKey 失败时 SetLoopMotion/InterceptMotion 直接返回:保留当前动作。 return - _bind(s, msa, s in HitReaction.LOOP_STATES, 1.0) + _bind(s, msa, s in HitReaction.LOOP_STATES, _loop_speed(s)) + +# CActorInstance::SetMoveSpeed:m_fMovSpd = movSpd / 100,走 / 跑循环动作按它播放 +# (Move -> SetLoopMotion(WALK/RUN, 0.15, m_fMovSpd))。>1100 时参考端不走动作累计, +# EntityStore 退回按 duration 插值,这里保持原速播放。 +func set_move_speed(moving_speed: int) -> void: + _move_speed_ratio = 1.0 if moving_speed <= 0 or moving_speed > 1100 \ + else float(moving_speed) / 100.0 + if anim != null and _state in MOVE_STATES and not _hit.active: + anim.set("time_scale", _hit.scale_for(_move_speed_ratio)) + +func _loop_speed(state: String) -> float: + return _move_speed_ratio if state in MOVE_STATES else 1.0 + +## 走 / 跑动作的根运动速度(cm/s,movSpd 100):x = walk,y = run。0 = 取不到。 +## net_world 推给 EntityStore,远端移动按动作累计量推进(CActorInstance::AccumulationMovement)。 +func get_move_motion_speeds() -> Vector2: + return Vector2(MsaMotion.move_speed(String(resolve_motion("walk").path)), + MsaMotion.move_speed(String(resolve_motion("run").path))) # CGraphicThingInstance::InsertDelay(fStiffenTime) func insert_delay(d: float) -> void: diff --git a/project/ui/msa_motion.gd b/project/ui/msa_motion.gd new file mode 100644 index 00000000..fb1a99ae --- /dev/null +++ b/project/ui/msa_motion.gd @@ -0,0 +1,38 @@ +# MsaMotion —— 读 .msa 文本里的根运动:移动动作每秒位移(cm/s,movSpd 100 时)。 +# +# MotionDuration 0.600000 +# Accumulation 0.00 -255.45 0.00 +# +# 服务端 CHARACTER::GetMoveMotionSpeed = -Accumulation.y / MotionDuration; +# 客户端 CActorInstance::AccumulationMovement 按同一累计量推进。这里取 xy 长度。 +extends RefCounted + +static var _cache := {} # path -> float + +static func move_speed(path: String) -> float: + if path == "" or not path.to_lower().ends_with(".msa"): + return 0.0 + if _cache.has(path): + return float(_cache[path]) + var speed := 0.0 + var f := FileAccess.open(path, FileAccess.READ) + if f: + var duration := 0.0 + var accum := Vector2.ZERO + while not f.eof_reached(): + var parts := _fields(f.get_line()) + if parts.size() < 2: + continue + match String(parts[0]): + "MotionDuration": + duration = String(parts[1]).to_float() + "Accumulation": + if parts.size() >= 3: + accum = Vector2(String(parts[1]).to_float(), String(parts[2]).to_float()) + if duration > 0.0: + speed = accum.length() / duration + _cache[path] = speed + return speed + +static func _fields(line: String) -> PackedStringArray: + return line.strip_edges().replace("\t", " ").split(" ", false) diff --git a/project/ui/player_view.gd b/project/ui/player_view.gd index 99e65510..2bbe07ff 100644 --- a/project/ui/player_view.gd +++ b/project/ui/player_view.gd @@ -18,6 +18,8 @@ const FEMALE_RACES := [1, 3, 4, 6] const WEAPON_BONE := ["equip_right_hand", "equip_right", "equip_right", "equip_right"] const WEAPON_LEFT_BONE := ["", "equip_left", "", "equip_left"] const HitReaction = preload("res://ui/hit_reaction.gd") +const MsaMotion = preload("res://ui/msa_motion.gd") +const MOVE_STATES := ["walk", "run"] const HitCollision = preload("res://hit_collision.gd") # CRaceMotionData::MOTION_MODE_* -> playersettingmodule 注册动作用的目录(与 general 同级) const MOTION_MODE_DIRS := {1: "general", 2: "onehand_sword", 3: "twohand_sword", 4: "dualhand_sword", @@ -43,6 +45,7 @@ var anim: Node # Metin2AnimPlayer var motion_dir := "" var action_dir := "" var _state := "" +var _move_speed_ratio := 1.0 var _assets_root := "" var _audio: Node var _sound_instances: Array = [] @@ -135,7 +138,27 @@ func set_anim_state(s: String) -> void: if not FileAccess.file_exists(msa): msa = motion_dir.path_join(s + ".gr2") if FileAccess.file_exists(msa): - _bind(s, msa, s in HitReaction.LOOP_STATES, 1.0) + _bind(s, msa, s in HitReaction.LOOP_STATES, _loop_speed(s)) + +# CActorInstance::SetMoveSpeed:m_fMovSpd = movSpd / 100,走 / 跑循环动作按它播放 +# (Move -> SetLoopMotion(WALK/RUN, 0.15, m_fMovSpd))。>1100 时参考端不走动作累计, +# EntityStore 退回按 duration 插值,这里保持原速播放。 +func set_move_speed(moving_speed: int) -> void: + _move_speed_ratio = 1.0 if moving_speed <= 0 or moving_speed > 1100 \ + else float(moving_speed) / 100.0 + if anim != null and _state in MOVE_STATES and not _hit.active: + anim.set("time_scale", _hit.scale_for(_move_speed_ratio)) + +func _loop_speed(state: String) -> float: + return _move_speed_ratio if state in MOVE_STATES else 1.0 + +## 走 / 跑动作的根运动速度(cm/s,movSpd 100):x = walk,y = run。0 = 取不到。 +## net_world 推给 EntityStore,远端移动按动作累计量推进(CActorInstance::AccumulationMovement)。 +func get_move_motion_speeds() -> Vector2: + if motion_dir == "": + return Vector2.ZERO + return Vector2(MsaMotion.move_speed(motion_dir.path_join("walk.msa")), + MsaMotion.move_speed(motion_dir.path_join("run.msa"))) # Play one of the 40250 CRaceMotionData one-shot motions. The protocol sends # the numeric motion id; paired emotions use the other entity's race to select