From 1933a5cedaf330f52b40f053d3388ed55e246a89 Mon Sep 17 00:00:00 2001 From: shenlei Date: Sat, 12 Sep 2026 13:15:28 +0900 Subject: [PATCH] =?UTF-8?q?fix(combat):=20=E6=88=98=E6=96=97=E9=80=90?= =?UTF-8?q?=E9=A1=B9=E5=AF=B9=E9=BD=90=2040250=EF=BC=8C=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E7=A7=BB=E5=8A=A8=20/=20=E6=8A=80=E8=83=BD=E9=87=8A=E6=94=BE?= =?UTF-8?q?=E4=B8=8E=E6=AD=A6=E5=99=A8=E6=8C=82=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 连击类型由 combo_changed 驱动(SetComboSkillFlag,技能 122 等级) - 命中判定改为 .msa 刀尖 Z 圆柱 vs .msm 防御球(hit_collision.gd 逐行移植) - _register_hit 改为 map::insert 语义,修正命中上限 - 攻速同时缩放 GetAttackingElapsedTime 与攻击动作速率;按武器动作模式目录绑定攻击段 - 去掉本地连击超时,motion_bound 清命中表 / 连击段号 - __ProcessDataAttackSuccess:InsertDelay 硬直、physics_push.gd 击退 + GetBlendingPosition 同步、 命中特效、__HitGood / __HitGreate / __HitStone 受击动作链与抖动(ui/hit_reaction.gd) - ClassicSession::send_use_skill 不再夹带 CG_FLY_TARGETING - mac 前进 / 后退方向与技能释放修复;武器按职业骨骼挂到手上 - 新增 hit_collision / physics_push / hit_view / net_world_push / weapon_attach 测试; gpu_pose_bounds / race_motion_assembly 适配 damage 随机变体 - 文档:CLIENT-GAP.md、CLIENT-GAP-FIX.md §3.5 / §3.7 与 C.5 增量 130 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ft3kXpNdLbKEfg5uDLfqVF --- docs/CLIENT-GAP-FIX.md | 157 +++++++- docs/CLIENT-GAP.md | 27 +- extension/src/net/classic/classic_session.cpp | 8 +- extension/tests/net_classic_session_test.cpp | 10 + project/combat_fx_test.gd | 337 ++++++++++++++-- project/equip_model_test.gd | 71 +++- project/game_camera.gd | 2 +- project/game_scene.gd | 8 + project/gamescene_test.gd | 4 + project/gpu_pose_bounds_test.gd | 3 + project/hit_collision.gd | 160 ++++++++ project/hit_collision_test.gd | 78 ++++ project/hit_view_test.gd | 254 ++++++++++++ project/input_key_test.gd | 21 +- project/net_play.gd | 381 +++++++++++++----- project/net_world.gd | 57 +++ project/net_world_push_test.gd | 125 ++++++ project/physics_push.gd | 129 ++++++ project/physics_push_test.gd | 72 ++++ project/player_controller.gd | 10 +- project/player_move_test.gd | 42 ++ project/race_motion_assembly_test.gd | 4 +- project/skill_test.gd | 20 + project/ui/equip_model.gd | 54 ++- project/ui/hit_reaction.gd | 127 ++++++ project/ui/mob_view.gd | 98 ++++- project/ui/player_view.gd | 147 ++++++- project/ui/skill_table.gd | 63 ++- project/ui/ui_manager.gd | 12 +- project/weapon_attach_test.gd | 81 ++++ 30 files changed, 2353 insertions(+), 209 deletions(-) create mode 100644 project/hit_collision.gd create mode 100644 project/hit_collision_test.gd create mode 100644 project/hit_view_test.gd create mode 100644 project/net_world_push_test.gd create mode 100644 project/physics_push.gd create mode 100644 project/physics_push_test.gd create mode 100644 project/ui/hit_reaction.gd create mode 100644 project/weapon_attach_test.gd diff --git a/docs/CLIENT-GAP-FIX.md b/docs/CLIENT-GAP-FIX.md index 9684f154..6bbedbda 100644 --- a/docs/CLIENT-GAP-FIX.md +++ b/docs/CLIENT-GAP-FIX.md @@ -355,8 +355,10 @@ 普攻 `_swing_skill=0`),被击退目标记 `_victim_flush`。`_emit_swing()` 改为**只有拿不到 命中窗**(headless / 缺资源)才在挥击时立即发 `CG_ATTACK`。`_flush_victim_list()`(对齐 `PythonPlayerEventHandler.cpp:194`)帧末把 `_victim_flush`(≤16)打包 `client.sync_positions()`。 - 几何判定为近似:`reach = max(WeaponLength, 窗内采样最大水平偏移)`,命中 = 目标落在正面 `±60°`、 - `(reach+20+45) cm` 弧内;真实动态圆柱扫掠需骨骼矩阵 + defending sphere(随 §3.1 留桩)。 + ~~几何判定为近似:`reach = max(WeaponLength, 窗内采样最大水平偏移)`,命中 = 目标落在正面 `±60°`、 + `(reach+20+45) cm` 弧内;真实动态圆柱扫掠需骨骼矩阵 + defending sphere(随 §3.1 留桩)。~~ + **增量 130 已替换**为 `.msa` 刀尖扫掠 Z 圆柱 vs `.msm` 防御球的 `detect_z_cylinder` 判定(见 §3.5 修改 4 / + C.5 增量 130)。 `combat_fx_test.gd` 加 `FakeNetWorld` + 6 段断言(`CG_ATTACK` 延后 / 窗内命中 / `CG_SYNC_POSITION` / COMBO 单次 / 正面弧 / 越窗收起)。C++ 21/21 + 7 个 GDScript 回归全绿。留给增量 84:武器种类 → `combo_motion_mode` 映射。 @@ -700,7 +702,7 @@ 新增 `_attack_ctx()` 从 `client.get_duel()` / `get_pvp_relations()` / `get_guild_wars()` / `get_party()` 装配 ctx(observer / main_vid / pk_mode / duel_mode / duel_opponents / pvp_pairs / gvg_pairs / party_vids)。5 处调用点(含 `__ReserveProcess_ClickActor` 第 3 步、 - `_can_shot`、`_hit_geometry`)无签名变化。 + `_can_shot`、`_hit_geometry`;增量 130 起后者并入 `_normal_attack_process`)无签名变化。 ④ 自检 `project/entity_rules_test.gd`(第 15 个 canonical,25 组断言);`combat_fx_test` / `netplay_test` / `fly_test` 的怪物夹具补 `ch_type: 2`(此前无 `ch_type` → `_entity_kind` 归 PC, 桩按 PC 也「可打」;1:1 规则下和平同帝国 PC 不可打 → 夹具需明确怪类别,与真实服务器一致)。 @@ -2352,9 +2354,23 @@ OnHit(uSkill, victimActor, isSendPacket): **只有拿不到命中窗数据**(headless / 缺资源)才在挥击时立即发 `CG_ATTACK`,有窗时交给 `_attack_process`。`_flush_victim_list()`(对齐 `PythonPlayerEventHandler.cpp:194`)在 `_process` 帧末把 `_victim_flush`(≤ `SYNC_POSITION_LIMIT` 16)打包 `client.sync_positions()`。 - 几何判定是 **近似**:`reach = max(WeaponLength, 窗内采样最大水平偏移)`,命中 = 目标落在正面 + ~~几何判定是 **近似**:`reach = max(WeaponLength, 窗内采样最大水平偏移)`,命中 = 目标落在正面 `±60°`、`(reach + 20 + 45) cm` 弧内;真实的动态圆柱-圆柱扫掠需要骨骼矩阵 + defending - sphere 数据(同 §3.1 actor 碰撞一起留桩)。`combat_fx_test.gd` 加 `FakeNetWorld` + + sphere 数据(同 §3.1 actor 碰撞一起留桩)。~~ + **增量 130 升级为逐行判定**:删除 `_hit_deduped` / `_hit_geometry`,新增 `_normal_attack_process()` + 对齐 `__NormalAttackProcess`: + - 距离门 `v3Distance = (dX, dZ, dZ)`:出货代码平面 Y 不参与、高度差算两次,原样保留;上限 300 cm, + `IS_HUGE_RACE` 为 500 cm。 + - 命中窗 `[start,end]` 与 `[t-dt, t]` 相交后查 `m_HitDataMap`:COMBO 同窗不再判,其余等 `fInvisibleTime`。 + - 窗内每个采样的刀尖 `last_pos → pos` 按攻击者朝向转到世界 cm,作为半径 20 的动态 Z 圆柱, + 对受击方每个防御球做 `HitCollision.detect_z_cylinder()`(逐行移植 + `DetectCollisionDynamicZCylinderVSDynamicZCylinder` + `IntersectLineSegments`)。 + - 碰上后 `_register_hit()` → `_process_attack_success()`。 + - `_register_hit` 修正为 `map::insert` 语义:已有目标不覆盖冷却,插入后超上限才返回 FALSE。 + - 防御球取 `.msm` 的 `CollisionType 3`(`HitCollision.parse_msm_defending`):PC 读 + `root/msm/_.msm`,怪物读模型目录 `.msm`。上一帧球心缓存在 `_def_last`。 + - 判定时间改为 `_attacking_elapsed() = (_local_time - _motion_start_t) * _atk_speed_factor` + (`GetAttackingElapsedTime`);`_local_time` 由 `_advance_local_time()` 先扣 `m_fDelay` 再推进。`combat_fx_test.gd` 加 `FakeNetWorld` + 命中窗几何判定 / `CG_ATTACK` 延后 / `CG_SYNC_POSITION` / COMBO 单次 / 正面弧 / 越窗收起 6 段断言。 5. `OnAttack` 事件(动作真正起手时)发 `CG_CHARACTER_MOVE{FUNC_COMBO, wMotionIndex}`, 与 `CG_ATTACK` 是**两个不同时刻**的两个包,不能合并。**[已在增量 78 `_do_attack_swing` 落地]** @@ -2395,8 +2411,10 @@ OnHit(uSkill, victimActor, isSendPacket): 修改 6 完成(增量 84:`motion_mode_for` + `_refresh_motion_mode` 按装备武器 subtype / 骑乘 / 变身 切 `combo_motion_mode`,对齐 `RefreshState`);修改 7 完成(增量 85:`_clickable_distance()` 弓箭 `__GetBowRange()` 分支 + `_bow_range_cm()` + `POINT_BOW_DISTANCE` 加成)。C++ 21/21 + 7 GDScript -全绿。仍待:几何判定升级为真实动态圆柱扫掠(需骨骼矩阵 + defending sphere,随 §3.1 actor 碰撞)、 -服务端合法性校准。§3.6 箭矢 `MOTION_EVENT_TYPE_FLY` 事件已在增量 86 落地(见 §3.6)。 +全绿。~~仍待:几何判定升级为真实动态圆柱扫掠(需骨骼矩阵 + defending sphere,随 §3.1 actor 碰撞)、 +服务端合法性校准。~~ —— 增量 130 已落地:Z 圆柱 vs `.msm` 防御球,以及 `__ProcessDataAttackSuccess` +的完整效果(见修改 4 / §3.7 / C.5 增量 130)。仍待:服务端合法性校准;防御球当前是模型本地近似, +未跟骨骼矩阵走。§3.6 箭矢 `MOTION_EVENT_TYPE_FLY` 事件已在增量 86 落地(见 §3.6)。 --- @@ -2512,6 +2530,35 @@ handler 由 `__OnPressActor` 通过 `netplay_test` / `netbridge_test` / `gamescene_test` / `player_motion_test` 全绿。 +**增量 130(受击方反应 1:1,2026-09-12)** + +- 新增 `project/ui/hit_reaction.gd`,由 PlayerView / MobView 共用。`good()` / `greate()` / `stone()` + 逐分支对齐 `__HitGood` / `__HitGreate` / `__HitStone`: + - 击倒中不再换受击动作,Greate 在起身中也不换。 + - 晕眩时:Good → `Die`(死亡动作由服务端驱动,seam);Greate 只播 `DAMAGE_FLYING(_BACK)`, + 并置 `m_isRealDead`,不起身。 + - `isLock`(普攻 / 连击 / 技能 / 表情 / 钓鱼等)只抖动不换动作;Greate 在 `IsUsingSkill` 时也只抖。 + - `scalar = dot(攻击方朝向, 受击方朝向)`:小于 0 走正面 `DAMAGE` / `DAMAGE_FLYING → STAND_UP`; + 否则先试 `_BACK` 版,缺资源退回正面版。 + - 动作链 = `InterceptOnceMotion` + `PushOnceMotion` + 链尾 `PushLoopMotion`,回到受击前的 + wait / walk / run。 +- `__Shake(100)`:100 ms 内模型位置每帧偏移 ±`rand()%10` cm,结束复位。 +- `InsertDelay(fStiffenTime)`:`time_scale = 0` 冻结,到期恢复该动作的 `fSpeedRatio`;冻结期间新绑定的 + 动作同样保持冻结。 +- PC 受击文件按 `playersettingmodule` 注册表取,同名多份等权随机: + - `damage` / `damage_1`;BACK 版 `damage_2` / `damage_3` + - `damage_flying` / `back_damage_flying` + - `falling_stand` / `back_falling_stand` +- 怪物在 `STATE_MOTIONS` 新增 `BACK_DAMAGE` / `FRONT_KNOCKDOWN` / `BACK_KNOCKDOWN` / `FRONT_STANDUP` / + `BACK_STANDUP`。 +- `is_in_hit_reaction()` 改为返回 `_hit.active`:整条链播完才清 `net_play._knock_down`。原来的 + `_state == "damage"` 分支并入链尾处理。 +- 击退:`net_world.push_victim()` 用 `physics_push.gd`(逐行移植 `CPhysicsObject` + + `CEaseOutInterpolation`),每帧叠加位移。 + - `blending_position()` = `GetBlendingPosition`,供 `OnHit` 的 `CG_SYNC_POSITION` 使用。 + - 推动期间服务端位置变化:以新位置为基准继续叠加。 + - 推动结束后服务端位置一变,即丢弃偏移。 + --- ### 3.8 技能缺少统一合法性检查 @@ -10145,3 +10192,99 @@ dx² + dy² > s_fLimitDistance → 不播 seam ⑨ 名字与气泡合并为单尾标链表;完整 Quest / fishing / dungeon / mount / observer 玩法与 复杂 UI 分支;真实服务器、真机、纯 pack、双架构和正式签名验收。 ``` + +### C.5 批次记录 · 增量 130(W2 / §3.5 · §3.7 战斗与 40250 对齐:命中判定、命中效果、受击反应、攻速、连击类型、技能包) + +```text +批次:增量 130 —— 逐项修正与 40250 参考端战斗实现的差异(2026-09-12)。每项先写失败断言再改实现。 + +1. 连击类型: + net_play.gd 监听 client.combo_changed,由 _on_combo_changed() 处理,对齐 + CPythonPlayer::SetComboSkillFlag: + · 查连击技能 122 的等级;缺槽或等级 <= 0 直接返回,不改 combo type。 + · 否则 _combo_type = enabled ? MIN(level, 2) : 0。 + · 不在本地伪造技能等级。 + +2. 命中判定: + 删除 _hit_deduped / _hit_geometry(reach + 正面 ±60° 近似)。 + · 新增 project/hit_collision.gd,逐行移植: + IntersectLineSegments / FindNearestPointOnLineSegment / FindNearestPointOfParallelLineSegments / + AdjustNearestPoints / DetectCollisionDynamicZCylinderVSDynamicZCylinder; + 另含 .msm CollisionType 3 防御球解析与 IS_HUGE_RACE。 + · net_play._normal_attack_process() 流程: + (dX, dZ, dZ) 距离门 300 / 500 cm → 命中窗相交 → m_HitDataMap → + 刀尖采样半径 20 的 Z 圆柱 vs 受击方防御球(上一帧球心存 _def_last)。 + · PlayerView 取 root/msm/_.msm,MobView 取模型目录 .msm,都经 get_defending_spheres() 提供。 + · 没有 .msm 的占位节点退化为单球 HIT_FALLBACK_SPHERE(seam)。 + +3. _register_hit: + 改为 map::insert 语义:已登记的目标不刷新冷却,插入后超上限才返回 FALSE。 + +4. 攻速: + _atk_speed_factor(bAttackSpeed/100,>1100 → 0)同时用于两处: + · _attacking_elapsed() = (_local_time - _motion_start_t) * fSpeedRatio + · PlayerView.play_attack_motion(mode, index, speed_ratio) 的 time_scale + play_attack_motion 按 MOTION_MODE 目录绑定 attack(_1) / combo_0N,模式目录缺该段时回退 general。 + +5. 连击清零: + 删除本地连击超时清空。PlayerView / MobView 新增 motion_bound(state) 信号(__SetMotion 尾), + 由 net_play._on_motion_bound() 处理: + · 新动作带 AttackingData → 清 m_HitDataMap。 + · 连击中换到无 ComboInputData 的动作 → _combo_index = 0。 + +6. __ProcessDataAttackSuccess(_process_attack_success): + · InsertDelay(fStiffenTime):攻击者写 _delay(_advance_local_time 先扣),视图 time_scale 冻结; + 受击方同样 insert_delay,并记 m_fInvisibleTime(_victim_invisible_until,期间 AttackingProcess 跳过它)。 + · 击退门 __CanPushDestActor(_can_push): + 建筑 / 门 / 石头 / NPC / 巨型不推;晕眩必推;否则要求 owner 为主角且 owner 时间 <= 3 s。 + 通过后 net_world.push_victim() 推动(physics_push.gd 逐行移植 CPhysicsObject + CEaseOutInterpolation)。 + OnHit 时目标仍在推动中 → GetBlendingPosition 进 CG_SYNC_POSITION 列表。 + · 命中特效 blow_1_low.mse: + 建筑 / 门放在攻击者身前 30 cm、不转向;巨型放在命中点;其余放在受击方。 + game_scene 装配 net_play.fx = fx、fx_parent = Entities。 + · 受击反应:石头 / 门 → hit_stone;HIT_TYPE_GOOD → hit_good;HIT_TYPE_GREAT → hit_greate; + scalar = cos(攻击方 yaw − 受击方 yaw)。状态机 ui/hit_reaction.gd 见 §3.7 增量 130。 + · 最后 _on_hit:SetTarget + CG_ATTACK。 + +7. 技能包: + ClassicSession::send_use_skill 删除夹带的 CG_FLY_TARGETING,只发 TPacketCGUseSkill(对齐 + SendUseSkillPacket)。飞行目标仍由施法方的 CG_ADD_FLY_TARGETING / OnSetFlyTarget 发送。 + net_classic_session_test 新增 "use skill wire: no extra fly targeting" 断言。 + +8. 既有测试随行为调整: + 受击 damage 按 playersettingmodule 注册为 damage / damage_1 等权随机。 + · gpu_pose_bounds_test:CPU / GPU 两个视图绑定前用同一 seed,保证选中同一段。 + · race_motion_assembly_test:damage 允许任一注册变体。 + 这两个测试在本批次改动后曾失败(CPU / GPU 选到不同段、断言写死 damage.msa),调整后通过。 + +回归(godot --headless --path project --script .gd): + 新增 PASS:hit_collision_test、physics_push_test、hit_view_test、net_world_push_test。 + PASS:combat_fx_test、gamescene_test、netplay_test、skill_test、player_move_test、input_key_test、 + weapon_attach_test、mob_view_test、player_motion_test、fly_test、remote_player_test、 + net_world_vis_test、forest_mob_render_test、p2b_test、playable_harness_test、playable_combat_test、 + gpu_pose_bounds_test、race_motion_assembly_test。 + 已知失败(与本批次无关):equip_model_test "armor 11000 -> values[3]=0 回退 =vnum"(夹具期望过期)。 + forest_map_render_test 是 extends Node 的 MT_TEST_MODE 入口,不能用 --script 直跑,本批次未运行。 + ctest --test-dir build:22/23 PASS。net.classic_session 仍是既有 25 条失败 + (quest / duel / messenger / exchange / guild / safebox / mall / sync / ground item / despawn), + 都与 use_skill 无关;新增的 use skill 断言通过。 + +验收边界: + 已验证(离线):Z 圆柱几何、.msm 防御球解析、命中表 / 上限、攻速缩放、连击类型与清零、 + 硬直 / 击退 / 同步位置 / 特效挂载、受击动作链 / 抖动 / 冻结、技能包线格式。 + 未验证:真实服务器下的命中与击退手感、真机视觉。 + seam: + · 击退没有地形 / 角色碰撞,也无 AdjustCollisionWithOtherObjects、溅射、IsResistFallen。 + · owner 时间取客户端观测时刻。 + · 防御球按模型本地坐标、不跟骨骼矩阵;占位节点退化为单球。 + · 远端角色不跑 AttackProcess;晕眩 → Die 不在本地播。 + · 攻击 / 受击动作变体等权随机;受击动作固定取 general 目录。 + · 抖动用模型位置偏移,InsertDelay 用 time_scale 冻结。 + · 受击链期间的循环动作请求延后到链尾。 + · 推动结束后服务端位置一变即丢弃偏移。 + · 本地 GC 伤害包仍直接走 set_anim_state("damage")。 + 改动 C++ 后已重建 mtgodot 扩展;脚本改动需要重新导出 PCK 才进包。 + +下一批次: + 击退世界碰撞与 IsResistFallen;防御球跟骨骼矩阵;远端 AttackProcess;真实服务器战斗验收。 +``` diff --git a/docs/CLIENT-GAP.md b/docs/CLIENT-GAP.md index 91d33e9c..09e9767d 100644 --- a/docs/CLIENT-GAP.md +++ b/docs/CLIENT-GAP.md @@ -81,6 +81,20 @@ > 是单尾标链表、完整 `.msc` 电影播放器、Quest/fishing/dungeon/mount/observer 完整玩法、 > 真实服务器、真机、纯 pack、双架构和正式签名验收。 +> **W2 最新进展(增量 130,2026-09-12)**:战斗逐项对齐 40250。 +> - 命中判定改为 `.msa` 刀尖 Z 圆柱 vs `.msm` 防御球(`hit_collision.gd`)。 +> - `__ProcessDataAttackSuccess` 补齐:`InsertDelay` 硬直、`physics_push.gd` 击退 + `GetBlendingPosition` +> 同步、命中特效,以及 `__HitGood` / `__HitGreate` / `__HitStone` 受击动作链与抖动 +> (`ui/hit_reaction.gd`)。 +> - 攻速同时缩放命中时间与动作速率。 +> - 连击类型改由 `combo_changed` 驱动;连击段号和命中表随 `motion_bound` 清零。 +> - `send_use_skill` 不再夹带 `CG_FLY_TARGETING`。 +> - 测试:新增 `hit_collision_test` / `physics_push_test` / `hit_view_test` / `net_world_push_test`, +> 扩充 `combat_fx_test`。 +> - seam:击退没有世界 / 角色碰撞;防御球未跟骨骼;远端角色不跑 AttackProcess;owner 时间取客户端观测。 +> +> 详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.5 / §3.7 / C.5 增量 130。 + > **W4 最新进展(增量 100)**:EffectLib 已完成缺省 cycle、有限 LoopCount 发射停止、重播时钟复位和 > 粒子寿命隔离;OUTER / INNER 当前仍是显式标记的宽方向 fallback,不计作精确径向速度完成。完整 > 状态以本文变更记录和 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §6.3 / C.5 为准。 @@ -841,8 +855,9 @@ > `client.attack(_swing_skill, vid)`(`OnHit`→`SendAttackPacket`,普攻 `_swing_skill=0`), > 被击退目标帧末 `client.sync_positions()`(≤16,对齐 `PythonPlayerEventHandler.cpp:194 > FlushVictimList`)。`_emit_swing()` 因此改为**只有拿不到命中窗数据**(headless / 缺资源) -> 才在挥击瞬间立即发 `CG_ATTACK`。几何判定用 `reach = max(WeaponLength, 窗内采样最大水平偏移)` -> + 正面 `±60°` 弧近似(真实动态圆柱扫掠需骨骼矩阵 + defending sphere,随 §3.1 留桩)。 +> 才在挥击瞬间立即发 `CG_ATTACK`。~~几何判定用 `reach = max(WeaponLength, 窗内采样最大水平偏移)` +> + 正面 `±60°` 弧近似(真实动态圆柱扫掠需骨骼矩阵 + defending sphere,随 §3.1 留桩)。~~ +> 增量 130 已换成 Z 圆柱 vs `.msm` 防御球的逐行判定。 > `combat_fx_test.gd` 加 `FakeNetWorld` + 6 段断言。C++ **21/21** + 7 个 GDScript 回归全绿。 > 留给增量 84:武器种类 → `combo_motion_mode` 映射。 > 详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.5 / C.5。 @@ -1892,7 +1907,7 @@ | P0 | 🟡 | 装备 / 背包数据模型 | 常规背包 90 格、运行时 24 wear 位、腰带 16 格、基础 tooltip 已有;增量 96 按 `GameType.h` 对齐基础 wear / costume / ring / belt 发包坐标;增量 97 按 `ItemData.h` 防御性重建 `CanEquipNow` 的 antiflag→wearable→limit 校验;增量 98 完成变身 / 动物时装 / 婚纱部位遮挡;增量 99 让远端 PC 创建 `RemotePlayerView` 并消费 `awPart[ARMOR,WEAPON,HEAD,HAIR]` 四字段换装 | 变身字段、所有职业 / race 的资源、真服远端换装、精确 tie-break / 同槽替换 / `LIMIT_PCBANG`·`REAL_TIME*` / 属性读取、原版 costume / belt 视觉、装备对比和真机仍待验收 | | P0 | 🟡 | 移动与战斗同步 | 有 `GC_MOVE`、本地预测、攻击;`M2Client.character_position()` 可发送姿态回报,`M2Client.sync_positions()` 可按原包批量发送最多 16 个坐标,`M2Client.request_warp()` 可发送 `CG_WARP` 主动传送请求;`GC_SYNC_POSITION` 批量坐标纠偏、`GC_CHANGE_SPEED`、`GC_WALK_MODE`、`GC_CHARACTER_POSITION` 和 `GC_DIG_MOTION` 已恢复到实体状态,`NetPlay` 按 `moving_speed/100` 校准主角步行 / 奔跑预测,挖矿广播可驱动目标朝向 / dig 动画,`WALK_MODE` 会驱动远端 walk/run 动画选择;真服单会话已发送 `CG_CHARACTER_POSITION`(5B) 与 `CG_SYNC_POSITION`(16B),均无断连 / unknown;**本地移动改为 Src/Dst 模型(增量 76,§3.1)**:`player_controller.gd` 的 `walk_to()` → `_goto()` 对齐 `CInstanceBase::NEW_Goto()`(`InstanceBaseMovement.cpp:251`),太近(`< MOVABLE_GROUND_DISTANCE_M` 1.0 m)不动、三门 `_can_translate()`(`frozen`/`locked`/`moving_skill` ≈ `__IsSyncing`/`isLock`/`IsUsingMovingSkill`)未过时按 `__ReserveClickGround` 存点 + 0.1 s 延迟重试,`moving_skill` 只转向,WASD 分支 `_is_going = false`;**远端同步改为命令队列(增量 77,§3.2 part A)**:`EntityStore` 远端 Actor 位置由 `std::deque` 驱动(1:1 复刻 `StateProcess`,`InstanceBase.cpp:1278`),`mut_move()` 入队 + `drain_state_queue()` 按 `set_server_frame_ms()`(为 0 即时释放)+ `__CanProcessNetworkStatePacket` 逐条释放,`apply_state_cmd()` 走 `FUNC_WAIT` 1.0 / `FUNC_COMBO/ATTACK/MOB_SKILL` 50.0 硬阈值 + `m_kMovAfterFunc` 到达后动作 + `skip_collision`,线性插值只保留给 §2.1 可见性;**上行 6 事件回调(增量 78,§3.2 part B)**:`net_play.gd` 按 `PythonPlayerEventHandler.cpp` 拆成 OnMove(立即 `FUNC_MOVE` + 重置 moving/waiting 节流窗)/ OnMoving(300 ms 节流)/ OnWaiting(`_tick_on_waiting`,100 ms + 位移 ≥ 1 cm)/ OnStop(`FUNC_WAIT`)/ OnAttack(`CG_ATTACK` 外额外 `FUNC_COMBO`)/ `on_use_skill()`(`FUNC_SKILL|motion`),统一走 `_send_state`;`net_world.gd` 去掉自有 `follow_lerp`,节点位置直接跟随 `e.pos`(插值全交 C++ `tick()`);**真实服务器毫秒帧钟(增量 79,§3.2 收官)**:`ClassicStream::server_frame_ms()`(≈ `ELTimer_GetServerFrameMSec()`)由 `HDR_HANDSHAKE` 存的 `m_server_time_base = hs.time + lDelta` / `m_client_time_base` 算出,`ClassicSession::process()` 每帧 `set_server_frame_ms()` 后 classic 后端命令队列按服务器时间放行(m2dev 恒 0 = 即时释放,无回归);`drain_state_queue` 加防呆阀 `STATE_QUEUE_MAX_WAIT_MS=1000`(`chk_time` 领先帧钟超 1 s 立即放行,参考端无此上限,有意偏离);**Actor-Actor 碰撞 + 坐骑转向速度(增量 93,§3.1 收尾)**:`player_controller._actor_blocked()` 近似 `TestActorCollision`(8 m 距离门 + body 球重叠 `ACTOR_BODY_RADIUS_M=0.55` + 趋近判定)+ `_process` 逐轴门控(单 Actor 滑移 / 两轴挡=`BlockMovement`),`skip_actor_collision`(≈ `CanSkipCollision()`)观战置位;`rotation_speed_deg` + `_turn_toward()` 逐帧限幅收敛,`net_play._refresh_motion_mode()` 坐骑切 `ROT_SPEED_HORSE_DEG=300`(`c_fDefaultHorseRotationSpeed`)/ 常态 1200,替换两处 `rotation.y = atan2()` 瞬转;`player_move_test.gd` 5→8 组 | 仍需按原客户端同步窗口、碰撞纠偏触发时机与保留动作处理;**抖动缓冲 / 预测回滚不做(§3.2/§6 排除)**;姿态动作还没有完整 UI / 动画语义;~~§3.1 的 Actor 间碰撞(`TestActorCollision`/`BlockMovement`)、坐骑 `MODE_HORSE*`(依赖 §6.4 + §3.2)~~(增量 93 已近似落地)、真实 body sphere 半径(`.col` race 碰撞数据)、`AdjustDynamicCollisionMovement` 精确滑移 + `uCollisionCount==2` 复合门、`MOVABLE_GROUND_DISTANCE` 默认值校准、网络驱动本地预测玩家的 `__EnableSkipCollision()`;真实服务器 `GC_MOVE` `dwTime` 时基联机核验、`__IsEnableTCPProcess` 表情锁、`GC_SYNC_POSITION` 是否清队列(随对应 §3.x) | | P0 | 🟡 | PVP / 决斗 / 阵营 | `GC_PVP` 和 `DUEL_START` 已进入状态层,并以 `pvp_changed` / `duel_started` 信号提供给 Godot;挑战 / 战斗 / 复仇关系会在世界实体上显示标签;实体已有部分 empire / alignment / pk 字段;**可攻击判定 `IsAttackableInstance` 已 1:1(增量 94,`entity_rules.gd` + `net_play._is_attackable` / `_attack_ctx`,含 alignment grade/type、`IsSameEmpire`、`IsConflictAlignmentInstance`、`IsPVPInstance`、`__FindDUELKey`、`IsSamePartyMember`、`IsWoodenDoor`)** | ~~可攻击判定~~(增量 94 落地);~~原版名字颜色(`ENameColor` §8.8)~~(增量 110 落地:`name_color.gd` 1:1 `GetNameColorIndex` / `SetEmpireNameMode`,`net_world` 事件 + ~2Hz 兜底重染 `Label3D.modulate`,帝国名色默认关、开关 `net_world.set_empire_name_mode()` 已由增量 118 接 §8.4「名字色」radio);~~头顶称号色 / 公会名 / 等级~~(增量 111 落地:`text_tail.gd` 1:1 `GetTitleColor` / `RefreshTextTail` / `UpdateTextTailLevel` / `EnablePKTitle`,`net_world` 驱动 `GuildTag` / `TitleTag` / `LevelTag` 子标签、开关 `net_world.set_pk_title_enabled()` 未接 UI);~~头顶聊天 / 信息气泡~~(增量 112 落地:`chat_tail.gd` 1:1 `RegisterChatTail` / `RegisterInfoTail` / `TextTail_GetLivingTime` / `IsChatTextTail`,`net_world` 每 vid 一条 `"bubble"` `Label3D` + 5s 硬删 + `"名字: "` 前缀剥离 + SHOUT·COMMAND 排除、`register_info_tail()` 公开方法);~~头顶名字「是否显示」/「总是显示名字」/ LALT 名字显示 / 目标强显~~(增量 113 落地:`name_show.gd` 1:1 `IsAlwaysShowName` / `SetAlwaysShowNameFlag` / `__IsShowName` / `ShowAllTextTail` 的 `fDistanceFromPlayer < 3500` 平面距裁剪 / `ShowCharacterTextTail` 的 `isShow · !IsGuildWall · CanPickInstance` 资格 / `UpdateAllTextTail` 的 chat `bNameFlag` 强显,`net_world._name_shown()` 四路:`_always_show_name`(`game_option_ui` radio)/ `_alt_held`(LALT)/ `_target_vid`(`net_play.target_changed`)/ chat `bNameFlag`);~~头顶伤害飘字 `AddDamageEffect` / `ProcessDamage`~~(增量 114 落地:`damage_effect.gd` 1:1 `enum DamageFlag` / `RecvDamageInfoPacket` 的 `bSelf` / `bTarget` / `damage >= 0` 门 / `AddDamageEffect` 的 `IsShowDamage()` 门 / `ProcessDamage` 分类(`DODGE\|BLOCK`→MISS、`CRITICAL`·`POISON` 参考端注释掉走普通数字、`bSelf`→`"damage_"`、`!bTarget`→`"nontarget_"` 不渲染、否则 `"target_"`、`while(damage>0)` 逐位)/ `SetShowDamageFlag`,`net_world._on_damage` / `_process_damage_queue` 每帧 pop 一条、`game_option_ui`「伤害数字」radio 接 `net_world.set_show_damage`);~~头顶目标框 `RecvTargetPacket` 收包分派 / `CanViewTargetHP`~~(增量 115 落地:`target_board.gd` 1:1 `RecvTargetPacket` 判定树(实例缺失→`CloseTargetBoard`、目标已死→不动框、`IsPC()\|IsBuilding()`→`CloseTargetBoardIfDifferent`、`CanViewTargetHP`(`IsStone()\|IsWoodenDoor()\|IsEnemy()`)→`SetHPTargetBoard`、否则→`CloseTargetBoard`)+ `game.py` `SetHPTargetBoard` 换 VID 先 `ResetTargetBoard`,`net_play._on_target_info` 由「任意有名字 VID 都显框」改为按判定树分派:PC / building 不显血条框、NPC / warp 关框);~~目标框「看他国玩家」前置过滤(`uitarget.py TargetBoard.Open :189-193`)~~(增量 118 落地:`target_board.hidden_as_other_empire` + `net_play._on_target_info` 在 `classify` 前判 —— game-option 关「看他国目标框」且目标异阵营 → `_clear_target()` return;`net_play.set_view_other_empire_target()` 经 `game_scene._on_display_option` 接 §8.4 `target_board` radio、开局 `display_value` 回填、`game_option_ui.gd` `target_board` 默认修 0→1);仍需 PK 模式上行、保护区 `ATTRIBUTE_BANPK`、原版目标板视觉 / 决斗 UI、可攻击光标按 `ENameColor` 驱动;seam:`__FindPVPKey` / `__FindGVGKey` / `IsGameMaster` / `IsWall` race 白名单 / m2dev `IsKiller` 位的 packet setter、`name_color` 的 `kind_resolver` 未注入时回退裸 `ch_type`(seam ⑥)、`text_tail` 子标签 billboard 竖排 vs 屏幕空间排版(seam ⑦;逐像素水平排版增量 121 已 1:1、纵向位移 119、文本宽换真实字体度量 `GetTextSize` 123 已 1:1、单行文本高换 `GetTextSize` 的 h 124 已 1:1、剩余:无公会徽记 mark 实体 / 三子标签竖排叠 x 偏移 vs 同一屏幕行)、`title_name_resolver` 未注入回退裸 key(seam ⑧)、`chat_tail` 气泡与名字 `Label3D` 并存、名字与气泡未合成单尾标链表(seam ⑨ 遗留;~~名字随气泡下移 17px~~ 增量 117 落地:`name_tail_layout.gd` 纯静态第 28 个 canonical `UpdateShowingTextTail` :110-114 —— chat 尾标 `bNameFlag` TRUE 时名字 `Label3D` 局部 y 顶到「气泡 y + 17*pixel_size」,`net_world._reposition_name_tag` 由 `_apply_chat_tail` / `_expire_chat_tails` 调用)、~~子标签竖排 vs 参考屏幕空间逐像素水平排版(seam ⑦ 遗留)~~(增量 119 落地纵向:`name_tail_layout.gd` 增 `name_tail_lift_delta` / `subtag_world_y` 照抄 `ArrangeTextTail` :211/:228/:258/:283 —— 三子标签屏幕 y 全锚在已被 `UpdateShowingTextTail :114` 顶过的 `pTextTail->y` 上,`net_world` `SUBTAG_DEFAULT_Y` 常量 + `_reposition_name_tag` 顶完名字尾标后按同一 `lift` 平移 `GuildTag` / `TitleTag` / `LevelTag`;增量 121 落地横向:`name_x_shift`(fxAdd 8/4/0)/ `title_x_offset` / `level_x_offset` / `subtag_world_x` 照抄 `ArrangeTextTail :211-320` 非 EUROPE 分支,`net_world._layout_subtags_x` 在 `_apply_text_tail_to` 尾部设名字 / `TitleTag` / `LevelTag` 的 `.position.x`、`GuildTag` 恒 0;增量 123 落地文本宽:`text_metrics.gd` 纯静态第 30 个 canonical `text_size` / `width_of` / `label_width`(`Font.get_string_size` = `CGraphicTextInstance::GetTextSize` 1:1),`_layout_subtags_x` 的 `name_w` / `title_w` 与 `ground_items._arrange_tags` 的名条框 `w` 改走 `TextMetrics.label_width`、`SUBTAG_CHAR_W_PX` / `TAG_CHAR_W_PX` 保留仅作无字体兜底;增量 124 落地单行文本高:`text_metrics.gd` 增 `FALLBACK_LINE_H_PX := 15.0` + `height_of` / `label_height`,`ground_items._arrange_tags` 的框高 `h` 改走 `TextMetrics.label_height`(= `GetTextSize` 的 h)、`TAG_LINE_H_PX` 保留仅作无字体兜底;seam ⑦ 剩余:无公会徽记 mark 实体 / 三子标签竖排叠 x 偏移 vs 同一屏幕行)、~~悬停 `chr.Pick()` 强显~~(增量 116 落地:`pick_show.gd` + `net_world._hover_vid` forced 第 5 路 + `player_controller.hover_entity_changed`)、~~屏幕空间 `ArrangeTextTail` 防重叠~~ 增量 120 落地:`text_tail_arrange.gd` 纯静态第 29 个 canonical `boxes_overlap`(`isIn`)+ `arrange_item_tails`(外层逐条 / 内层比对 / 命中顶到 `pCompareTextTail->y + yEnd + 5` / 从头重跑 / `MAX_ITERS` 20 封顶),`ground_items._arrange_tags` 每帧投影所有掉落名条、跑去重叠、按 `pixel_size` 折回局部 y,`game_scene` 传相机 getter;`CanPickInstance` 仅按死透近似(seam ⑩:名条框宽 / 单行文本高已分别由增量 123 / 124 换真实字体度量 `GetTextSize` 的 w / h 1:1、带 owner 的去重叠框已由增量 125 按 `SetItemTextTailOwner`(`PythonTextTail.cpp:706-737`)的「owner 独立度量 + `yEnd += yOwnerSize + 4`」换 1:1(`text_tail_arrange.gd` 加纯静态 `item_box_with_owner`、`text_metrics.gd` 加 `string_width_like` / `string_height_like`、`ground_items._on_added` 存 `item_name` / `item_owner` meta 后 `_arrange_tags` 把名字子串与 owner + `'s` 子串分别度量),剩余:owner 未独立渲染成 CENTER 黄字 / `strOwnership` 未接本地化语素、只对掉落 item 名条)、伤害飘字单 `Label3D` 写整串 vs `damagevalue` dds 逐位特效 / self 双资源交替 / 全局单队列(seam ⑪)、目标框 poc 无 building / wooden-door 实体类型(`is_building` 恒 false、木门分类缺)/ ~~`LCONTROL` 悄悄话捷径未接~~(增量 122 已接:`net_play._on_pick` KIND_PC 分支轮询 `whisper_on_ctrl_click` → `whisper_requested` → `game_scene` → `chat.start_whisper`;`game_scene` `KEY_CTRL` 喂 `_lcontrol_down`)/ `target_info` 由 C++ EntityStore 侧抽出不改 C++ 收包(seam ⑫ 末尾:门实体分类、独立 whisper dialog 窗口) | -| P1 | 🟡 | Actor 战斗运行时 | 有骨骼动画、基础 combo / 相机抖动;受击不再用固定硬直秒数(增量 73 删除 `_hitstun_until`),「能否行动」统一走 `_can_process_network_state()` / `_can_attack()` / `_can_use_skill()`(对齐 `InstanceBase.cpp:1247` / `InstanceBaseBattle.cpp:355` / `:310`),击退态由受击 `.msa` 播放生命周期驱动、动作播完清零;动作切换按 Granny `EaseInCurve(0,0,1,1)` 的 `2w²−w³` 逐骨 crossfade;角色 mesh 支持距离 LOD、15% 迟滞和约 0.18s 新旧 mesh 淡入(`LodGhost` + `lod_fade` uniform);右手武器 / 左手盾按 `weaponComposite[0] = weaponInvWorld[0]·weaponLocal[0]` grip 复合挂 `equip_right_hand`(探针实测 Δ≤0.06 m,非标准米级偏移文件夹断回退);`.msa` motion event 已分派声音、EffectPosition 特效、震屏、屏闪和弓箭 FLY(增量 86:`type 6` FIFO `_pending_shots` → `client.shoot()` = `OnShoot`,`OnSetFlyTarget` 在 `net_play._emit_swing` 发 `client.add_fly_targeting()`;WARP 留钩子);玩家 / 怪物强制实时投影并附脚底接触阴影 Decal;点击 / 攻击距离按目标类型分 `150 / 500 / 100` cm(增量 74,对齐 `InstanceBaseBattle.cpp:294`);普攻节奏改由 `.msa` `ComboInputData.DirectInputTime`(`get_motion_data().next_combo`,缺组退化 `duration*0.9`)除以 `bAttackSpeed/100` 得出,`attack_speed` 只缩放动作播放速率不再写死间隔;自动攻击走 `ReservedMode` 状态机(增量 75):`_update_auto_attack()` + `_reserve_process_click_actor()` 严格 10 步(照 `PythonPlayer.cpp:207` / `PythonPlayerInput.cpp:877`:出距离 `pc.walk_to` 靠近、NPC 走 `click_npc`、`IN_SAFE` / `DEST_IN_SAFE` / `EMPTY_ARROW` 经 `signal cannot_act`、第 10 步按节奏 `_do_attack_swing`) | PC 连击段表已解析(增量 80,`formats/combo_table.{h,cpp}` 从 `playersettingmodule.py` 的 `ReserveComboAttackNew` / `RegisterComboAttackNew` 建 `map>`,非 `.msm`),并已接进 `net_play.gd` 连击状态机替掉 `(_combo + 1) % 3`(增量 81:`_do_attack_swing` / `_run_next_combo` / `_combo_process` / `_on_end_combo` / `_clear_combo` 逐行对齐 `ActorInstanceBattle.cpp:157/213/256/297/308`,按 `.msa` `ComboInputData` 三时间驱动 `_combo_index` / `_is_pre_input` / `_is_next_pre_input`,段号经新静态绑定 `Metin2AnimPlayer.parse_combo_tables()` 按 `race & 3` 取);`.msa` `HitDataContainer` 多命中窗已导出(增量 82:`formats/msa.{h,cpp}` `Msa::HitWindow` + `get_motion_data().hit_windows` / `motion_type` / `hit_limit_count`,`textscript.cpp` 补 `List` 解析,含真实双命中窗 `assassin/dualhand_sword/combo_01.msa`);命中帧已落地(增量 83:`net_play.gd` `_begin_hit_windows` / `_attack_process(dt)` / `_hit_deduped` / `_register_hit` / `_hit_geometry` / `_flush_victim_list` 逐帧按动作时间与 `hit_window` [start,end] 相交对 `client.get_entities()` 可攻击 / 未死 / 非安全区实体做几何判定,`m_HitDataMap` 每窗查重 + 命中上限 NORMAL/COMBO 16 / SKILL `hit_limit_count`,命中 → `client.attack(_swing_skill, vid)`(`OnHit`),被击退目标帧末 `client.sync_positions()` ≤16;`_emit_swing` 改为只有缺命中窗数据才在挥击瞬间发 `CG_ATTACK`);武器种类 → `combo_motion_mode` 映射已落地(增量 84:`net_play.gd` `motion_mode_for()` 逐分支复刻 `CInstanceBase::RefreshState` 的 `SetMotionMode`(`InstanceBase.cpp:2851`),`_refresh_motion_mode()` 读 `parts[WEAPON]` / `get_equipment()[WEAR_WEAPON].vnum` + `proto.item().{type,sub_type}` + `mount_vnum` / `polymorph` / `wedding_dress`,由 `_on_entity_info` / `inventory_changed` / `mount_changed` 三处触发,换模式内部 `_clear_combo()`;`combat_fx_test` 加 `FakeProto` + 15 段映射 + 2 段端到端断言);弓箭 `__GetBowRange()` 已落地(增量 85:`_clickable_distance()` 里 `_is_bow_mode()` 改用 `_bow_range_cm()` = `BOW_RANGE_BASE_CM`(2500-100) + `_bow_distance_bonus`(`POINT_BOW_DISTANCE`=34,`_on_points()` 读 `points["bow_distance"]`),NPC/采集物分支覆盖之,逐分支对齐 `NEW_IsClickableDistanceDestInstance` + `__GetBowRange`;`combat_fx_test` §3.5 段加 8 段断言);剩余:几何判定当前是 `reach`(max WeaponLength / 采样水平偏移)+ 正面 ±60° 弧近似,真实动态 Z 圆柱 vs 防御球扫掠随 §3.1 Actor 碰撞一起补;坐骑移速规则、`POINT_BOW_DISTANCE` 真服下发路径和服务端合法性校准;技能合法性判定位数据层已落地(增量 87:`ui/skill_table.gd` 按 `RegisterSkillDesc` 把 skilldesc 第 11/12 列折成 `attr_bits` / `need_weapon_bits` + `skill_type`,`IsCanUseSkill` / `IsStandingSkill` / `IsHorseSkill` / `IsNeedTarget` / `IsNeedCorpse` / `CanChangeDirection` / `IsChargeSkill` / `CanUseWeaponType` 等谓词逐条对齐 `CPythonSkill`);三层校验已落地(增量 88:`project/player_skill.gd` `extends RefCounted` 纯校验器逐行移植 `PythonPlayerSkill.cpp` 的 (a)`click_skill_slot`(`:106`)/ (b)`can_use_skill`(`:439`,硬编码骑乘术 `109`)/ (c)`check_skill_usable`(`:166` 完整前置顺序)/ (d)`use_skill`(`:460` + `_resolve_target` 目标矩阵),返回 `{ok, code, target_vid}`,`net_play.skill_context()` 注入运行期状态,`ui/quickbar.gd activate()` 施放前跑三层、非静默码 → 信号 `skill_rejected` → `net_play.cannot_act`(同一 `OnCannotUseSkill` 通道),新增 `player_skill_test.gd`);边界收紧 + 短蓝短血校验已落地(增量 89,§3.8 修改 3 → 三层全落地:`net_bounds.h` `SKILL_MAX_NUM=255` + `skill_index()` 谓词,`M2Client::use_skill()` 的 `skill_id` 护栏收紧成 `!bounds::skill_index()` 对齐 `ClickSkillSlot` 的 `slot >= SKILL_MAX_NUM` 门;`skill_table.gd` 解析 `.msk` `NeedSPFormula` + `_SKILL_POWER[]` 功率表 → `need_sp` / `need_hp` / `_skill_point`;`player_skill.gd` `_check_short_mana` / `_check_short_life` 从桩改为移植 `__CheckShortMana`(`:320`)/ `__CheckShortLife`(`:354`),HP/SP 经 `net_play.skill_context()` 读 `get_points()` 供入、`-1` 不判);技能升级门已落地(增量 90,§3.10:`wire.h` +`POINT_SKILL=28` / `POINT_SUB_SKILL=27` / `POINT_HORSE_SKILL=113`,`M2Client::get_points()` 透出 `skill_active` / `skill_support` / `skill_horse`,`skill_up()` 入参收紧为 `!bounds::skill_index()`;`skill_table.gd` +`max_level_of` / `skill_level_limit`(本 40250 数据无 `LEVEL_LIMIT` 列 / `.msk` 记号 → 恒 0)/ `can_level_up_skill(id, level)`(= `skillCanLevelUpSkill`,本 build 前置技能 / 前置属性字段无人填充 → 化简为 `level < GetMaxLevel() && !CANNOT_LEVEL_UP`);`skill_ui.gd` `refresh()` 逐行移植 `uicharacter.py` 的 `RefreshSkillPlusButtonList` / `__RefreshSkillPlusButton` / `CanShowPlusButton`——技能组门 `get_skill_group()!=0` + 三技能点来源分开取 + `skillGrade==0` + 马术页 `level<20` + 主动页 `can_level_up_skill` + 辅助页 `HIDE_SUPPORT_SKILL_POINT` 整页隐;按钮 `_rebuild()` 建 / `refresh()` 切 `visible`,升级结果由 `GC_SKILL_LEVEL` 经 `skills_changed` 驱动、不本地伪造;`skill_test.gd` +`§3.10` 段);`ReservedMode` 的 CLICK_ITEM / CLICK_POSITION / USE_SKILL 分派已落地(增量 91:`_refresh_reserved()` 四路 `match` 逐行对齐 `NEW_RefreshMouseWalkingDirection`,含 CLICK_ITEM 的 20cm 拾取阈值、CLICK_POSITION 的 0.1s ReservedDelay 门、USE_SKILL 的 `IS_HUGE_RACE` −200cm 修正 + `use_skill_hook` 进射程施法;`__ChangeTargetToPickedInstance` 拾取回退 = `IsTargetableInstance` 限 PC/怪、否则清目标;扇形 / 圆形补 fly-targeting = `send_fly_targeting()` 经 `_instances_in_fan_range` / `_instances_in_circle_range` + `_random_pos_in_fan_range` 补 `CG_ADD_FLY_TARGETING`,几何逐行对齐 `NEW_GetInstanceVectorInFanRange` / `InCircleRange`);安全区 / 弓箭 / 骑乘 / dash 判定位的服务端来源、`IS_HUGE_RACE` vnum 白名单(现用 `proto.mob` `size>=3` / `rank>=4` 近似)仍留桩;另补 combo 取消窗口、精确受击 / 击退、角色碰撞、同步碰撞、武器拖尾、剩余 motion event / 骨骼挂点语义、GPU skin LOD、双持 / 左手武器和目标锁定相机;`03150` 类偏移网格需 per-weapon 偏移数据或修 .gr2 | +| P1 | 🟡 | Actor 战斗运行时 | 有骨骼动画、基础 combo / 相机抖动;受击不再用固定硬直秒数(增量 73 删除 `_hitstun_until`),「能否行动」统一走 `_can_process_network_state()` / `_can_attack()` / `_can_use_skill()`(对齐 `InstanceBase.cpp:1247` / `InstanceBaseBattle.cpp:355` / `:310`),击退态由受击 `.msa` 播放生命周期驱动、动作播完清零;动作切换按 Granny `EaseInCurve(0,0,1,1)` 的 `2w²−w³` 逐骨 crossfade;角色 mesh 支持距离 LOD、15% 迟滞和约 0.18s 新旧 mesh 淡入(`LodGhost` + `lod_fade` uniform);右手武器 / 左手盾按 `weaponComposite[0] = weaponInvWorld[0]·weaponLocal[0]` grip 复合挂 `equip_right_hand`(探针实测 Δ≤0.06 m,非标准米级偏移文件夹断回退);`.msa` motion event 已分派声音、EffectPosition 特效、震屏、屏闪和弓箭 FLY(增量 86:`type 6` FIFO `_pending_shots` → `client.shoot()` = `OnShoot`,`OnSetFlyTarget` 在 `net_play._emit_swing` 发 `client.add_fly_targeting()`;WARP 留钩子);玩家 / 怪物强制实时投影并附脚底接触阴影 Decal;点击 / 攻击距离按目标类型分 `150 / 500 / 100` cm(增量 74,对齐 `InstanceBaseBattle.cpp:294`);普攻节奏改由 `.msa` `ComboInputData.DirectInputTime`(`get_motion_data().next_combo`,缺组退化 `duration*0.9`)除以 `bAttackSpeed/100` 得出,`attack_speed` 只缩放动作播放速率不再写死间隔;自动攻击走 `ReservedMode` 状态机(增量 75):`_update_auto_attack()` + `_reserve_process_click_actor()` 严格 10 步(照 `PythonPlayer.cpp:207` / `PythonPlayerInput.cpp:877`:出距离 `pc.walk_to` 靠近、NPC 走 `click_npc`、`IN_SAFE` / `DEST_IN_SAFE` / `EMPTY_ARROW` 经 `signal cannot_act`、第 10 步按节奏 `_do_attack_swing`) | PC 连击段表已解析(增量 80,`formats/combo_table.{h,cpp}` 从 `playersettingmodule.py` 的 `ReserveComboAttackNew` / `RegisterComboAttackNew` 建 `map>`,非 `.msm`),并已接进 `net_play.gd` 连击状态机替掉 `(_combo + 1) % 3`(增量 81:`_do_attack_swing` / `_run_next_combo` / `_combo_process` / `_on_end_combo` / `_clear_combo` 逐行对齐 `ActorInstanceBattle.cpp:157/213/256/297/308`,按 `.msa` `ComboInputData` 三时间驱动 `_combo_index` / `_is_pre_input` / `_is_next_pre_input`,段号经新静态绑定 `Metin2AnimPlayer.parse_combo_tables()` 按 `race & 3` 取);`.msa` `HitDataContainer` 多命中窗已导出(增量 82:`formats/msa.{h,cpp}` `Msa::HitWindow` + `get_motion_data().hit_windows` / `motion_type` / `hit_limit_count`,`textscript.cpp` 补 `List` 解析,含真实双命中窗 `assassin/dualhand_sword/combo_01.msa`);命中帧已落地(增量 83:`net_play.gd` `_begin_hit_windows` / `_attack_process(dt)` / `_register_hit` / `_flush_victim_list` 逐帧按动作时间与 `hit_window` [start,end] 相交对 `client.get_entities()` 可攻击 / 未死 / 非安全区实体做判定,增量 130 起为 `_normal_attack_process` 的刀尖 Z 圆柱 vs `.msm` 防御球逐行判定,`m_HitDataMap` 每窗查重 + 命中上限 NORMAL/COMBO 16 / SKILL `hit_limit_count`,命中 → `client.attack(_swing_skill, vid)`(`OnHit`),被击退目标帧末 `client.sync_positions()` ≤16;`_emit_swing` 改为只有缺命中窗数据才在挥击瞬间发 `CG_ATTACK`);武器种类 → `combo_motion_mode` 映射已落地(增量 84:`net_play.gd` `motion_mode_for()` 逐分支复刻 `CInstanceBase::RefreshState` 的 `SetMotionMode`(`InstanceBase.cpp:2851`),`_refresh_motion_mode()` 读 `parts[WEAPON]` / `get_equipment()[WEAR_WEAPON].vnum` + `proto.item().{type,sub_type}` + `mount_vnum` / `polymorph` / `wedding_dress`,由 `_on_entity_info` / `inventory_changed` / `mount_changed` 三处触发,换模式内部 `_clear_combo()`;`combat_fx_test` 加 `FakeProto` + 15 段映射 + 2 段端到端断言);弓箭 `__GetBowRange()` 已落地(增量 85:`_clickable_distance()` 里 `_is_bow_mode()` 改用 `_bow_range_cm()` = `BOW_RANGE_BASE_CM`(2500-100) + `_bow_distance_bonus`(`POINT_BOW_DISTANCE`=34,`_on_points()` 读 `points["bow_distance"]`),NPC/采集物分支覆盖之,逐分支对齐 `NEW_IsClickableDistanceDestInstance` + `__GetBowRange`;`combat_fx_test` §3.5 段加 8 段断言);命中判定与命中效果已逐行对齐(增量 130:`hit_collision.gd` `detect_z_cylinder` + `.msm` 防御球;`_process_attack_success` 做 `InsertDelay` 硬直、`physics_push.gd` 击退 + `GetBlendingPosition` 同步、命中特效、`__HitGood` / `__HitGreate` / `__HitStone` 受击动作链与抖动;攻速同时缩放 `GetAttackingElapsedTime` 与动作速率;`combo_changed` → `SetComboSkillFlag`;`motion_bound` 清 `m_HitDataMap` / 连击段号;`send_use_skill` 不再夹带 `CG_FLY_TARGETING`);剩余:击退无地形 / 角色碰撞,防御球未跟骨骼矩阵,远端角色不跑 AttackProcess;坐骑移速规则、`POINT_BOW_DISTANCE` 真服下发路径和服务端合法性校准;技能合法性判定位数据层已落地(增量 87:`ui/skill_table.gd` 按 `RegisterSkillDesc` 把 skilldesc 第 11/12 列折成 `attr_bits` / `need_weapon_bits` + `skill_type`,`IsCanUseSkill` / `IsStandingSkill` / `IsHorseSkill` / `IsNeedTarget` / `IsNeedCorpse` / `CanChangeDirection` / `IsChargeSkill` / `CanUseWeaponType` 等谓词逐条对齐 `CPythonSkill`);三层校验已落地(增量 88:`project/player_skill.gd` `extends RefCounted` 纯校验器逐行移植 `PythonPlayerSkill.cpp` 的 (a)`click_skill_slot`(`:106`)/ (b)`can_use_skill`(`:439`,硬编码骑乘术 `109`)/ (c)`check_skill_usable`(`:166` 完整前置顺序)/ (d)`use_skill`(`:460` + `_resolve_target` 目标矩阵),返回 `{ok, code, target_vid}`,`net_play.skill_context()` 注入运行期状态,`ui/quickbar.gd activate()` 施放前跑三层、非静默码 → 信号 `skill_rejected` → `net_play.cannot_act`(同一 `OnCannotUseSkill` 通道),新增 `player_skill_test.gd`);边界收紧 + 短蓝短血校验已落地(增量 89,§3.8 修改 3 → 三层全落地:`net_bounds.h` `SKILL_MAX_NUM=255` + `skill_index()` 谓词,`M2Client::use_skill()` 的 `skill_id` 护栏收紧成 `!bounds::skill_index()` 对齐 `ClickSkillSlot` 的 `slot >= SKILL_MAX_NUM` 门;`skill_table.gd` 解析 `.msk` `NeedSPFormula` + `_SKILL_POWER[]` 功率表 → `need_sp` / `need_hp` / `_skill_point`;`player_skill.gd` `_check_short_mana` / `_check_short_life` 从桩改为移植 `__CheckShortMana`(`:320`)/ `__CheckShortLife`(`:354`),HP/SP 经 `net_play.skill_context()` 读 `get_points()` 供入、`-1` 不判);技能升级门已落地(增量 90,§3.10:`wire.h` +`POINT_SKILL=28` / `POINT_SUB_SKILL=27` / `POINT_HORSE_SKILL=113`,`M2Client::get_points()` 透出 `skill_active` / `skill_support` / `skill_horse`,`skill_up()` 入参收紧为 `!bounds::skill_index()`;`skill_table.gd` +`max_level_of` / `skill_level_limit`(本 40250 数据无 `LEVEL_LIMIT` 列 / `.msk` 记号 → 恒 0)/ `can_level_up_skill(id, level)`(= `skillCanLevelUpSkill`,本 build 前置技能 / 前置属性字段无人填充 → 化简为 `level < GetMaxLevel() && !CANNOT_LEVEL_UP`);`skill_ui.gd` `refresh()` 逐行移植 `uicharacter.py` 的 `RefreshSkillPlusButtonList` / `__RefreshSkillPlusButton` / `CanShowPlusButton`——技能组门 `get_skill_group()!=0` + 三技能点来源分开取 + `skillGrade==0` + 马术页 `level<20` + 主动页 `can_level_up_skill` + 辅助页 `HIDE_SUPPORT_SKILL_POINT` 整页隐;按钮 `_rebuild()` 建 / `refresh()` 切 `visible`,升级结果由 `GC_SKILL_LEVEL` 经 `skills_changed` 驱动、不本地伪造;`skill_test.gd` +`§3.10` 段);`ReservedMode` 的 CLICK_ITEM / CLICK_POSITION / USE_SKILL 分派已落地(增量 91:`_refresh_reserved()` 四路 `match` 逐行对齐 `NEW_RefreshMouseWalkingDirection`,含 CLICK_ITEM 的 20cm 拾取阈值、CLICK_POSITION 的 0.1s ReservedDelay 门、USE_SKILL 的 `IS_HUGE_RACE` −200cm 修正 + `use_skill_hook` 进射程施法;`__ChangeTargetToPickedInstance` 拾取回退 = `IsTargetableInstance` 限 PC/怪、否则清目标;扇形 / 圆形补 fly-targeting = `send_fly_targeting()` 经 `_instances_in_fan_range` / `_instances_in_circle_range` + `_random_pos_in_fan_range` 补 `CG_ADD_FLY_TARGETING`,几何逐行对齐 `NEW_GetInstanceVectorInFanRange` / `InCircleRange`);安全区 / 弓箭 / 骑乘 / dash 判定位的服务端来源、`IS_HUGE_RACE` vnum 白名单(现用 `proto.mob` `size>=3` / `rank>=4` 近似)仍留桩;另补 combo 取消窗口、`IsResistFallen` / 击退碰撞、角色碰撞、同步碰撞、武器拖尾、剩余 motion event / 骨骼挂点语义、GPU skin LOD、双持 / 左手武器和目标锁定相机;`03150` 类偏移网格需 per-weapon 偏移数据或修 .gr2 | | P1 | 🟡 | 飞行物与命中特效 | `GC_FLY_TARGETING` / `GC_ADD_FLY_TARGETING` 保存 shooter 的目标队列和 VID / 坐标;~~`GC_CREATE_FLY` 可消费队列并生成 tween 发光球~~ → **增量 92:`project/fly_object.gd` 的 `FlyManager` / `FlyInstance` / `FlyData` 对齐 `GameLib/FlyingObjectManager.{h,cpp}` + `FlyingInstance.{h,cpp}`——`Update()` 逐帧真实弹道(`vel += accel*dt` + `gravity`)+ 线段-点命中判定(`_seg_point_sq_dist` 1:1 移植 `EterLib/GrpMath.h:80` `square_distance_between_linesegment_and_point` < `m_fBombRange²`)+ `OnShootDamage` / pierce `--` 续飞 / 超程 `__Explode(false)` / `m_bHitOnBackground` 触地;`FlyData` 默认值取 `FlyingData.cpp __Initialize()`;`net_world._on_fly` 换成 `_fly_mgr.spawn(...)` + `signal fly_exploded`。`net_play._can_shot()` 防御式重建 `__CanShot`(`grep` 全树无定义)——非弓 / 无箭 `EMPTY_ARROW` / 无目标 / 死目标 / 出弓程 `OUT_OF_RANGE`,`_emit_swing` 弓分支据此跳过 `CG_FLY_TARGETING` / `OnShoot`** | seam:`GC_CREATE_FLY` 在本 40250 客户端无包处理器(仅 `NetStream.cpp` 头串索引 70)→ 实际驱动走 POC 自有 `fly_cue`;重力参考端作用于 `.z`=上、本 POC 映射到 `-Y`;怪物球体碰撞 / `CullingMgr.ForInRange` / 建筑体素 r=1 判定未移植(客户端飞行物只做命中"时机",伤害服务端裁决);无 `.fly` 数据文件加载器 → 合成箭参数(`init_vel=45` / `flat_range=max(dist*1.5,30)` / `bomb_range=0.6`);`FIRE_CRACKER` / `AUTO_FIRE` 的 `CreateIndexedFly` 分支已在常量层预留但本 build 无驱动来源;bomb effect / 声音 / EffectLib 特效仍是占位闪光 | | P1 | 🟡 | 任务 / NPC | 增量 95 已完成 `CPythonEventManager` 常用标签解析 / 分派;增量 101 完成 Quest 日志 `QUEST_MAX_NUM=5` 五行窗口、离散滚动、默认 / 服务端 icon、clock 和任务行事件;增量 108 完成小地图任务信标越界夹圆周 + 指向目标的方向箭头(世界箭头 / 屏幕边缘指示首版,`minimap.signal_marker_geometry` + `_draw_signal_arrow`);增量 109 完成 quest 信件按钮条 `interfacemodule.__ArrangeQuestButton` 多列定位(`yCount=(H-330)//63`、换列 +100px、最新在前、组队窗 `xPos`→130);`SelectItemUI`、QuestButton、地图信号和对话图片已有首版 | 逐帧 `UpdateEventSet`、Skip / EndEventProcess、`.msc`、NEXT / DONE 文案、相机 / fade / cinema 消费端、CJK IME、任务日志窗体 `_list` 自身分页 / 多列、Atlas 同一信标夹边、完整 `.sub` 图标、立绘 / 电影、真实资源和真服仍待验收 | | P1 | 🟡 | 商店 / 交易 / 仓库 | 普通 NPC 商店、交易状态和仓库列表已有首版;**`SHOP_GC_START_EX` 多货架已解析(`m_shop_tabs`,`get_shop()`),`shop_ui` 带 5×8/40 格货位、货架按钮 + `tabIdx*40+slot` 买位置、空位禁用和 BUY 确认(固定 count=1)**;增量 56 增加仓库 5×9 分页 / 指定格 / 仓库内移动、交易 12 格指定投放和商店 BUY/SELL toggle / 出售投放区;增量 57 补齐出售 anti-flag、首次贵重物品确认、普通 / `COUNT_PER_1GOLD` 价格公式和失败音效;增量 58 接入统一物品 tooltip;增量 60 交易物品状态补齐 sockets[3] / attrs[7] | 补交易事务回滚提示、仓库金钱 / 改密码、完整音效和 `coin_type` 非金币货币显示;真实 `.sub` / 图标和真服回包仍待验收 | @@ -2293,9 +2308,11 @@ Python 脚本;文档中出现的 `assets/root/*.py` 均属于本工程迁移 SKILL `hit_limit_count`),命中 → `client.attack(_swing_skill, vid)`(`OnHit`→`SendAttackPacket`, 普攻 `_swing_skill=0`),被击退目标帧末 `_flush_victim_list()` → `client.sync_positions()`(≤16, 对齐 `PythonPlayerEventHandler.cpp:194 FlushVictimList`)。`_emit_swing()` 因此改为只有拿不到 - 命中窗数据(headless / 缺资源)才在挥击瞬间立即发 `CG_ATTACK`。几何判定为近似: + 命中窗数据(headless / 缺资源)才在挥击瞬间立即发 `CG_ATTACK`。~~几何判定为近似: `reach = max(WeaponLength, 窗内采样最大水平偏移)` + 正面 `±60°` 弧(真实动态圆柱扫掠需骨骼矩阵 - + defending sphere,随 §3.1 actor 碰撞一起留桩)。`combat_fx_test.gd` 加 `FakeNetWorld` + 6 段断言。 + + defending sphere,随 §3.1 actor 碰撞一起留桩)。~~ **增量 130** 已换成 + `HitCollision.detect_z_cylinder` 刀尖 Z 圆柱 vs `.msm` 防御球,并补全 + `__ProcessDataAttackSuccess`:硬直、击退、命中特效、受击动作链、抖动。`combat_fx_test.gd` 加 `FakeNetWorld` + 6 段断言。 ~~**剩余(增量 84)**:武器种类 → `combo_motion_mode` 映射(当前恒 `MOTION_MODE_GENERAL`)。~~ —— **增量 84 已落地**:`net_play.gd` 补全 `MOTION_MODE_*`(`CRaceMotionData::EMode` 全 17 项) + `ITEM_TYPE_WEAPON/ROD/PICK` + `WEAPON_SUB_*` 常量,新增静态 diff --git a/extension/src/net/classic/classic_session.cpp b/extension/src/net/classic/classic_session.cpp index b68e9a2b..e8736a1b 100644 --- a/extension/src/net/classic/classic_session.cpp +++ b/extension/src/net/classic/classic_session.cpp @@ -796,12 +796,8 @@ bool ClassicSession::send_use_skill(uint32_t skill_vnum, uint32_t target_vid) { if (m_stage != Stage::InGame) { return false; } - if (target_vid) { - CGFlyTargeting ft{}; - ft.header = HDR_CG_FLY_TARGETING; - ft.target_vid = target_vid; - m_stream.send_fixed(&ft, sizeof(ft)); - } + // PythonNetworkStreamPhaseGame.cpp SendUseSkillPacket: only TPacketCGUseSkill. + // Fly targets are sent by the skill caller (CG_ADD_FLY_TARGETING / OnSetFlyTarget). CGUseSkill p{}; p.header = HDR_CG_USE_SKILL; p.vnum = skill_vnum; diff --git a/extension/tests/net_classic_session_test.cpp b/extension/tests/net_classic_session_test.cpp index 76d00915..b93af6ee 100644 --- a/extension/tests/net_classic_session_test.cpp +++ b/extension/tests/net_classic_session_test.cpp @@ -1185,6 +1185,16 @@ int main() { out.back() == SEQUENCE_TABLE[seq], "guild invite answer wire"); } + // --- CG_USE_SKILL: PythonNetworkStreamPhaseGame.cpp SendUseSkillPacket sends + // only the use-skill packet; fly-targeting is a separate caller decision --- + { + uint32_t seq = s.stream().sequence_index(); + CHECK(s.send_use_skill(3, 8888), "use skill send"); + auto out = drain(s); + CHECK(out.size() == sizeof(CGUseSkill) + 1 && out[0] == HDR_CG_USE_SKILL && + out.back() == SEQUENCE_TABLE[seq], "use skill wire: no extra fly targeting"); + } + // --- GC_CHAR_ADDITIONAL_INFO(136) with no matching pending GC_CHARACTER_ADD // is dropped and leaves the live entity untouched (§2.2) --- { diff --git a/project/combat_fx_test.gd b/project/combat_fx_test.gd index 8703c382..91c8623d 100644 --- a/project/combat_fx_test.gd +++ b/project/combat_fx_test.gd @@ -19,7 +19,9 @@ class FakeClient extends Node: signal affect_added(affect: Dictionary) signal affect_removed(type: int) signal phase_changed(phase: String) + signal combo_changed(enabled: bool) var main := 1000 + var skills := [] var ents := {1000: {"vid": 1000, "hp": 100, "dead": false}, 2000: {"vid": 2000, "hp": 50, "dead": false, "pos": Vector3(1, 0, 0)}} var attacks := [] var moves := [] @@ -40,10 +42,38 @@ class FakeClient extends Node: func move(a, b, c, d, e) -> bool: moves.append([a, b, c, d, e]); return true func sync_positions(arr) -> bool: synced.append(arr); return true func say(t, s) -> bool: said.append([t, s]); return true + func get_skills() -> Array: return skills class FakeNetWorld extends Node: var nodes := {} + var pushes := [] # [vid, dir(actor-world xy), force] + var pushing := {} func node_for(vid: int) -> Node3D: return nodes.get(vid, null) + func push_victim(vid: int, dir: Vector2, force: float) -> void: + pushes.append([vid, dir, force]) + pushing[vid] = true + func is_pushing(vid: int) -> bool: return bool(pushing.get(vid, false)) + func blending_position(vid: int) -> Vector3: return nodes[vid].global_position + Vector3(0.1, 0, 0) + +# 受击方视图桩:.msm 防御球 + InsertDelay + __Hit* 反应 +class FakeVictim extends Node3D: + var spheres := [{"radius": 45.0, "pos": Vector3(0, 0, 100), "bone": ""}] + var delays := [] + var reactions := [] + func get_defending_spheres() -> Array: return spheres + func insert_delay(t: float) -> void: delays.append(t) + func hit_good(scalar: float, _stunned: bool) -> void: reactions.append(["good", scalar]) + func hit_greate(scalar: float, _stunned: bool) -> void: reactions.append(["greate", scalar]) + func hit_stone(_stunned: bool) -> void: reactions.append(["stone", 0.0]) + +class FakeFx extends Node: + var spawned := [] # [name, global_pos, node] + func spawn_at(n: String, parent: Node3D, pos: Vector3, _one_shot := true) -> Node3D: + var e := Node3D.new() + parent.add_child(e) + e.global_position = pos + spawned.append([n, pos, e]) + return e class FakeProto extends Node: var items := {} @@ -61,11 +91,18 @@ class FakeAnim extends Node: func get_motion_data() -> Dictionary: return md class FakeView extends Node3D: + signal motion_bound(state: String) var states := [] + var attack_motions := [] # [motion_mode, motion_index, speed_ratio] + var delays := [] var anim := FakeAnim.new() func _init() -> void: add_child(anim) func set_anim_state(s): states.append(s) + func play_attack_motion(mode: int, index: int, speed: float) -> void: + attack_motions.append([mode, index, speed]) + motion_bound.emit("attack") + func insert_delay(t: float) -> void: delays.append(t) class FakeHud extends Node: var affects := [] @@ -77,6 +114,19 @@ func _ck(c: bool, m: String) -> void: _fail += 1 printerr("FAIL: " + m) +# 命中小节:每个场景前清掉命中表 / 无敌时间 / 防御球上一帧缓存 / 硬直与各桩记录 +func _reset_hit(np: Node, fc: Node, nw: Node, fx: Node) -> void: + np._hit_dedup.clear() + np._victim_invisible_until.clear() + np._def_last.clear() + np._delay = 0.0 + np._victim_flush.clear() + fc.attacks.clear() + fc.synced.clear() + nw.pushes.clear() + nw.pushing.clear() + fx.spawned.clear() + func _init() -> void: await _run() if _fail == 0: @@ -156,8 +206,49 @@ func _run() -> void: _ck(is_equal_approx(np._atk_speed_factor, 1.5), "attack_speed 150 -> factor 1.5") _ck(is_equal_approx(np._current_attack_period(), np.DEFAULT_ATTACK_PERIOD / 1.5), "attack speed scales the resolved period") + # CInstanceBase::SetAttackSpeed:uAtkSpd > 1100 -> 0;否则 /100,无上下限 + np._apply_attack_speed(400) + _ck(is_equal_approx(np._atk_speed_factor, 4.0), "attack_speed 400 -> factor 4.0 (no clamp)") + np._apply_attack_speed(1200) + _ck(np._atk_speed_factor == 0.0, "attack_speed > 1100 -> factor 0") + _ck(is_finite(np._current_attack_period()), "factor 0 -> attack period stays finite") np._apply_attack_speed(100) + # --- CGraphicThingInstance::InsertDelay / UpdateTime:本地时间 --- + np._local_time = 5.0 + np._delay = 0.05 + np._advance_local_time(0.02) + _ck(is_equal_approx(np._local_time, 5.0) and is_equal_approx(np._delay, 0.03), + "delay > elapsed -> local time frozen, delay shrinks") + np._advance_local_time(0.05) + _ck(is_equal_approx(np._local_time, 5.02) and np._delay == 0.0, + "delay < elapsed -> local time advances by the remainder") + np._apply_attack_speed(200) + np._motion_start_t = np._local_time - 0.1 + _ck(is_equal_approx(np._attacking_elapsed(), 0.2), + "GetAttackingElapsedTime = (local - start) * fSpeedRatio") + np._apply_attack_speed(100) + var lt0: float = np._local_time + np._process(0.016) + _ck(np._local_time > lt0, "_process advances the local time") + + # --- SetComboSkillFlag(PythonPlayerSkill.cpp):GC 连击开关 -> SetComboType(MIN(lv,2)) --- + fc.skills = [{"id": 122, "level": 3, "master": 0}] + fc.combo_changed.emit(true) + _ck(np._combo_type == 2, "combo on + skill 122 lv3 -> combo type MIN(3,2)=2, got %d" % np._combo_type) + fc.combo_changed.emit(false) + _ck(np._combo_type == 0, "combo off -> combo type 0") + fc.skills = [{"id": 122, "level": 1, "master": 0}] + fc.combo_changed.emit(true) + _ck(np._combo_type == 1, "combo on + skill 122 lv1 -> combo type 1") + fc.skills = [{"id": 122, "level": 0, "master": 0}] + fc.combo_changed.emit(false) + _ck(np._combo_type == 1, "skill 122 level 0 -> return before SetComboType") + fc.skills = [] + fc.combo_changed.emit(false) + _ck(np._combo_type == 1, "no skill 122 slot -> return before SetComboType") + np._combo_type = 0 + # --- §3.5 连击状态机:InputComboAttackCommand / __RunNextCombo / ComboProcess --- # 合成一张 4 段的 1H type1 段表(段号 14..17),并给 player_view 一个可控 .msa。 var K: int = (np.MOTION_MODE_GENERAL << 16) | 0 @@ -181,101 +272,271 @@ func _run() -> void: _ck(fc.attacks.size() == 1 and fc.attacks[0][0] == 0, "CG_ATTACK bType is skill 0, not combo seg") _ck(fc.moves.size() >= 1 and fc.moves.back()[0] == np.FUNC_COMBO and fc.moves.back()[1] == 14, "FUNC_COMBO carries seg no. 14 (NAME_COMBO_ATTACK_1)") + _ck(view.attack_motions.size() == 1 and view.attack_motions[0] == [np.MOTION_MODE_GENERAL, 14, 1.0], + "swing binds the seg motion (mode, index 14, speed ratio 1.0), got %s" % [view.attack_motions]) # 立刻再输入(elapsed ~0 < pre_input_time):既不推进也不置 pre-input np._do_attack_swing(pcn, {}) _ck(np._combo_index == 1 and not np._is_pre_input, "input before InputStartTime -> ignored") - # elapsed 落在 [start, next):置 m_isPreInput,不推进 - np._combo_started_t = np._now() - 0.15 + # elapsed 落在 [start, next):置 m_isPreInput,不推进(GetAttackingElapsedTime 基于本地时间) + np._motion_start_t = np._local_time - 0.15 np._do_attack_swing(pcn, {}) _ck(np._combo_index == 1 and np._is_pre_input, "input in [start,next) -> pre-input latched") # ComboProcess:elapsed 过了 NextComboTime -> 触发挂起的 pre-input -> 段 2 - np._combo_started_t = np._now() - 0.25 + np._motion_start_t = np._local_time - 0.25 np._combo_process() _ck(np._combo_index == 2 and not np._is_pre_input, "ComboProcess fires pre-input -> combo index 2") # elapsed 过 NextComboTime 直接输入 -> 立即推进(段 3、段 4) - np._combo_started_t = np._now() - 0.30 + np._motion_start_t = np._local_time - 0.30 np._do_attack_swing(pcn, {}) _ck(np._combo_index == 3, "input past NextComboTime -> advance to 3") - np._combo_started_t = np._now() - 0.30 + np._motion_start_t = np._local_time - 0.30 np._do_attack_swing(pcn, {}) _ck(np._combo_index == 4, "advance to last seg 4") # 段 4 是最后一段:__OnEndCombo(非骑乘不复位),再输入越界不推进 - np._combo_started_t = np._now() - 0.30 + np._motion_start_t = np._local_time - 0.30 np._do_attack_swing(pcn, {}) _ck(np._combo_index == 4, "past last seg -> no further advance (non-horse)") - # 动作回到 Wait(elapsed > duration)-> __ClearCombo - np._combo_started_t = np._now() - 1.5 + # 参考端 ComboProcess 没有「超过整段时长就清连击」:elapsed > duration 段号保持 + np._motion_start_t = np._local_time - 1.5 np._combo_process() - _ck(np._combo_index == 0 and not np._is_pre_input, "motion back to Wait -> __ClearCombo") + _ck(np._combo_index == 4, "ComboProcess has no duration timeout -> combo index kept, got %d" % np._combo_index) + # __SetMotion 尾:新绑定的动作没有 ComboInputData(回 Wait)-> m_dwcurComboIndex = 0 + view.anim.md = {"duration": 2.0} + view.motion_bound.emit("wait") + _ck(np._combo_index == 0, "bound motion without ComboInputData -> combo index 0") + # __SetMotion 尾:绑定带 MotionAttackData 的动作 -> m_HitDataMap.clear() + np._hit_dedup = {0: {2000: 1.0}} + view.anim.md = {"has_attacking_data": true, "has_combo_input": true, "duration": 1.0} + view.motion_bound.emit("attack") + _ck(np._hit_dedup.is_empty(), "bound attacking motion -> hit data map cleared") - # 没有段表(缺资源)-> 退化为单段普攻(段号 13) + # 没有段表(缺资源)-> 退化为单段普攻(段号 13);攻速系数原样传给动作播放 np._combo_tables = {} fc.moves.clear() + np._apply_attack_speed(150) np._do_attack_swing(pcn, {}) _ck(np._combo_index == 0 and fc.moves.back()[0] == np.FUNC_COMBO \ and fc.moves.back()[1] == np.NAME_NORMAL_ATTACK, "no combo table -> single NORMAL_ATTACK swing") + _ck(view.attack_motions.back() == [np.MOTION_MODE_GENERAL, np.NAME_NORMAL_ATTACK, 1.5], + "attack motion plays at fSpeedRatio 1.5, got %s" % [view.attack_motions.back()]) + np._apply_attack_speed(100) - # --- §3.5 修改 4:命中窗几何判定 -> OnHit + FlushVictimList --- + # --- __NormalAttackProcess 的 m_HitDataMap:新窗 insert;旧窗 map::insert 不覆盖 + 每窗上限 --- + np._hit_dedup.clear() + np._hit_motion_type = np.MOTION_TYPE_SKILL + np._hit_limit_count = 2 + np._hit_invisible_time = 0.5 + np._local_time = 10.0 + _ck(np._register_hit(0, 1) and is_equal_approx(float(np._hit_dedup[0][1]), 10.5), + "first hit of a window -> insert local + fInvisibleTime, process") + np._local_time = 11.0 + _ck(np._register_hit(0, 1) and is_equal_approx(float(np._hit_dedup[0][1]), 10.5), + "victim already in the window -> map::insert keeps the old time, count 1 <= limit") + _ck(np._register_hit(0, 2), "second victim -> count 2 <= iHitLimitCount 2") + _ck(not np._register_hit(0, 3), "third victim -> count 3 > iHitLimitCount -> FALSE") + np._hit_limit_count = 0 + np._hit_dedup.clear() + _ck(np._register_hit(0, 1), "SKILL: first hit of a window skips the limit check") + _ck(not np._register_hit(0, 1), "SKILL iHitLimitCount 0 -> next hit in that window 1 > 0 -> FALSE") + np._hit_motion_type = np.MOTION_TYPE_COMBO + for i in 16: + np._register_hit(1, 100 + i) + _ck(not np._register_hit(1, 200), "COMBO/NORMAL: 17th victim in one window -> > 16 -> FALSE") + np._hit_dedup.clear() + + # --- §3.5 修改 4 / §3.7:__NormalAttackProcess 扫掠球 vs .msm 防御球 -> __ProcessDataAttackSuccess --- # SceneTree 脚本里 add_child 到 root 的节点要等一帧才真正 is_inside_tree(), # 几何判定读 global_position,所以这一小节先 await 一帧。 var nw := FakeNetWorld.new(); get_root().add_child(nw) np.net_world = nw - var vnode := Node3D.new(); get_root().add_child(vnode) + var fx := FakeFx.new(); get_root().add_child(fx) + var fx_parent := Node3D.new(); get_root().add_child(fx_parent) + np.fx = fx + np.fx_parent = fx_parent + var vnode := FakeVictim.new(); get_root().add_child(vnode) nw.nodes[2000] = vnode - fc.ents[2000] = {"vid": 2000, "ch_type": 2, "hp": 50, "dead": false, "knock_down": true} + fc.ents[2000] = {"vid": 2000, "ch_type": 2, "race": 101, "hp": 50, "dead": false, "owner_vid": 1000} fc.ents[1000]["dead"] = false await process_frame - vnode.global_position = Vector3(0, 0, 1.0) # 攻击者正前方 1 m(yaw 0 -> 前向 +Z) + vnode.global_position = Vector3(0, 0, 1.0) # 攻击者正前方 1 m(yaw 0:模型 -Y = Godot +Z) + vnode.rotation.y = 0.0 pcn.global_position = Vector3.ZERO pcn.rotation.y = 0.0 + # 一个采样:t=0.1 时刀尖球从模型本地 (-60,-80) 横扫到 (60,-80),高 1 m view.anim.md = { + "has_attacking_data": true, "hit_windows": [{ - "start_time": 0.0, "end_time": 0.5, "bone": "equip_right_hand", - "weapon_length": 120.0, "samples": [], + "start_time": 0.0, "end_time": 0.5, "bone": "equip_right_hand", "weapon_length": 120.0, + "samples": [{"time": 0.1, "last_pos": Vector3(-60, -80, 100), "pos": Vector3(60, -80, 100)}], }], - "motion_type": np.MOTION_TYPE_COMBO, "hit_limit_count": 0, "invisible_time": 0.1, - "next_combo": 0.2, + "motion_type": np.MOTION_TYPE_COMBO, "hitting_type": np.HIT_TYPE_GOOD, "hit_limit_count": 0, + "invisible_time": 0.1, "stiffen_time": 0.05, "external_force": 3.0, "next_combo": 0.2, } np._combo_tables = {} - fc.attacks.clear() - fc.synced.clear() + np._target_vid = 0 + view.delays.clear() + _reset_hit(np, fc, nw, fx) np._do_attack_swing(pcn, {}) # _emit_swing:有命中窗 -> CG_ATTACK 不在挥击时发 _ck(fc.attacks.size() == 0, "hit windows present -> CG_ATTACK deferred off the swing") _ck(np._hit_windows.size() == 1, "swing cached the .msa hit window") - np._swing_start_t = np._now() - 0.1 # 推进到命中窗内 - np._process(0.016) - _ck(fc.attacks.size() == 1 and fc.attacks[0] == [0, 2000], - "in-window + in front arc -> OnHit CG_ATTACK(skill 0, vid 2000)") - _ck(fc.synced.size() == 1 and fc.synced[0][0]["vid"] == 2000, - "pushed victim -> frame-end CG_SYNC_POSITION") - np._swing_start_t = np._now() - 0.2 - np._process(0.016) + # 采样时刻 0.1 不在 [motiontime - elapsed, motiontime] 内 -> 不判定 + np._motion_start_t = np._local_time - 0.2 + np._attack_process(0.016) + _ck(fc.attacks.is_empty(), "no .msa sample inside [t-dt, t] -> no hit") + np._motion_start_t = np._local_time - 0.105 + np._attack_process(0.016) + _ck(fc.attacks == [[0, 2000]], "sweep sphere crosses the defending sphere -> OnHit CG_ATTACK(0, 2000), got %s" % [fc.attacks]) + _ck(np._target_vid == 2000, "OnHit -> SetTarget(victim)") + _ck(is_equal_approx(np._delay, 0.05) and view.delays == [0.05], "attacker InsertDelay(fStiffenTime), got %s" % [view.delays]) + _ck(vnode.delays == [0.05], "victim InsertDelay(fStiffenTime), got %s" % [vnode.delays]) + _ck(float(np._victim_invisible_until.get(2000, 0.0)) > np._now(), "victim m_fInvisibleTime = now + fInvisibleTime") + _ck(vnode.reactions == [["good", 1.0]], "HIT_TYPE_GOOD -> __HitGood(scalar cos(0) = 1), got %s" % [vnode.reactions]) + _ck(fx.spawned.size() == 1 and fx.spawned[0][0] == np.EFFECT_HIT \ + and Vector3(fx.spawned[0][1]).is_equal_approx(vnode.global_position), + "blow_1_low.mse spawned at the victim position, got %s" % [fx.spawned]) + if fx.spawned.size() == 1: + _ck(absf(absf((fx.spawned[0][2] as Node3D).rotation.y) - PI) < 0.01, "hit effect yaw = atan2(-dx, -dz) = ±π") + _ck(nw.pushes.size() == 1 and nw.pushes[0][0] == 2000 and Vector2(nw.pushes[0][1]).is_equal_approx(Vector2(0, -1)) \ + and is_equal_approx(float(nw.pushes[0][2]), 3.0), + "__PushCircle + IncreaseExternalForce(3) along victim - attacker (actor-world (0,-1)), got %s" % [nw.pushes]) + np._flush_victim_list() + var bxy := MapCoord.to_server_cm(vnode.global_position + Vector3(0.1, 0, 0)) + _ck(fc.synced.size() == 1 and fc.synced[0][0]["vid"] == 2000 and fc.synced[0][0]["x"] == int(bxy.x) \ + and fc.synced[0][0]["y"] == int(bxy.y), + "pushing victim -> frame-end CG_SYNC_POSITION at its blending position, got %s" % [fc.synced]) + # COMBO:同窗同目标只一次(先把受击方无敌时间去掉,单独验 m_HitDataMap) + np._victim_invisible_until.clear() + np._attack_process(0.016) _ck(fc.attacks.size() == 1, "COMBO motion_type -> same window/victim hits once only") - vnode.global_position = Vector3(0, 0, -1.0) # 转到身后 + # AttackingProcess:rVictim.__isInvisible() -> FALSE np._hit_dedup.clear() - np._swing_start_t = np._now() - 0.2 - np._process(0.016) - _ck(fc.attacks.size() == 1, "victim behind attacker -> outside front arc -> no hit") + np._def_last.clear() + np._victim_invisible_until[2000] = np._now() + 5.0 + np._attack_process(0.016) + _ck(fc.attacks.size() == 1, "victim still inside m_fInvisibleTime -> no hit") + + # __CanPushDestActor:owner 不是攻击者 / owner 超 3 秒 -> 不推;晕眩 -> 推 + _reset_hit(np, fc, nw, fx) + fc.ents[2000]["owner_vid"] = 0 + np._attack_process(0.016) + np._flush_victim_list() + _ck(fc.attacks.size() == 1 and nw.pushes.is_empty() and fc.synced.is_empty(), + "victim not owned by the attacker -> hit, no push, no CG_SYNC_POSITION") + _reset_hit(np, fc, nw, fx) + fc.ents[2000]["owner_vid"] = 1000 + np._owner_seen[2000] = [1000, np._now() - 3.5] + np._attack_process(0.016) + _ck(fc.attacks.size() == 1 and nw.pushes.is_empty(), "owner time > 3 s -> no push") + _reset_hit(np, fc, nw, fx) + fc.ents[2000]["stunned"] = true + np._attack_process(0.016) + _ck(nw.pushes.size() == 1, "stunned victim -> always pushable") + fc.ents[2000]["stunned"] = false + np._owner_seen.erase(2000) + + # HIT_TYPE_NONE:__ProcessDataAttackSuccess 直接 return(不硬直、不发包);GREAT -> __HitGreate + _reset_hit(np, fc, nw, fx) + view.delays.clear() + vnode.delays.clear() + vnode.reactions.clear() + np._hit_type = np.HIT_TYPE_NONE + np._attack_process(0.016) + _ck(fc.attacks.is_empty() and view.delays.is_empty() and vnode.delays.is_empty() and fx.spawned.is_empty(), + "HIT_TYPE_NONE -> no delay / effect / OnHit") + _reset_hit(np, fc, nw, fx) + np._hit_type = np.HIT_TYPE_GREAT + np._attack_process(0.016) + _ck(vnode.reactions.size() == 1 and vnode.reactions[0][0] == "greate", "HIT_TYPE_GREAT -> __HitGreate, got %s" % [vnode.reactions]) + np._hit_type = np.HIT_TYPE_GOOD + + # 距离怪癖:v3Distance = (dX, dZ, dZ) —— 平面 Y 不参与,高度差算两次 + _reset_hit(np, fc, nw, fx) + vnode.global_position = Vector3(3.5, 0, 1.0) + vnode.spheres = [{"radius": 45.0, "pos": Vector3(-350, 0, 100), "bone": ""}] + np._attack_process(0.016) + _ck(fc.attacks.is_empty(), "|dX| 350 >= 300 -> distance reject although the spheres touch") + _reset_hit(np, fc, nw, fx) + vnode.global_position = Vector3(0, 0, 3.5) + vnode.spheres = [{"radius": 45.0, "pos": Vector3(0, 270, 100), "bone": ""}] + np._attack_process(0.016) + _ck(fc.attacks.size() == 1, "|dY| 350 is not part of the distance check -> hit") + _reset_hit(np, fc, nw, fx) + vnode.global_position = Vector3(0, 2.2, 1.0) + vnode.spheres = [{"radius": 45.0, "pos": Vector3(0, 0, 100), "bone": ""}] + np._attack_process(0.016) + _ck(fc.attacks.is_empty(), "height 2.2 m -> 2 * 220² >= 300² -> reject") + _reset_hit(np, fc, nw, fx) + vnode.global_position = Vector3(0, 2.0, 1.0) + np._attack_process(0.016) + _ck(fc.attacks.size() == 1, "height 2.0 m -> 2 * 200² < 300², Z cylinder ignores height -> hit") + + # IS_HUGE_RACE:500 cm 距离、不推、特效在 (攻击者 + 防御球) * 0.5 + _reset_hit(np, fc, nw, fx) + fc.ents[2000]["race"] = 2493 + vnode.global_position = Vector3(4.0, 0, 1.0) + vnode.spheres = [{"radius": 45.0, "pos": Vector3(-400, 0, 100), "bone": ""}] + np._attack_process(0.016) + np._flush_victim_list() + _ck(fc.attacks.size() == 1 and nw.pushes.is_empty() and fc.synced.is_empty(), + "IS_HUGE_RACE: 400 cm < 500 -> hit, no push, no sync") + _ck(fx.spawned.size() == 1 and Vector3(fx.spawned[0][1]).is_equal_approx(Vector3(0, 0.5, 0.5)), + "IS_HUGE_RACE: effect at (attacker + defending sphere) * 0.5, got %s" % [fx.spawned]) + fc.ents[2000]["race"] = 101 + + # 防御球随受击方朝向旋转;__HitGood 的 scalar = cos(攻击者朝向 - 受击方朝向) + _reset_hit(np, fc, nw, fx) + vnode.reactions.clear() + vnode.global_position = Vector3(0, 0, 1.6) + vnode.spheres = [{"radius": 25.0, "pos": Vector3(0, -60, 100), "bone": ""}] + vnode.rotation.y = 0.0 + np._attack_process(0.016) + _ck(fc.attacks.is_empty(), "victim yaw 0 -> defending sphere 220 cm ahead -> miss") + _reset_hit(np, fc, nw, fx) + vnode.rotation.y = PI + np._attack_process(0.016) + _ck(fc.attacks.size() == 1, "victim yaw π -> sphere swings to 100 cm -> hit") + _ck(vnode.reactions.size() == 1 and is_equal_approx(float(vnode.reactions[0][1]), -1.0), + "__HitGood scalar = cos(0 - π) = -1, got %s" % [vnode.reactions]) + vnode.rotation.y = 0.0 + + # 身后:扫掠球够不到 + _reset_hit(np, fc, nw, fx) + vnode.global_position = Vector3(0, 0, -1.0) + vnode.spheres = [{"radius": 45.0, "pos": Vector3(0, 0, 100), "bone": ""}] + np._attack_process(0.016) + _ck(fc.attacks.is_empty(), "victim behind the attacker -> sweep misses") + + # 占位胶囊(无 .msm):退化防御球 + var ph := Node3D.new(); get_root().add_child(ph) + await process_frame + ph.global_position = Vector3(0, 0, 1.0) + nw.nodes[2000] = ph + _reset_hit(np, fc, nw, fx) + np._attack_process(0.016) + _ck(fc.attacks.size() == 1, "node without defending spheres -> fallback sphere -> hit") + nw.nodes[2000] = vnode vnode.global_position = Vector3(0, 0, 1.0) - np._swing_start_t = np._now() - 2.0 # 动作越过所有命中窗 - np._process(0.016) + + np._motion_start_t = np._local_time - 2.0 # 动作越过所有命中窗 + np._attack_process(0.016) _ck(np._hit_windows.is_empty(), "motion past all windows -> hit windows cleared") # 目标已死:挥击命中窗内也不发 CG_ATTACK(死亡目标不是有效命中) fc.ents[2000]["dead"] = true np._do_attack_swing(pcn, {}) - np._hit_dedup.clear() - np._swing_start_t = np._now() - 0.1 - np._process(0.016) - _ck(fc.attacks.size() == 1, "dead victim inside the hit window -> no CG_ATTACK") + _reset_hit(np, fc, nw, fx) + np._motion_start_t = np._local_time - 0.105 + np._attack_process(0.016) + _ck(fc.attacks.is_empty(), "dead victim inside the hit window -> no CG_ATTACK") fc.ents[2000]["dead"] = false np.net_world = null + np.fx = null # --- §3.5 修改 6:武器种类 -> combo_motion_mode(RefreshState 的 SetMotionMode 分支)--- # 纯映射表:CItemData 类型/子类型 + 骑乘/变身 -> CRaceMotionData::EMode diff --git a/project/equip_model_test.gd b/project/equip_model_test.gd index 33173db3..701d46ab 100644 --- a/project/equip_model_test.gd +++ b/project/equip_model_test.gd @@ -15,7 +15,8 @@ class FakeClient extends Node: for i in 11: equip.append({"vnum": 0, "count": 0, "wear": i}) func get_equipment() -> Array: return equip func get_main_vid() -> int: return 1000 - func get_entity(_v) -> Dictionary: return {"race": 0, "parts": main_parts} + var mount_vnum := 0 + func get_entity(_v) -> Dictionary: return {"race": 0, "parts": main_parts, "mount_vnum": mount_vnum} func set_wear(idx, vnum): equip[idx] = {"vnum": vnum, "count": 1, "wear": idx} inventory_changed.emit(2, 90 + idx) # window EQUIPMENT @@ -36,6 +37,14 @@ class StubModel extends Node3D: "hair_skin": hair_skin = val; return true return false +# item_proto 桩:只给武器 subtype(WEAPON_DAGGER=1 BOW=2 FAN=5,item_length.h EWeaponSubTypes) +class FakeProto extends Node: + const SUB := {19: 0, 2000: 2, 4000: 1, 7000: 5} + func item(vnum: int) -> Dictionary: + if SUB.has(vnum): + return {"type": 1, "sub_type": SUB[vnum]} + return {} + var _fail := 0 func _ck(c: bool, m: String) -> void: if not c: @@ -43,7 +52,7 @@ func _ck(c: bool, m: String) -> void: printerr("FAIL: " + m) func _init() -> void: - _run() + await _run() if _fail == 0: print("PASS: equip_model_test (item_list + weapon swap)") quit(0) @@ -97,15 +106,50 @@ func _run() -> void: await process_frame _ck(model.gr2_path == "d:/ymir work/pc/warrior/warrior.gr2", "armor_model_map -> gr2_path set") - # 盾(WEAR_SHIELD=10)-> shield_gr2 = item_list.model 解析 - # 用一个真存在的武器 vnum 当盾(资产里 00010.gr2 存在) - fc.set_wear(10, 19) + # 左右手:ActorInstanceAttach.cpp AttachWeapon + # __IsRightHandWeapon: DAGGER / (FAN && 骑马) -> 右;BOW -> 否;其余 -> 右 + # __IsLeftHandWeapon : DAGGER / (FAN && 骑马) / BOW -> 左(GetSubModelThing = 同一 gr2) + # shield_gr2 槽 = PART_WEAPON_LEFT;参考端不渲染 WEAR_SHIELD(盾在 item_list 没模型)。 + var fch := FakeClient.new() + var mh := StubModel.new() + var fp := FakeProto.new() + get_root().add_child(fch) + get_root().add_child(mh) + get_root().add_child(fp) + var emh: Node = EquipModel.new() + get_root().add_child(emh) + emh.setup(fch, il, func() -> Node: return mh, assets) + emh.main_getter = func() -> int: return 1000 + emh.proto = fp + var sword_p: String = emh._resolve_weapon(19) + var bow_p: String = emh._resolve_weapon(2000) + var dagger_p: String = emh._resolve_weapon(4000) + var fan_p: String = emh._resolve_weapon(7000) + _ck(sword_p != "" and bow_p != "" and dagger_p != "" and fan_p != "", "weapon gr2 resolvable (19/2000/4000/7000)") + fch.set_wear(4, 19) await process_frame - print(" shield_gr2 -> '", model.shield_gr2, "'") - _ck(model.shield_gr2 == "" or model.shield_gr2.ends_with("00010.gr2"), "WEAR_SHIELD -> shield_gr2") - fc.set_wear(10, 0) + _ck(mh.weapon_gr2 == sword_p and mh.shield_gr2 == "", "sword -> right hand only (%s | %s)" % [mh.weapon_gr2, mh.shield_gr2]) + fch.set_wear(4, 4000) await process_frame - _ck(model.shield_gr2 == "", "unequip shield -> shield_gr2 cleared") + _ck(mh.weapon_gr2 == dagger_p and mh.shield_gr2 == dagger_p, "dagger -> both hands (%s | %s)" % [mh.weapon_gr2, mh.shield_gr2]) + fch.set_wear(4, 2000) + await process_frame + _ck(mh.weapon_gr2 == "" and mh.shield_gr2 == bow_p, "bow -> left hand only (%s | %s)" % [mh.weapon_gr2, mh.shield_gr2]) + fch.set_wear(4, 7000) + await process_frame + _ck(mh.weapon_gr2 == fan_p and mh.shield_gr2 == "", "fan on foot -> right hand only (%s | %s)" % [mh.weapon_gr2, mh.shield_gr2]) + fch.mount_vnum = 20030 + fch.entity_info.emit(1000, {}) + await process_frame + _ck(mh.weapon_gr2 == fan_p and mh.shield_gr2 == fan_p, "fan mounted -> both hands (%s | %s)" % [mh.weapon_gr2, mh.shield_gr2]) + fch.mount_vnum = 0 + fch.set_wear(4, 19) + fch.set_wear(10, 19) # WEAR_SHIELD 不渲染 + await process_frame + _ck(mh.shield_gr2 == "", "WEAR_SHIELD is not rendered (%s)" % mh.shield_gr2) + fch.set_wear(4, 0) + await process_frame + _ck(mh.weapon_gr2 == "" and mh.shield_gr2 == "", "unequip -> both hands cleared") # 头盔(WEAR_HEAD=1)有模型 -> 覆盖 hair_gr2;拆下回角色发型(parts[3]=5) var em2: Node = EquipModel.new() @@ -139,6 +183,11 @@ func _run() -> void: # 11000 "Wolf Armour" -> values[3] == 0 -> 回退 =vnum _ck(em3._armor_shape_default(11000) == 11000, "armor 11000 -> values[3]=0 回退 =vnum") _ck(em3._armor_specular(11209) == 100, "armor 11209(+9) -> specular 100") + # FakeProto 的 subtype 与真 item_proto 一致 + for v in FakeProto.SUB: + var it: Dictionary = pr.item(v) + _ck(int(it.get("type", -1)) == 1 and int(it.get("sub_type", -1)) == FakeProto.SUB[v], + "item_proto %d -> WEAPON sub_type %d (got %s/%s)" % [v, FakeProto.SUB[v], it.get("type"), it.get("sub_type")]) # 时装 / parts[] 优先:服务器把 costume vnum 写进主角 parts[ARMOR],压过装备槽 var seen := {"body": -1, "hair": ""} @@ -174,7 +223,7 @@ func _run() -> void: fc.equip[10] = {"vnum": 19, "count": 1, "wear": 10} # 有盾 fc.main_parts = [88888, 0, 0, 5] # parts[ARMOR] -> shape 101 model.weapon_gr2 = "x"; model.shield_gr2 = "x"; model.hair_gr2 = "x" - em5._last_weapon_vnum = -1; em5._last_shield_vnum = -1 + em5._last_weapon_vnum = -1 em5._last_body_vnum = -1; em5._last_head_vnum = -2 em5.refresh() _ck(model.weapon_gr2 == "", "动物时装 -> weapon_gr2 清空 (%s)" % model.weapon_gr2) @@ -182,7 +231,7 @@ func _run() -> void: _ck(model.hair_gr2 == "", "动物时装 -> hair_gr2 清空 (%s)" % model.hair_gr2) # 脱下动物时装 -> 武器恢复解析(资产里 00010.gr2 可能不存在,至少不再是遮挡态) fc.main_parts = [0, 0, 0, 5] - em5._last_weapon_vnum = -999; em5._last_shield_vnum = -999 + em5._last_weapon_vnum = -999 em5._last_body_vnum = -1; em5._last_head_vnum = -2 em5.refresh() _ck(model.weapon_gr2 == "" or model.weapon_gr2.ends_with("00010.gr2"), diff --git a/project/game_camera.gd b/project/game_camera.gd index b44547e1..a2ae6770 100644 --- a/project/game_camera.gd +++ b/project/game_camera.gd @@ -144,7 +144,7 @@ func _pinch_dist() -> float: return (_touches[ks[0]] as Vector2).distance_to(_touches[ks[1]] as Vector2) func heading() -> float: - # 相机看向的水平方向(供角色移动「相对相机」用) + # 相机方位角:相机在 head + (sin yaw, cos yaw) 一侧,视线水平前方是 -(sin yaw, cos yaw)。 return yaw func is_event_locked() -> bool: diff --git a/project/game_scene.gd b/project/game_scene.gd index 01606747..7e1fda6c 100644 --- a/project/game_scene.gd +++ b/project/game_scene.gd @@ -273,6 +273,10 @@ func setup(m2client: Node, assets_root: String, # 特效注册表(P5)—— GC_SPECIFIC_EFFECT(.mse 路径) / GC_SPECIAL_EFFECT(内建 id) 在实体上播 fx = EffectRegistry.new() fx.setup(_assets) + # §3.7 __ProcessDataAttackSuccess:命中特效(m_dwBattleHitEffectID)挂在实体挂载点下播 + if net_play: + net_play.fx = fx + net_play.fx_parent = _mount # 技能(P6)—— K 键技能窗,数字键 1-4 → 快捷栏 0-3 / F1-F4 → 4-7 / Ctrl+数字 = 表情,+ 技能特效表 if assets_root != "": @@ -280,6 +284,10 @@ func setup(m2client: Node, assets_root: String, for lang in ["en", "common"]: if skill_table.load_file(assets_root.path_join("locale/locale/%s/skilldesc.txt" % lang)): break + # LoadLocaleData:RegisterSkillDesc 之后 RegisterSkillTable(技能射程 TARGET_RANGE)。 + for lang in ["en", "common"]: + if skill_table.load_table(assets_root.path_join("locale/locale/%s/skilltable.txt" % lang)): + break skill_fx = SkillFx.new() skill_fx.setup(fx, skill_table) skills = SkillUI.new() diff --git a/project/gamescene_test.gd b/project/gamescene_test.gd index 8c24670f..071eb73e 100644 --- a/project/gamescene_test.gd +++ b/project/gamescene_test.gd @@ -71,6 +71,10 @@ func _run() -> void: _ck(gs.pc != null, "player_controller built") _ck(gs.net_world != null, "net_world built") _ck(gs.net_play != null, "net_play built") + # §3.7 命中特效 m_dwBattleHitEffectID:net_play 用场景的特效注册表,挂在实体挂载点下 + _ck(gs.net_play != null and gs.fx != null and gs.net_play.fx == gs.fx \ + and gs.net_play.fx_parent == gs.get_node_or_null("Entities"), + "net_play.fx / fx_parent wired to the scene effect registry + entity mount") _ck(gs.hud != null, "hud built") _ck(gs.get_node_or_null("AppLifecycle") == null, "GameScene does not create a second lifecycle coordinator") diff --git a/project/gpu_pose_bounds_test.gd b/project/gpu_pose_bounds_test.gd index 9e395833..af54410a 100644 --- a/project/gpu_pose_bounds_test.gd +++ b/project/gpu_pose_bounds_test.gd @@ -24,6 +24,9 @@ func run() -> void: for motion in ["wait", "walk", "run", "attack", "damage", "dead"]: for i in 2: OS.set_environment("MTGODOT_GPUSKIN", str(i)) + # damage has weighted variants (damage / damage_1, CRaceData::GetMotionKey); + # the same seed makes the CPU and GPU views pick the same clip. + seed(race) views[i].set_anim_state(motion) views[i].anim.set("loop", false) for fraction in [0.0, 0.25, 0.5, 0.75, 1.0]: diff --git a/project/hit_collision.gd b/project/hit_collision.gd new file mode 100644 index 00000000..c57c2b6b --- /dev/null +++ b/project/hit_collision.gd @@ -0,0 +1,160 @@ +# HitCollision —— CLIENT-GAP §3.5 修改 2:`CActorInstance::__NormalAttackProcess` 用到的碰撞几何, +# 逐行移植(单位 = Metin2 cm,Z 为高度): +# IntersectLineSegments / FindNearestPointOnLineSegment / FindNearestPointOfParallelLineSegments / +# AdjustNearestPoints(EterLib/lineintersect_utils.cpp,MY_EPSILON 0.1) +# DetectCollisionDynamicZCylinderVSDynamicZCylinder(GameLib/GameUtil.cpp) +# .msm AttachingData CollisionType 3 = m_DefendingPointInstanceList 的防御球 +extends RefCounted + +const MY_EPSILON := 0.1 +const COLLISION_TYPE_DEFENDING := 3 # NRaceData::COLLISION_TYPE_DEFENDING +const HUGE_RACE_VNUM := 2493 # IS_HUGE_RACE(InstanceBase.cpp) + +static var _msm_cache := {} + +# FindNearestPointOnLineSegment:|L|² < ε² 时 Nearest = A1,parameter 保持调用方原值。 +# 返回 [Nearest, parameter]。 +static func _nearest_on_segment(a1: Vector3, l: Vector3, b: Vector3, parameter: float) -> Array: + var d := l.length_squared() + if d < MY_EPSILON * MY_EPSILON: + return [a1, parameter] + var p := clampf((b - a1).dot(l) / d, 0.0, 1.0) + return [a1 + p * l, p] + +static func _out_of_range(a: float) -> bool: + return a < 0.0 or a > 1.0 + +# FindNearestPointOfParallelLineSegments(s[] 已被 FindNearestPointOnLineSegment 夹到 [0,1], +# 所以前两个分支在出货代码里实际走不到;照抄保留)。 +static func _parallel(a1: Vector3, a2: Vector3, la: Vector3, b1: Vector3, b2: Vector3, lb: Vector3) -> Array: + var r0 := _nearest_on_segment(a1, la, b1, 0.0) + var out_a: Vector3 = r0[0] + var s0: float = r0[1] + var s1: float = _nearest_on_segment(a1, la, b2, 0.0)[1] + var out_b := Vector3.ZERO + if s0 < 0.0 and s1 < 0.0: + out_a = a1 + out_b = b2 if s0 < s1 else b1 + elif s0 > 1.0 and s1 > 1.0: + out_a = a2 + out_b = b1 if s0 < s1 else b2 + else: + var temp := 0.5 * (clampf(s0, 0.0, 1.0) + clampf(s1, 0.0, 1.0)) + out_a = a1 + temp * la + out_b = _nearest_on_segment(b1, lb, out_a, temp)[0] + return [out_a, out_b] + +static func _adjust(a1: Vector3, la: Vector3, b1: Vector3, lb: Vector3, s: float, t: float) -> Array: + var out_a := a1 + s * la + var out_b := b1 + t * lb + var r: Array + if _out_of_range(s) and _out_of_range(t): + s = clampf(s, 0.0, 1.0) + out_a = a1 + s * la + r = _nearest_on_segment(b1, lb, out_a, t) + out_b = r[0] + t = r[1] + if _out_of_range(t): + t = clampf(t, 0.0, 1.0) + out_b = b1 + t * lb + r = _nearest_on_segment(a1, la, out_b, s) + out_a = r[0] + s = r[1] + r = _nearest_on_segment(b1, lb, out_a, t) + out_b = r[0] + elif _out_of_range(s): + s = clampf(s, 0.0, 1.0) + out_a = a1 + s * la + out_b = _nearest_on_segment(b1, lb, out_a, t)[0] + elif _out_of_range(t): + t = clampf(t, 0.0, 1.0) + out_b = b1 + t * lb + out_a = _nearest_on_segment(a1, la, out_b, s)[0] + return [out_a, out_b] + +# 返回 [OutA, OutB]:两段上的最近点对。 +static func intersect_line_segments(a1: Vector3, a2: Vector3, b1: Vector3, b2: Vector3) -> Array: + var la := a2 - a1 + var lb := b2 - b1 + var l11 := la.length_squared() + var l22 := lb.length_squared() + var eps2 := MY_EPSILON * MY_EPSILON + if l11 < eps2: + return [a1, _nearest_on_segment(b1, lb, a1, 0.0)[0]] + elif l22 < eps2: + return [_nearest_on_segment(a1, la, b1, 0.0)[0], b1] + var ab := b1 - a1 + var l12 := -la.dot(lb) + var det_l := l11 * l22 - l12 * l12 + if absf(det_l) < MY_EPSILON: + return _parallel(a1, a2, la, b1, b2, lb) + var ra := la.dot(ab) + # 出货代码是 +dot(Lb, AB)(旁注写的是负号)——两段都在动时 t 会反号,1:1 保留。 + var rb := lb.dot(ab) + var t := (l11 * rb - ra * l12) / det_l + var s := (ra - l12 * t) / l11 + if _out_of_range(s) or _out_of_range(t): + return _adjust(a1, la, b1, lb, s, t) + return [a1 + s * la, b1 + t * lb] + +# DetectCollisionDynamicZCylinderVSDynamicZCylinder:两个扫掠球都压到 z=0(高度不参与), +# 各自 AABB(按自身半径外扩)不相交直接 false,否则比较两段最近点距离与 r1+r2。 +static func detect_z_cylinder(c1_last: Vector3, c1_pos: Vector3, r1: float, + c2_last: Vector3, c2_pos: Vector3, r2: float) -> bool: + c1_last.z = 0.0 + c1_pos.z = 0.0 + c2_last.z = 0.0 + c2_pos.z = 0.0 + var r := r1 + r2 + var mi1 := Vector3(minf(c1_last.x, c1_pos.x), minf(c1_last.y, c1_pos.y), minf(c1_last.z, c1_pos.z)) - Vector3.ONE * r1 + var mi2 := Vector3(maxf(c1_last.x, c1_pos.x), maxf(c1_last.y, c1_pos.y), maxf(c1_last.z, c1_pos.z)) + Vector3.ONE * r1 + var mi3 := Vector3(minf(c2_last.x, c2_pos.x), minf(c2_last.y, c2_pos.y), minf(c2_last.z, c2_pos.z)) - Vector3.ONE * r2 + var mi4 := Vector3(maxf(c2_last.x, c2_pos.x), maxf(c2_last.y, c2_pos.y), maxf(c2_last.z, c2_pos.z)) + Vector3.ONE * r2 + if mi4.x < mi1.x or mi2.x < mi3.x: + return false + if mi4.y < mi1.y or mi2.y < mi3.y: + return false + if mi4.z < mi1.z or mi2.z < mi3.z: + return false + var o := intersect_line_segments(c1_last, c1_pos, c2_last, c2_pos) + return (Vector3(o[0]) - Vector3(o[1])).length_squared() <= r * r + +# IS_HUGE_RACE(InstanceBase.cpp):出货表只有 2493。 +static func is_huge_race(race: int) -> bool: + return race == HUGE_RACE_VNUM + +# 解析 .msm 的防御球:[{radius, pos: Vector3(cm, 模型本地), bone}]。bone 非空 = isAttaching +# 挂骨骼(本端按模型本地坐标近似,seam)。缺文件 -> []。按路径缓存。 +static func parse_msm_defending(path: String) -> Array: + if _msm_cache.has(path): + return _msm_cache[path] + var out := [] + if FileAccess.file_exists(path): + var f := FileAccess.open(path, FileAccess.READ) + var ctype := -1 + var bone := "" + var attaching := false + var radius := 0.0 + while f and not f.eof_reached(): + var parts := f.get_line().strip_edges().replace("\t", " ").split(" ", false) + if parts.is_empty(): + continue + var key := String(parts[0]).to_lower() + if key == "group" and parts.size() > 1 and String(parts[1]).to_lower().begins_with("attachingdata"): + ctype = -1 + bone = "" + attaching = false + elif key == "collisiontype" and parts.size() > 1: + ctype = int(parts[1]) + elif key == "isattaching" and parts.size() > 1: + attaching = int(parts[1]) != 0 + elif key == "attachingbonename" and parts.size() > 1: + bone = String(parts[1]).trim_prefix("\"").trim_suffix("\"") + elif key == "radius" and parts.size() > 1: + radius = float(parts[1]) + elif key == "position" and parts.size() > 3 and ctype == COLLISION_TYPE_DEFENDING: + out.append({"radius": radius, + "pos": Vector3(float(parts[1]), float(parts[2]), float(parts[3])), + "bone": bone if attaching else ""}) + _msm_cache[path] = out + return out diff --git a/project/hit_collision_test.gd b/project/hit_collision_test.gd new file mode 100644 index 00000000..8e5c0df6 --- /dev/null +++ b/project/hit_collision_test.gd @@ -0,0 +1,78 @@ +# hit_collision_test —— §3.5:__NormalAttackProcess 的碰撞几何 headless 自检。 +# IntersectLineSegments(EterLib/lineintersect_utils.cpp) +# DetectCollisionDynamicZCylinderVSDynamicZCylinder(GameLib/GameUtil.cpp) +# .msm AttachingData CollisionType 3 = m_DefendingPointInstanceList +# godot --headless --path project --script hit_collision_test.gd +extends SceneTree + +const HitCollision = preload("res://hit_collision.gd") + +var _fail := 0 +func _ck(c: bool, m: String) -> void: + if not c: + _fail += 1 + printerr("FAIL: " + m) + +func _init() -> void: + _run() + if _fail == 0: + print("PASS: hit_collision_test (segments / z-cylinder / msm defending)") + quit(0) + else: + printerr("%d check(s) failed" % _fail) + quit(1) + +func _near(a: Vector3, b: Vector3) -> bool: + return a.distance_to(b) < 0.01 + +func _run() -> void: + # --- IntersectLineSegments --- + # 静止受击方(L22 < ε²):OutB = B1,OutA = A 上离 B1 最近点。 + var r: Array = HitCollision.intersect_line_segments( + Vector3(-100, 0, 0), Vector3(100, 0, 0), Vector3(0, 50, 0), Vector3(0, 50, 0)) + _ck(_near(r[0], Vector3.ZERO) and _near(r[1], Vector3(0, 50, 0)), "stationary B -> nearest on A = (0,0,0)") + # 退化 A(L11 < ε²) + r = HitCollision.intersect_line_segments( + Vector3(10, 10, 0), Vector3(10, 10, 0), Vector3(0, 0, 0), Vector3(100, 0, 0)) + _ck(_near(r[0], Vector3(10, 10, 0)) and _near(r[1], Vector3(10, 0, 0)), "degenerate A -> OutB (10,0,0)") + # 平行(|DetL| < ε):两段 clamp 参数取中点 + r = HitCollision.intersect_line_segments( + Vector3(0, 0, 0), Vector3(100, 0, 0), Vector3(0, 50, 0), Vector3(100, 50, 0)) + _ck(_near(r[0], Vector3(50, 0, 0)) and _near(r[1], Vector3(50, 50, 0)), "parallel -> midpoints (50,0)/(50,50)") + # 一般情形,s/t 双越界 -> AdjustNearestPoints + r = HitCollision.intersect_line_segments( + Vector3(0, 0, 0), Vector3(100, 0, 0), Vector3(200, -50, 0), Vector3(200, 50, 0)) + _ck(_near(r[0], Vector3(100, 0, 0)) and _near(r[1], Vector3(200, 0, 0)), "both out of range -> (100,0)/(200,0)") + # 参考端 rb = +dot(Lb, AB)(注释里是负号):两条运动段十字相交时 t 被算成 -0.5, + # 经 AdjustNearestPoints 夹到 B1。1:1 保留这个出货行为。 + r = HitCollision.intersect_line_segments( + Vector3(-100, 0, 0), Vector3(100, 0, 0), Vector3(0, -100, 0), Vector3(0, 100, 0)) + _ck(_near(r[0], Vector3.ZERO) and _near(r[1], Vector3(0, -100, 0)), + "shipped rb sign: crossing moving segments -> OutB clamps to B1, got %s / %s" % [r[0], r[1]]) + + # --- DetectCollisionDynamicZCylinderVSDynamicZCylinder --- + # 攻击球扫过静止受击球:只看扫掠段,不看端点;z(高度)清零。 + _ck(HitCollision.detect_z_cylinder(Vector3(-200, 0, 500), Vector3(200, 0, 500), 20.0, + Vector3(0, 60, 0), Vector3(0, 60, 0), 70.0), "sweep passes 60cm from victim, r 20+70 -> hit (height ignored)") + _ck(not HitCollision.detect_z_cylinder(Vector3(-200, 0, 0), Vector3(200, 0, 0), 20.0, + Vector3(0, 100, 0), Vector3(0, 100, 0), 70.0), "sweep passes 100cm away -> no hit") + _ck(not HitCollision.detect_z_cylinder(Vector3(-200, 0, 0), Vector3(200, 0, 0), 20.0, + Vector3(400, 0, 0), Vector3(400, 0, 0), 70.0), "AABB reject -> no hit") + _ck(HitCollision.detect_z_cylinder(Vector3(0, 0, 0), Vector3(0, 0, 0), 20.0, + Vector3(80, 0, 900), Vector3(80, 0, 900), 70.0), "static spheres 80cm apart, r sum 90 -> hit") + + # --- .msm 防御球(CollisionType 3),CollisionType 1 = body 不算 --- + var assets := AssetRoot.path() + var wolf := assets.path_join("Monster/ymir work/monster/wolf/wolf_blue.msm") + var ds: Array = HitCollision.parse_msm_defending(wolf) + _ck(ds.size() == 1, "wolf_blue.msm -> 1 defending sphere, got %d" % ds.size()) + if ds.size() == 1: + _ck(is_equal_approx(float(ds[0].radius), 90.0) and _near(ds[0].pos, Vector3(0, -15, 80)), + "wolf defending sphere r90 (0,-15,80), got %s" % [ds[0]]) + var war := assets.path_join("root/msm/warrior_m.msm") + ds = HitCollision.parse_msm_defending(war) + _ck(ds.size() >= 1 and is_equal_approx(float(ds[0].radius), 70.0) and _near(ds[0].pos, Vector3(0, 0, 100)), + "warrior_m.msm defending sphere r70 (0,0,100), got %s" % [ds]) + _ck(HitCollision.parse_msm_defending(assets.path_join("no/such.msm")).is_empty(), "missing msm -> []") + # IS_HUGE_RACE(InstanceBase.cpp) + _ck(HitCollision.is_huge_race(2493) and not HitCollision.is_huge_race(101), "IS_HUGE_RACE: only 2493") diff --git a/project/hit_view_test.gd b/project/hit_view_test.gd new file mode 100644 index 00000000..c83e052e --- /dev/null +++ b/project/hit_view_test.gd @@ -0,0 +1,254 @@ +# hit_view_test —— CLIENT-GAP §3.7 受击方 / 攻击方视图(PlayerView / MobView)headless 自检: +# __HitGood / __HitGreate / __HitStone 动作链(ActorInstanceBattle.cpp:769-890)、__Shake、 +# InsertDelay(动作冻结)、攻击动作按 (motion mode, motion index, fSpeedRatio) 绑定、 +# .msm 防御球、motion_bound(__SetMotion 尾给 net_play 的钩子)。 +# godot --headless --path project --script hit_view_test.gd +extends SceneTree + +const PlayerView = preload("res://ui/player_view.gd") +const MobView = preload("res://ui/mob_view.gd") + +# Metin2AnimPlayer 桩:只记属性(anim_path 每次赋值都记,验证「同路径先清再设」) +class FakeAnim extends Node: + signal playback_finished + var sets := [] + var anim_path := "": + set(v): + anim_path = v + sets.append(v) + var loop := true + var time_scale := 1.0 + var playing := true + var blend_time := 0.15 + func get_motion_data() -> Dictionary: return {} + +const MSM := "ScriptType CharacterModelScript\nGroup AttachingData\n{\n\tGroup AttachingData00\n\t{\n\t\tAttachingDataType 0\n\t\tIsAttaching 0\n\t\tGroup CollisionData\n\t\t{\n\t\t\tCollisionType 3\n\t\t\tGroup SphereData\n\t\t\t{\n\t\t\t\tSphereDataCount 1\n\t\t\t\tGroup SphereData00\n\t\t\t\t{\n\t\t\t\t\tRadius %.1f\n\t\t\t\t\tPosition 0.0 0.0 %.1f\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n" + +var _fail := 0 +func _ck(c: bool, m: String) -> void: + if not c: + _fail += 1 + printerr("FAIL: " + m) + +func _touch(p: String, body := "") -> void: + DirAccess.make_dir_recursive_absolute(p.get_base_dir()) + var f := FileAccess.open(p, FileAccess.WRITE) + f.store_string(body) + f.close() + +var _done := false + +func _init() -> void: + await _run() + _ck(_done, "test body ran to completion (a SCRIPT ERROR aborted _run)") + if _fail == 0: + print("PASS: hit_view_test (hit reactions / shake / delay / attack motion / defending spheres)") + quit(0) + else: + printerr("%d check(s) failed" % _fail) + quit(1) + +func _run() -> void: + var root := ProjectSettings.globalize_path("user://hit_view_test") + var gen := root.path_join("general") + for n in ["wait", "run", "attack", "attack_1", "damage", "damage_1", "damage_2", "damage_3", + "damage_flying", "falling_stand", "back_damage_flying", "back_falling_stand"]: + _touch(gen.path_join(n + ".msa")) + for n in ["combo_01", "combo_02", "attack"]: + _touch(root.path_join("onehand_sword/%s.msa" % n)) + _touch(root.path_join("root/msm/warrior_m.msm"), MSM % [50.0, 90.0]) + _touch(root.path_join("root/msm/warrior_w.msm"), MSM % [40.0, 80.0]) + var gen_attack := [gen.path_join("attack.msa"), gen.path_join("attack_1.msa")] + + # --- PlayerView --------------------------------------------------------- + var pv: Node3D = PlayerView.new() + get_root().add_child(pv) + var model := Node3D.new() + pv.add_child(model) + pv.model = model + var anim := FakeAnim.new() + pv.add_child(anim) + pv.anim = anim + anim.playback_finished.connect(pv._on_playback_finished) + pv.motion_dir = gen + pv._assets_root = root + pv._race = 0 + var bound := [] + pv.motion_bound.connect(func(s: String): bound.append(s)) + await process_frame + + pv.set_anim_state("wait") + _ck(anim.anim_path == gen.path_join("wait.msa") and bound == ["wait"], + "set_anim_state binds wait.msa -> motion_bound(wait), got %s" % [bound]) + + # __SetMotion(MAKE_MOTION_KEY(mode, index), fSpeedRatio = m_fAtkSpd) + var c1 := root.path_join("onehand_sword/combo_01.msa") + pv.play_attack_motion(2, 14, 1.5) + _ck(anim.anim_path == c1 and not anim.loop and is_equal_approx(anim.time_scale, 1.5) \ + and bound.back() == "attack", + "ONEHAND_SWORD COMBO_ATTACK_1 -> combo_01.msa one-shot at speed 1.5, got %s" % anim.anim_path) + anim.sets.clear() + pv.play_attack_motion(2, 14, 1.5) + _ck(anim.sets == ["", c1], "same seg again -> re-bound from frame 0, got %s" % [anim.sets]) + pv.play_attack_motion(2, 15, 1.0) + _ck(anim.anim_path == root.path_join("onehand_sword/combo_02.msa"), "index 15 -> combo_02.msa") + pv.play_attack_motion(1, 13, 1.0) + _ck(anim.anim_path in gen_attack, "GENERAL NORMAL_ATTACK -> attack / attack_1, got %s" % anim.anim_path) + pv.play_attack_motion(1, 14, 1.0) + _ck(anim.anim_path in gen_attack, "GENERAL COMBO_ATTACK_1 -> attack / attack_1, got %s" % anim.anim_path) + pv.play_attack_motion(6, 14, 1.0) + _ck(anim.anim_path in gen_attack, "missing mode folder -> general attack fallback, got %s" % anim.anim_path) + _ck(not pv.is_in_hit_reaction(), "attack motion is not a hit reaction") + + # CGraphicThingInstance::InsertDelay:动作冻结 fDelay 秒后恢复 fSpeedRatio + pv.play_attack_motion(2, 14, 1.5) + pv.insert_delay(0.1) + _ck(anim.time_scale == 0.0, "InsertDelay -> motion frozen") + pv._process(0.05) + _ck(anim.time_scale == 0.0, "inside the delay -> still frozen") + pv._process(0.06) + _ck(is_equal_approx(anim.time_scale, 1.5), "delay over -> fSpeedRatio restored, got %s" % anim.time_scale) + + # isLock(攻击动作中):__HitGood 只 __Shake(100) + var atk_path: String = anim.anim_path + pv.hit_good(-1.0, false) + _ck(anim.anim_path == atk_path and pv._hit.shake_left > 0.0, "isLock (attack) -> shake only, no damage motion") + pv._process(0.05) + var off: Vector3 = model.position + _ck(absf(off.x) <= 0.09 and absf(off.y) <= 0.09 and absf(off.z) <= 0.09, + "ShakeProcess offset within rand()%%10 cm, got %s" % off) + pv._process(0.2) + _ck(model.position == Vector3.ZERO, "shake over -> model back on its ground offset, got %s" % model.position) + + # __HitGood:fScalar < 0 -> NAME_DAMAGE;>= 0 -> NAME_DAMAGE_BACK;之后 PushLoopMotion(WAIT) + pv.set_anim_state("wait") + pv._hit.shake_left = 0.0 + pv.hit_good(-1.0, false) + _ck(anim.anim_path in [gen.path_join("damage.msa"), gen.path_join("damage_1.msa")] and not anim.loop \ + and pv.is_in_hit_reaction() and bound.back() == "damage", + "fScalar < 0 -> NAME_DAMAGE (damage / damage_1), got %s" % anim.anim_path) + anim.playback_finished.emit() + _ck(anim.anim_path == gen.path_join("wait.msa") and not pv.is_in_hit_reaction(), "damage done -> WAIT") + pv.hit_good(1.0, false) + _ck(anim.anim_path in [gen.path_join("damage_2.msa"), gen.path_join("damage_3.msa")], + "fScalar >= 0 -> NAME_DAMAGE_BACK (damage_2 / damage_3), got %s" % anim.anim_path) + anim.playback_finished.emit() + pv._process(0.2) + pv._hit.shake_left = 0.0 + var before: String = anim.anim_path + pv.hit_good(-1.0, true) + _ck(anim.anim_path == before and pv._hit.shake_left == 0.0, "stunned -> Die() branch: no shake / damage motion") + + # __HitGreate:DAMAGE_FLYING -> STAND_UP -> 回循环动作;击倒 / 起身中不再受 Good / Greate + pv.set_anim_state("run") + pv.hit_greate(-1.0, false) + _ck(anim.anim_path == gen.path_join("damage_flying.msa") and pv.is_in_hit_reaction(), + "GREAT fScalar < 0 -> NAME_DAMAGE_FLYING, got %s" % anim.anim_path) + before = anim.anim_path + pv.hit_good(-1.0, false) + _ck(anim.anim_path == before, "IsKnockDown -> __HitGood returns") + pv.hit_greate(1.0, false) + _ck(anim.anim_path == before, "IsKnockDown -> __HitGreate returns") + anim.playback_finished.emit() + _ck(anim.anim_path == gen.path_join("falling_stand.msa"), "knockdown done -> PushOnceMotion(NAME_STAND_UP)") + pv.hit_greate(-1.0, false) + _ck(anim.anim_path == gen.path_join("falling_stand.msa"), "__IsStandUpMotion -> __HitGreate returns") + anim.playback_finished.emit() + _ck(anim.anim_path == gen.path_join("run.msa") and not pv.is_in_hit_reaction(), + "stand up done -> loop motion resumes (run), got %s" % anim.anim_path) + pv.hit_greate(1.0, false) + _ck(anim.anim_path == gen.path_join("back_damage_flying.msa"), "GREAT fScalar >= 0 -> NAME_DAMAGE_FLYING_BACK") + anim.playback_finished.emit() + _ck(anim.anim_path == gen.path_join("back_falling_stand.msa"), "-> NAME_STAND_UP_BACK") + anim.playback_finished.emit() + pv.hit_greate(1.0, true) + _ck(anim.anim_path == gen.path_join("back_damage_flying.msa"), "stunned GREAT -> DAMAGE_FLYING_BACK") + anim.playback_finished.emit() + _ck(anim.anim_path == gen.path_join("back_damage_flying.msa"), "stunned knockdown -> m_isRealDead: no stand up") + # IsUsingSkill -> 只 __Shake + pv.set_anim_state("skill") + pv._hit.shake_left = 0.0 + before = anim.anim_path + pv.hit_greate(-1.0, false) + _ck(anim.anim_path == before and pv._hit.shake_left > 0.0, "IsUsingSkill -> __HitGreate shakes only") + pv.set_anim_state("wait") + pv._hit.shake_left = 0.0 + pv.hit_stone(false) + _ck(pv._hit.shake_left > 0.0 and anim.anim_path == gen.path_join("wait.msa"), "__HitStone -> shake only") + pv._hit.shake_left = 0.0 + pv.hit_stone(true) + _ck(pv._hit.shake_left == 0.0, "stunned stone -> Die() branch, no shake") + + # .msm 防御球:root/msm/_.msm + var sp: Array = pv.get_defending_spheres() + _ck(sp.size() == 1 and is_equal_approx(float(sp[0]["radius"]), 50.0) \ + and Vector3(sp[0]["pos"]).is_equal_approx(Vector3(0, 0, 90)), + "race 0 -> warrior_m.msm defending sphere, got %s" % [sp]) + pv._race = 4 + sp = pv.get_defending_spheres() + _ck(sp.size() == 1 and is_equal_approx(float(sp[0]["radius"]), 40.0), "race 4 -> warrior_w.msm, got %s" % [sp]) + + # --- MobView ------------------------------------------------------------ + var mdir := root.path_join("mob") + var files := {"WAIT": "00.msa", "RUN": "03.msa", "NORMAL_ATTACK": "20.msa", "FRONT_DAMAGE": "30.msa", + "FRONT_KNOCKDOWN": "32.msa", "FRONT_STANDUP": "33.msa", "BACK_DAMAGE": "34.msa", "BACK_KNOCKDOWN": "35.msa"} + var mv: Node3D = MobView.new() + get_root().add_child(mv) + var mmodel := Node3D.new() + mv.add_child(mmodel) + mv.model = mmodel + var manim := FakeAnim.new() + mv.add_child(manim) + mv.anim = manim + manim.playback_finished.connect(mv._on_playback_finished) + mv._dir = mdir + mv._mesh_stem = "mobx" + for k in files: + _touch(mdir.path_join(files[k])) + mv._motions[k] = mdir.path_join(files[k]) + _touch(mdir.path_join("mobx.msm"), MSM % [60.0, 120.0]) + var mbound := [] + mv.motion_bound.connect(func(s: String): mbound.append(s)) + var M := func(n: String) -> String: return mdir.path_join(files[n]) + + mv.set_anim_state("wait") + _ck(manim.anim_path == M.call("WAIT") and mbound == ["wait"], "mob set_anim_state -> motion_bound(wait)") + mv.hit_good(1.0, false) + _ck(manim.anim_path == M.call("BACK_DAMAGE") and mv.is_in_hit_reaction(), "mob fScalar >= 0 -> BACK_DAMAGE") + manim.playback_finished.emit() + _ck(manim.anim_path == M.call("WAIT") and not mv.is_in_hit_reaction(), "mob damage done -> WAIT") + mv.hit_good(-1.0, false) + _ck(manim.anim_path == M.call("FRONT_DAMAGE"), "mob fScalar < 0 -> FRONT_DAMAGE") + manim.playback_finished.emit() + mv._motions.erase("BACK_DAMAGE") + mv.hit_good(1.0, false) + _ck(manim.anim_path == M.call("FRONT_DAMAGE"), "no DAMAGE_BACK motion -> InterceptOnceMotion(NAME_DAMAGE)") + manim.playback_finished.emit() + mv.hit_greate(1.0, false) + _ck(manim.anim_path == M.call("BACK_KNOCKDOWN"), "mob GREAT fScalar >= 0 -> BACK_KNOCKDOWN") + manim.playback_finished.emit() + _ck(manim.anim_path == M.call("WAIT"), "no BACK_STANDUP -> PushOnceMotion fails -> WAIT, got %s" % manim.anim_path) + mv.hit_greate(-1.0, false) + _ck(manim.anim_path == M.call("FRONT_KNOCKDOWN"), "mob GREAT fScalar < 0 -> FRONT_KNOCKDOWN") + manim.playback_finished.emit() + _ck(manim.anim_path == M.call("FRONT_STANDUP"), "-> FRONT_STANDUP") + manim.playback_finished.emit() + _ck(manim.anim_path == M.call("WAIT"), "-> WAIT") + mv._motions.erase("FRONT_KNOCKDOWN") + mv._motions.erase("BACK_KNOCKDOWN") + before = manim.anim_path + mv.hit_greate(1.0, false) + _ck(manim.anim_path == before, "no knockdown motions -> no motion change") + mv.set_anim_state("attack") + mv.hit_good(-1.0, false) + _ck(manim.anim_path == M.call("NORMAL_ATTACK"), "mob attacking -> isLock, no damage motion") + mv.insert_delay(0.05) + _ck(manim.time_scale == 0.0, "mob InsertDelay -> frozen") + mv._process(0.06) + _ck(manim.time_scale == 1.0, "mob delay over -> speed restored") + sp = mv.get_defending_spheres() + _ck(sp.size() == 1 and is_equal_approx(float(sp[0]["radius"]), 60.0), "mob -> /.msm, got %s" % [sp]) + + pv.queue_free() + mv.queue_free() + _done = true diff --git a/project/input_key_test.gd b/project/input_key_test.gd index 5dd16d4d..104e50d5 100644 --- a/project/input_key_test.gd +++ b/project/input_key_test.gd @@ -4,6 +4,7 @@ extends SceneTree const GameScene = preload("res://game_scene.gd") +const UiManager = preload("res://ui/ui_manager.gd") class FakeClient extends Node: var emotes := [] @@ -73,7 +74,7 @@ func _key(code: Key, pressed := true, ctrl := false, meta := false) -> InputEven return event func _init() -> void: - _run() + await _run() if _fail == 0: print("PASS: input_key_test (ClientVS22 desktop key map)") quit(0) @@ -143,3 +144,21 @@ func _run() -> void: gs._unhandled_input(_key(KEY_J, true, true)) _ck(client.commands == ["/user_horse_ride", "/user_horse_back", "/unmount"], "Ctrl/Command horse commands follow reference") + + # CWindowManager::RunKeyDown:只有 LockWindow(模态)或返回 True 的 ActiveWindow(输入框) + # 截键;普通打开的窗口(技能窗 / 背包)不吞键,1-4 / F1-F4 照样落到 game.py 快捷栏。 + var ui_manager: CanvasLayer = UiManager.new() + get_root().add_child(ui_manager) + await process_frame + gs.ui = ui_manager + var skill_window := Panel.new() + ui_manager.open(skill_window) + qb.activated.clear() + gs._unhandled_input(_key(KEY_2)) + gs._unhandled_input(_key(KEY_F2)) + _ck(qb.activated == [1, 5], "non-modal window open keeps number/F-key quick slots: %s" % [qb.activated]) + var confirm := Panel.new() + ui_manager.open(confirm, true) + gs._unhandled_input(_key(KEY_3)) + _ck(qb.activated == [1, 5], "modal lock window still swallows quick slot keys") + gs.ui = null diff --git a/project/net_play.gd b/project/net_play.gd index 861fb801..d1e23d6e 100644 --- a/project/net_play.gd +++ b/project/net_play.gd @@ -95,9 +95,21 @@ const MOTION_TYPE_NORMAL := 1 const MOTION_TYPE_COMBO := 2 const MOTION_TYPE_SKILL := 3 const HIT_ATTACK_RADIUS_CM := 20.0 # c_fAttackRadius(ActorInstanceCollisionDetection.cpp:355) -const HIT_VICTIM_RADIUS_CM := 45.0 # 受击方防御圆柱近似半径(真实取自 defending sphere 数据) -const HIT_FRONT_COS := 0.5 # 正面 ±60° 弧内才判定命中(近似动态圆柱扫掠) const SYNC_POSITION_LIMIT := 16 # FlushVictimList 的 SYNC_POSITION_COUNT_LIMIT +# NRaceData::EHitType(GameType.h) +const HIT_TYPE_NONE := 0 +const HIT_TYPE_GREAT := 1 +const HIT_TYPE_GOOD := 2 +const HIT_DISTANCE_CM := 300.0 # __NormalAttackProcess:fDistance >= 300² -> FALSE +const HIT_DISTANCE_HUGE_CM := 500.0 # IS_HUGE_RACE(victim):>= 500² -> FALSE +const COMBO_HIT_LIMIT := 16 # MOTION_TYPE_COMBO / NORMAL 每个命中窗最多 16 个目标 +const PUSH_OWNER_TIME := 3.0 # __CanPushDestActor:__GetOwnerTime() > 3.0f -> 不推 +const COMBO_SKILL_ID := 122 # SetComboSkillFlag 的 c_iSkillIndex_Combo +# m_dwBattleHitEffectID(playersettingmodule.py RegisterCacheEffect(EFFECT_HIT, ...)) +const EFFECT_HIT := "d:/ymir work/effect/hit/blow_1/blow_1_low.mse" +# 占位胶囊(没有 .msm 防御球)退化用的一个防御球(seam) +const HIT_FALLBACK_SPHERE := {"radius": 45.0, "pos": Vector3(0, 0, 100), "bone": ""} +const HitCollision = preload("res://hit_collision.gd") var client: Node # M2Client var pc: Node # PlayerController @@ -108,10 +120,20 @@ var hud: Node # 可空 # `DEFAULT_ATTACK_PERIOD` 只在拿不到动作数据(如 headless 无 player_view)时兜底。 const DEFAULT_ATTACK_PERIOD := 0.6 var attack_period := DEFAULT_ATTACK_PERIOD # 上一次解析出的普攻节奏(秒),供 HUD / 测试读取 -var _atk_speed_factor := 1.0 # bAttackSpeed / 100(0.25..3),只缩放播放速率 +var _atk_speed_factor := 1.0 # m_fAtkSpd = bAttackSpeed / 100(>1100 -> 0),动作 fSpeedRatio var camera: Node # GameCamera(可空,用于受击抖屏) -var player_view: Node # PlayerView(可空,set_anim_state("damage")) +# PlayerView(可空)。motion_bound(state) = __SetMotion 尾(清命中表 / 连击段号)。 +var player_view: Node: + set(v): + if is_instance_valid(player_view) and player_view.has_signal("motion_bound") \ + and player_view.motion_bound.is_connected(_on_motion_bound): + player_view.motion_bound.disconnect(_on_motion_bound) + player_view = v + if v and v.has_signal("motion_bound") and not v.motion_bound.is_connected(_on_motion_bound): + v.motion_bound.connect(_on_motion_bound) +var fx # EffectRegistry(可空):命中特效 EFFECT_HIT +var fx_parent: Node3D # 命中特效挂载点(game_scene._mount) var proto: Node # Metin2Proto(可空,按 race 分类 NPC/怪) const DAMAGE_DODGE := 1 << 2 @@ -188,18 +210,27 @@ var _combo_type := 0 # m_wcurComboType(SetComboType;连击技能 var _combo_index := 0 # m_dwcurComboIndex(0 = 不在连击中) var _is_pre_input := false # m_isPreInput(已过 InputStartTime、待 NextComboTime 触发) var _is_next_pre_input := false # m_isNextPreInput(弓箭:输入超限后排队下一击) -var _combo_started_t := 0.0 # 当前连击段动作起播时刻(GetAttackingElapsedTime 基准) var _combo_tables := {} # { class:int -> { key:int -> PackedInt32Array } } +# CGraphicThingInstance 本地时间:UpdateTime 每帧 += elapsed,InsertDelay 冻结(命中硬直)。 +var _local_time := 0.0 # GetLocalTime() +var _delay := 0.0 # m_fDelay +var _motion_start_t := 0.0 # m_kCurMotNode.fStartTime(本地时间;GetAttackingElapsedTime 基准) # §3.5 修改 4 —— 当前挥击的命中窗状态(m_pkCurRaceMotionData->GetMotionAttackDataPointer # + m_HitDataMap + m_kVctkVictim 的等价) -var _swing_start_t := 0.0 # 本次挥击动作起播时刻(命中窗时间基准,独立于连击输入计时) var _swing_skill := 0 # 本次挥击的 uSkill(普攻 0;技能施法由 §3.8 设置) var _hit_windows := [] # get_motion_data().hit_windows(THitDataContainer) var _hit_motion_type := 0 # get_motion_data().motion_type(NRaceData::EMotionType) var _hit_limit_count := 0 # get_motion_data().hit_limit_count(SKILL 类的每窗命中上限) -var _hit_invisible_time := 0.0 # get_motion_data().invisible_time(同目标再命中的冷却) -var _hit_dedup := {} # { window_idx:int -> { victim_vid:int -> cooldown_until:float } } +var _hit_invisible_time := 0.0 # get_motion_data().invisible_time(fInvisibleTime) +var _hit_type := HIT_TYPE_NONE # get_motion_data().hitting_type(iHittingType) +var _hit_stiffen := 0.0 # get_motion_data().stiffen_time(fStiffenTime) +var _hit_external_force := 0.0 # get_motion_data().external_force(fExternalForce) +var _hit_dedup := {} # m_HitDataMap:{ window_idx:int -> { victim_vid:int -> 本地时间 + fInvisibleTime } } var _victim_flush := [] # 帧末 CG_SYNC_POSITION 的被击退者列表 [{vid,x,y}] +var _victim_invisible_until := {} # 受击方 m_fInvisibleTime:{ vid -> 秒(_now())} +var _def_last := {} # 防御球 v3LastPosition:{ vid -> {frame, pos: [Vector3 cm]} } +var _atk_frame := 0 # _attack_process 帧号(判断 _def_last 是否是上一帧) +var _owner_seen := {} # m_fOwnerBaseTime 的客户端观测:{ vid -> [owner_vid, 秒] } var _observer_mode := false var _attack_key_down := false var _fishing_active := false # IsFishing(): 当前处于 WAIT/REACT 的本地主角 @@ -242,12 +273,14 @@ func setup(m2client: Node, player_ctl: Node, nw: Node, hud_node: Node = null) -> client.points_changed.connect(_on_points) client.vitals_changed.connect(_on_vitals) client.target_info.connect(_on_target_info) - client.entity_despawned.connect(func(v): if v == _target_vid: _clear_target()) + client.entity_despawned.connect(_on_entity_gone) client.entity_dead.connect(func(v): if v == _target_vid: _clear_target()) if client.has_signal("damage"): client.damage.connect(_on_damage) if client.has_signal("fishing_event"): client.fishing_event.connect(_on_fishing_event) + if client.has_signal("combo_changed"): + client.combo_changed.connect(_on_combo_changed) if client.has_signal("observer_mode_changed"): client.observer_mode_changed.connect(_on_observer_mode) if client.has_method("is_observer_mode"): @@ -357,6 +390,30 @@ func set_asset_root(root: String) -> void: func set_combo_type(t: int) -> void: _combo_type = clampi(t, 0, 2) +# CPythonPlayer::SetComboSkillFlag(PythonPlayerSkill.cpp:916):GC 连击开关到达时查连击技能 +# 122 的槽位 / 等级,缺槽或等级 <= 0 直接 return(不改 combo type),否则 +# SetComboType(bFlag ? MIN(iLevel, 2) : 0)。 +func _on_combo_changed(enabled: bool) -> void: + if client == null or not client.has_method("get_skills"): + return + var level := -1 + for skill in client.get_skills(): + if int(skill.get("id", 0)) == COMBO_SKILL_ID: + level = int(skill.get("level", 0)) + break + if level <= 0: + return + _combo_type = mini(level, 2) if enabled else 0 + +# CActorInstance::__SetMotion 尾(ActorInstanceMotion.cpp):绑定带 MotionAttackData 的动作 -> +# m_HitDataMap.clear();连击中换到没有 ComboInputData 的动作 -> m_dwcurComboIndex = 0。 +func _on_motion_bound(_state: String) -> void: + var md := _motion_data() + if bool(md.get("has_attacking_data", false)): + _hit_dedup.clear() + if _combo_index != 0 and not bool(md.get("has_combo_input", false)): + _combo_index = 0 + func set_combo_motion_mode(mode: int) -> void: if mode != combo_motion_mode: combo_motion_mode = mode @@ -627,6 +684,8 @@ func _on_main_set(vid: int) -> void: net_world.set_local_vid(vid) func _on_entity_info(vid: int, entity: Dictionary) -> void: + if entity.has("owner_vid"): + _note_owner(vid, int(entity.get("owner_vid", 0))) if vid != _main_vid: return if pc and pc.has_method("set_server_speed"): @@ -641,8 +700,9 @@ func _on_entity_info(vid: int, entity: Dictionary) -> void: func _apply_attack_speed(speed: int) -> void: # `CInstanceBase::SetAttackSpeed`:bAttackSpeed / 100 只缩放动作播放速率, # 不是一个独立的固定间隔。节奏本身由 `.msa` combo 时间给出(见 _current_attack_period)。 + # 出货代码:uAtkSpd > 1100 -> 0,否则 uAtkSpd / 100,没有上下限。speed 0 = 数据未到,保留旧值。 if speed > 0: - _atk_speed_factor = clampf(float(speed) / 100.0, 0.25, 3.0) + _atk_speed_factor = 0.0 if speed > 1100 else float(speed) / 100.0 # 当前攻击 `.msa` 的普攻节奏(秒),已按攻速缩放。拿不到动作数据时用兜底常量。 func _current_attack_period() -> float: @@ -650,8 +710,25 @@ func _current_attack_period() -> float: var nc := float(_motion_data().get("next_combo", 0.0)) if nc > 0.0: base = nc + if _atk_speed_factor <= 0.0: + return base # fSpeedRatio 0 = 动作冻结;节奏只作 HUD / 冷却兜底,不除零 return base / _atk_speed_factor +# CGraphicThingInstance::UpdateTime:m_fDelay 先吃掉本帧 elapsed,余下才推进本地时间。 +func _advance_local_time(dt: float) -> void: + var elapsed := dt + if _delay > elapsed: + _delay -= elapsed + elapsed = 0.0 + else: + elapsed -= _delay + _delay = 0.0 + _local_time += elapsed + +# CActorInstance::GetAttackingElapsedTime:(GetLocalTime() - fStartTime) * fSpeedRatio +func _attacking_elapsed() -> float: + return (_local_time - _motion_start_t) * _atk_speed_factor + # CInstanceBase::__GetBowRange(InstanceBase.cpp:694)——基础 + 主角 POINT_BOW_DISTANCE 加成。 func _bow_range_cm() -> float: return BOW_RANGE_BASE_CM + _bow_distance_bonus @@ -916,6 +993,7 @@ func is_use_skill_reserved(slot: int) -> bool: return _reserved_mode == ReservedMode.USE_SKILL and _skill_slot_reserved == slot func _process(dt: float) -> void: + _advance_local_time(dt) # 击退动作播完 -> 解除击退闸门(动作时长驱动,非计时器)。 if _knock_down and player_view and player_view.has_method("is_in_hit_reaction") \ and not player_view.is_in_hit_reaction(): @@ -1002,7 +1080,7 @@ func _emit_cannot(code: String) -> void: # CInstanceBase::IsAttackableInstance(InstanceBase.cpp:2147)—— 逐行实现在 entity_rules.gd。 # 这里只负责把网络快照拼成 self_e / victim_e / ctx。参考端此函数**不查死亡**(死亡另在各 -# 调用点判:__ReserveProcess_ClickActor 第 4 步、_hit_geometry 等)——本 POC 保留一个 +# 调用点判:__ReserveProcess_ClickActor 第 4 步、_attack_process 等)——本 POC 保留一个 # dead 提前返回作为便利,多数调用点本来也会再查一次。 func _is_attackable(e: Dictionary) -> bool: if bool(e.get("dead", false)): @@ -1425,7 +1503,7 @@ func _do_attack_swing(tnode: Node3D, _rv: Dictionary) -> void: _run_next_combo(vec) return - var t := _now() - _combo_started_t + var t := _attacking_elapsed() var md := _motion_data() if bool(md.get("has_combo_input", false)): var t_start := float(md.get("pre_input_time", 0.0)) # GetComboInputStartTime @@ -1451,7 +1529,6 @@ func _run_next_combo(vec: PackedInt32Array) -> void: if arr_idx < 0 or arr_idx >= vec.size(): _combo_index -= 1 # 段号越界:参考端 TraceError + return,不推进 return - _combo_started_t = _now() _emit_swing(int(vec[arr_idx]), _combo_index > 1) if _combo_index == vec.size(): _on_end_combo() @@ -1472,7 +1549,7 @@ func _clear_combo() -> void: # CActorInstance::ComboProcess(ActorInstanceBattle.cpp:213)——每帧推进挂起输入 / 收尾。 func _combo_process() -> void: if _combo_index != 0: - var t := _now() - _combo_started_t + var t := _attacking_elapsed() var md := _motion_data() var has_ci := bool(md.get("has_combo_input", false)) var full := float(md.get("duration", attack_period)) @@ -1486,9 +1563,8 @@ func _combo_process() -> void: if not vec.is_empty(): _run_next_combo(vec) return - # 动作已回到 Wait(超过整段时长、无挂起输入)→ __ClearCombo - if not _is_pre_input and t > full: - _clear_combo() + # 参考端这里没有「超过整段时长就清连击」:段号在动作回 Wait 时由 __SetMotion 尾 + # (_on_motion_bound)清零。 else: _is_pre_input = false if _is_next_pre_input and not _using_skill: @@ -1518,13 +1594,17 @@ func _emit_swing(motion_index: int, is_combo: bool) -> void: # OnAttack → SendCharacterStatePacket(FUNC_COMBO, wMotionIndex):这一包带连击段号 # (PythonPlayerEventHandler.cpp:102),与 CG_ATTACK 是两个不同时刻的两个包。 _send_state(FUNC_COMBO, motion_index, pc.player.position) + # __SetMotion(SSetMotionData{ dwMotKey(mode, index), fSpeedRatio = m_fAtkSpd }):先绑定该段 + # 动作,命中窗 / 连击时间才读得到这一段的 .msa。 + if player_view and player_view.has_method("play_attack_motion"): + player_view.play_attack_motion(combo_motion_mode, motion_index, _atk_speed_factor) + elif player_view and player_view.has_method("set_anim_state"): + player_view.set_anim_state("combo" if is_combo else "attack") _begin_hit_windows() if _hit_windows.is_empty(): # CG_ATTACK 的 bType 是技能号,普攻恒 0,不是连击段号 # (PythonPlayerEventHandler.cpp:135 / PhaseGame.cpp:2530)。 client.attack(_swing_skill, _vid_reserved) - if player_view and player_view.has_method("set_anim_state"): - player_view.set_anim_state("combo" if is_combo else "attack") # 节奏来自动作数据(.msa ComboInputData.DirectInputTime / next_combo), # 除以攻速系数;缺组时退化为 DEFAULT_ATTACK_PERIOD。§3.3 attack_period = _current_attack_period() @@ -1545,27 +1625,33 @@ func _send_fly_target() -> void: var sp := _server_xy(tnode.global_position) client.add_fly_targeting(vid, int(sp.x), int(sp.y)) -# 挥击起播:从当前 `.msa` 取 THitDataContainer + iMotionType + iHitLimitCount + fInvisibleTime, -# 清空上一击的 m_HitDataMap(对齐 __ProcessNormalAttack 里对 m_HitDataMap 的重建)。 +# 挥击起播:从刚绑定的 `.msa` 取 TMotionAttackData(THitDataContainer + iMotionType + +# iHitLimitCount + fInvisibleTime + iHittingType + fStiffenTime + fExternalForce), +# m_kCurMotNode.fStartTime = 本地时间,清空上一击的 m_HitDataMap。 func _begin_hit_windows() -> void: var md := _motion_data() var w: Variant = md.get("hit_windows", []) _hit_windows = w if w is Array else [] - _hit_motion_type = int(md.get("motion_type", 0)) + _hit_motion_type = int(md.get("motion_type", MOTION_TYPE_NONE)) _hit_limit_count = int(md.get("hit_limit_count", 0)) _hit_invisible_time = float(md.get("invisible_time", 0.0)) + _hit_type = int(md.get("hitting_type", HIT_TYPE_NONE)) + _hit_stiffen = float(md.get("stiffen_time", 0.0)) + _hit_external_force = float(md.get("external_force", 0.0)) _hit_dedup.clear() - _swing_start_t = _now() + _motion_start_t = _local_time -# CInstanceBase::AttackProcess(InstanceBaseBattle.cpp:413)+ CActorInstance::__NormalAttackProcess -# (ActorInstanceCollisionDetection.cpp:333)——挥击动作播放中,每帧按 hit_window [start,end] -# 对所有可攻击实体做扫掠球几何判定,命中即 OnHit(SendAttackPacket)。 +# CInstanceBase::AttackProcess(InstanceBaseBattle.cpp:413)-> CActorInstance::AttackingProcess -> +# __NormalAttackProcess(ActorInstanceCollisionDetection.cpp:333):挥击动作播放中,每帧用 .msa +# 刀尖扫掠球(半径 20)对每个可攻击实体的 .msm 防御球做 Z 圆柱检测,命中即 +# __ProcessDataAttackSuccess(硬直 / 击退 / 特效 / 受击反应 / OnHit)。 func _attack_process(dt: float) -> void: if _hit_windows.is_empty() or client == null or not client.is_in_game(): return if pc == null or pc.player == null or net_world == null: return - var t := _now() - _swing_start_t + _atk_frame += 1 + var t := _attacking_elapsed() # 动作已越过所有命中窗 -> 收起(等同 !CanCheckAttacking) var latest_end := 0.0 for w in _hit_windows: @@ -1577,8 +1663,8 @@ func _attack_process(dt: float) -> void: # CheckAttacking:自己在安全区直接不判定 if bool(_main_entity().get("in_safe", false)): return + var a_pos := _actor_cm(pc.player.global_position) var yaw := _player_yaw() - var origin: Vector3 = pc.player.global_position for e in client.get_entities(): var vid := int(e.get("vid", 0)) if vid == 0 or vid == _main_vid: @@ -1588,76 +1674,187 @@ func _attack_process(dt: float) -> void: var vnode: Node3D = net_world.node_for(vid) if vnode == null: continue - for wi in _hit_windows.size(): - var w: Dictionary = _hit_windows[wi] - var ws := float(w.get("start_time", 0.0)) - var we := float(w.get("end_time", 0.0)) - # 扫掠:动作时间窗 [t-dt, t] 与命中窗 [ws, we] 相交(对齐 lower_bound(motiontime-elapsed)) - if t < ws or (t - dt) > we: - continue - if _hit_deduped(wi, vid): - continue - if not _hit_geometry(w, origin, yaw, vnode.global_position, t, dt): - continue - if not _register_hit(wi, vid): - break # 该窗命中数超上限 -> 本帧不再处理这个实体 - # OnHit:SetTarget(FALSE) + SendAttackPacket(uSkill, victimVID) - if _target_vid != vid: - _target_vid = vid - client.attack(_swing_skill, vid) - # IsPushing 的目标:帧末 CG_SYNC_POSITION 上报其被击退后的位置 - if bool(e.get("knock_down", false)) or bool(e.get("stunned", false)): - var sp := _server_xy(vnode.global_position) - _victim_flush.append({"vid": vid, "x": int(sp.x), "y": int(sp.y)}) - break # 一个实体一帧命中一次(对齐 __NormalAttackProcess 命中即 return TRUE) + # AttackingProcess:rVictim.__isInvisible() -> 跳过 + if _now() < float(_victim_invisible_until.get(vid, 0.0)): + continue + _normal_attack_process(e, vid, vnode, a_pos, yaw, t, dt) -# m_HitDataMap 查重:COMBO 动作同窗同目标只一次;其余按 fInvisibleTime 冷却。 -func _hit_deduped(wi: int, vid: int) -> bool: - var m: Dictionary = _hit_dedup.get(wi, {}) - if not m.has(vid): - return false - if _hit_motion_type == MOTION_TYPE_COMBO: - return true - return float(m[vid]) > _now() +# Godot 世界 m -> 角色世界 cm(X, Y, 高度) +static func _actor_cm(g: Vector3) -> Vector3: + return Vector3(g.x * CM, -g.z * CM, g.y * CM) -# 记一次命中并做每窗命中上限校验:NORMAL/COMBO 上限 16,SKILL 用 iHitLimitCount。 -# 返回 false = 超上限,本帧跳过(对齐 iCurrentHitCount > … return FALSE)。 -func _register_hit(wi: int, vid: int) -> bool: - var m: Dictionary = _hit_dedup.get(wi, {}) - var first := not m.has(vid) - m[vid] = _now() + _hit_invisible_time - _hit_dedup[wi] = m - if first: - return true - var cap := 16 - if _hit_motion_type != MOTION_TYPE_COMBO and _hit_motion_type != MOTION_TYPE_NORMAL: - cap = _hit_limit_count - return m.size() <= cap +static func _godot_m(a: Vector3) -> Vector3: + return Vector3(a.x / CM, a.z / CM, -a.y / CM) -# 扫掠球几何近似:reach = max(WeaponLength, 窗内采样最大水平偏移),命中条件 = 目标落在 -# 正面 reach 弧内。真实的动态圆柱-圆柱扫掠需要骨骼矩阵 + defending sphere 数据(同 §3.1 留桩)。 -func _hit_geometry(w: Dictionary, origin: Vector3, yaw: float, victim_pos: Vector3, t: float, dt: float) -> bool: - var reach_cm := float(w.get("weapon_length", 0.0)) - var samples: Variant = w.get("samples", []) - if samples is Array: - for s in samples: - var st := float(s.get("time", 0.0)) +# 模型本地 cm 按 Godot yaw 转到角色世界朝向(= Metin2 的 Z 轴旋转) +static func _rot_cm(v: Vector3, yaw: float) -> Vector3: + var c := cos(yaw) + var s := sin(yaw) + return Vector3(v.x * c - v.y * s, v.x * s + v.y * c, v.z) + +func _normal_attack_process(e: Dictionary, vid: int, vnode: Node3D, a_pos: Vector3, yaw: float, + t: float, dt: float) -> void: + var v_pos := _actor_cm(vnode.global_position) + var huge := HitCollision.is_huge_race(int(e.get("race", 0))) + # 出货代码 v3Distance = (dX, dZ, dZ):平面 Y 不参与、高度差算两次(1:1 保留) + var dx := v_pos.x - a_pos.x + var dz := v_pos.z - a_pos.z + var dist2 := dx * dx + 2.0 * dz * dz + var lim := HIT_DISTANCE_HUGE_CM if huge else HIT_DISTANCE_CM + if dist2 >= lim * lim: + return + var spheres := _defending_spheres(vid, vnode, v_pos) + for wi in _hit_windows.size(): + var w: Dictionary = _hit_windows[wi] + if t < float(w.get("start_time", 0.0)) or (t - dt) > float(w.get("end_time", 0.0)): + continue + # m_HitDataMap:同窗已命中过 -> COMBO 永不再判;其余要等 fInvisibleTime 过去 + var m: Dictionary = _hit_dedup.get(wi, {}) + if m.has(vid) and (_hit_motion_type == MOTION_TYPE_COMBO or float(m[vid]) > _local_time): + continue + var samples: Variant = w.get("samples", []) + if not (samples is Array): + continue + for smp in samples: + var st := float(smp.get("time", 0.0)) if st < t - dt or st > t: continue - var p: Vector3 = s.get("pos", Vector3.ZERO) - reach_cm = maxf(reach_cm, Vector2(p.x, p.y).length()) - if reach_cm <= 0.0: - reach_cm = CLICK_DIST_DEFAULT_CM - var max_dist := (reach_cm + HIT_ATTACK_RADIUS_CM + HIT_VICTIM_RADIUS_CM) / CM - var to: Vector3 = victim_pos - origin - to.y = 0.0 - var d := to.length() - if d > max_dist: - return false - if d < 0.001: + var p: Vector3 = smp.get("pos", Vector3.ZERO) + var lp: Vector3 = smp.get("last_pos", p) + var d_pos := _rot_cm(p, yaw) + a_pos + var d_last := _rot_cm(lp, yaw) + a_pos + for sp in spheres: + if not HitCollision.detect_z_cylinder(d_last, d_pos, HIT_ATTACK_RADIUS_CM, + sp["last"], sp["pos"], float(sp["radius"])): + continue + if not _register_hit(wi, vid): + return + var hit_pos: Vector3 = (a_pos + (sp["pos"] as Vector3)) * 0.5 if huge else (a_pos + v_pos) * 0.5 + _process_attack_success(e, vid, vnode, hit_pos, huge) + return + +# 受击方 m_DefendingPointInstanceList:模型本地球心按受击方朝向旋转 + 受击方位置; +# v3LastPosition = 上一帧(没有上一帧就取本帧)。没有 .msm 的占位节点退化为一个球(seam)。 +func _defending_spheres(vid: int, vnode: Node3D, v_pos: Vector3) -> Array: + var raw: Array = [] + if vnode.has_method("get_defending_spheres"): + raw = vnode.get_defending_spheres() + if raw.is_empty(): + raw = [HIT_FALLBACK_SPHERE] + var vyaw := vnode.rotation.y + var cur: Array = [] + for sp in raw: + cur.append(_rot_cm(sp.get("pos", Vector3.ZERO), vyaw) + v_pos) + var prev: Dictionary = _def_last.get(vid, {}) + var has_prev := int(prev.get("frame", -1)) == _atk_frame - 1 and (prev.get("pos", []) as Array).size() == cur.size() + var out: Array = [] + for i in raw.size(): + out.append({"radius": float(raw[i].get("radius", 0.0)), "pos": cur[i], + "last": prev["pos"][i] if has_prev else cur[i]}) + _def_last[vid] = {"frame": _atk_frame, "pos": cur} + return out + +# m_HitDataMap 登记(__NormalAttackProcess 命中分支):窗第一次命中 -> 新建 {vid: 本地时间 + +# fInvisibleTime} 直接处理;否则 map::insert(已在就不覆盖),NORMAL/COMBO 超 16、SKILL 超 +# iHitLimitCount -> FALSE。 +func _register_hit(wi: int, vid: int) -> bool: + if not _hit_dedup.has(wi): + _hit_dedup[wi] = {vid: _local_time + _hit_invisible_time} return true - var fwd := Vector3(sin(yaw), 0.0, cos(yaw)) - return fwd.dot(to / d) >= HIT_FRONT_COS + var m: Dictionary = _hit_dedup[wi] + if not m.has(vid): + m[vid] = _local_time + _hit_invisible_time + var cap := _hit_limit_count + if _hit_motion_type == MOTION_TYPE_COMBO or _hit_motion_type == MOTION_TYPE_NORMAL: + cap = COMBO_HIT_LIMIT + return m.size() <= cap + +# CActorInstance::__ProcessDataAttackSuccess(ActorInstanceBattle.cpp) +func _process_attack_success(e: Dictionary, vid: int, vnode: Node3D, hit_pos: Vector3, huge: bool) -> void: + if _hit_type == HIT_TYPE_NONE: + return + # InsertDelay(fStiffenTime):攻击者本地时间冻结(命中硬直) + _delay = _hit_stiffen + if player_view and player_view.has_method("insert_delay"): + player_view.insert_delay(_hit_stiffen) + var a_g: Vector3 = pc.player.global_position + var v_g := vnode.global_position + var to := v_g - a_g + # __PushCircle + IncreaseExternalForce + if _can_push(e, vid, huge) and _hit_external_force > 0.0 and net_world.has_method("push_victim"): + net_world.push_victim(vid, Vector2(to.x, -to.z).normalized(), _hit_external_force) + # 受击方 m_fInvisibleTime = 现在 + fInvisibleTime;受击方 InsertDelay + _victim_invisible_until[vid] = _now() + _hit_invisible_time + if vnode.has_method("insert_delay"): + vnode.insert_delay(_hit_stiffen) + var kind := _entity_kind(e) + var building := kind == EntityRules.KIND_BUILDING + var door := kind == EntityRules.KIND_WOODEN_DOOR + # 命中特效 m_dwBattleHitEffectID:建筑 / 门在攻击者身前 30 cm、不转;其余在受击方(巨型:命中点) + if fx and fx_parent and fx.has_method("spawn_at"): + var at := v_g + var rot := atan2(-to.x, -to.z) + if building or door: + var flat := Vector3(to.x, 0.0, to.z) + at = a_g + (flat.normalized() if flat.length() > 0.0 else Vector3.ZERO) * 0.3 + rot = 0.0 + elif huge: + at = _godot_m(hit_pos) + var eff: Node3D = fx.spawn_at(EFFECT_HIT, fx_parent, at, true) + if eff: + eff.rotation.y = rot + # __HitStone / __HitGood / __HitGreate + if not building: + var stunned := bool(e.get("stunned", false)) + var scalar := cos(_player_yaw() - vnode.rotation.y) + if kind == EntityRules.KIND_STONE or door: + if vnode.has_method("hit_stone"): + vnode.hit_stone(stunned) + elif _hit_type == HIT_TYPE_GOOD: + if vnode.has_method("hit_good"): + vnode.hit_good(scalar, stunned) + elif _hit_type == HIT_TYPE_GREAT: + if vnode.has_method("hit_greate"): + vnode.hit_greate(scalar, stunned) + _on_hit(vid, huge) + +# CActorInstance::__CanPushDestActor(ActorInstanceBattle.cpp) +func _can_push(e: Dictionary, vid: int, huge: bool) -> bool: + var kind := _entity_kind(e) + if kind in [EntityRules.KIND_BUILDING, EntityRules.KIND_WOODEN_DOOR, EntityRules.KIND_STONE, EntityRules.KIND_NPC]: + return false + if huge: + return false + if bool(e.get("stunned", false)): + return true + var owner := int(e.get("owner_vid", 0)) + _note_owner(vid, owner) + if owner != _main_vid: + return false + return _now() - float(_owner_seen[vid][1]) <= PUSH_OWNER_TIME + +# m_dwOwnerVID / m_fOwnerBaseTime:服务端 owner 变化时记下客户端观测时刻(__GetOwnerTime 的近似)。 +func _note_owner(vid: int, owner: int) -> void: + var seen: Array = _owner_seen.get(vid, []) + if seen.is_empty() or int(seen[0]) != owner: + _owner_seen[vid] = [owner, _now()] + +# CPythonPlayerEventHandler::OnHit:SetTarget(victim, FALSE) + SendAttackPacket(uSkill, victim); +# 受击方正被击退且不是巨型 -> 记进帧末 CG_SYNC_POSITION 列表(GetBlendingPosition)。 +func _on_hit(vid: int, huge: bool) -> void: + _target_vid = vid + client.attack(_swing_skill, vid) + if huge or not net_world.has_method("is_pushing") or not net_world.is_pushing(vid): + return + var sp := _server_xy(net_world.blending_position(vid)) + _victim_flush.append({"vid": vid, "x": int(sp.x), "y": int(sp.y)}) + +func _on_entity_gone(vid: int) -> void: + _owner_seen.erase(vid) + _victim_invisible_until.erase(vid) + _def_last.erase(vid) + if vid == _target_vid: + _clear_target() # CPythonPlayerEventHandler::FlushVictimList(PythonPlayerEventHandler.cpp:194)——帧末把 # 被击退目标的位置一次性 CG_SYNC_POSITION 上行,上限 SYNC_POSITION_COUNT_LIMIT=16。 diff --git a/project/net_world.gd b/project/net_world.gd index d9cff7f6..6b9eae0f 100644 --- a/project/net_world.gd +++ b/project/net_world.gd @@ -27,6 +27,7 @@ const PickShow = preload("res://pick_show.gd") const NameTailLayout = preload("res://name_tail_layout.gd") const TextMetrics = preload("res://text_metrics.gd") const ScreenHpBar = preload("res://screen_hp_bar.gd") +const PhysicsPush = preload("res://physics_push.gd") signal entity_added(node: Node3D, vid: int) signal entity_removed(vid: int) @@ -93,6 +94,8 @@ var follow_lerp := 14.0 var snap_dist := 6.0 var _by_vid := {} # vid:int -> Node3D +# §3.7 受击击退:vid -> {obj: PhysicsPush(CPhysicsObject), off: Vector2 累计位移 cm, base: 推开时的 e.pos} +var _push := {} var _main_vid := 0 var _local_vid := 0 # 由 net_play 设:这个 vid 由本地 player 代表,不生成节点 var _local_node: Node3D # 本地玩家的真模型(不挂在 _mount 下) @@ -836,6 +839,7 @@ func _on_despawn(vid: int) -> void: _duel_opponents.erase(vid) _fading.erase(vid) _chat_tails.erase(vid) + _push.erase(vid) _remove_hp_bar(vid) var n: Node3D = _by_vid.get(vid, null) if n: @@ -1279,6 +1283,8 @@ func _process(dt: float) -> void: # SNetworkActorData::UpdatePosition 式线性插值),这里只跟随 e.pos,不再 # 叠第二层 follow_lerp——否则会把 C++ 的到达时机拖慢、和动作事件脱节。 var want := _grounded(e.get("pos", n.position)) + if _push.has(vid): + want = _pushed_position(vid, e, dt, want) n.position = want # 朝向:Metin2 heading(度) -> Godot yaw。经验换算,真机再校准。 var yaw := MapCoord.heading_to_yaw(float(e.get("angle_deg", 0.0))) @@ -1318,10 +1324,61 @@ func _apply_anim(n: Node3D, f: int, moving: bool, walk_mode := 1, dead := false) func _grounded(p: Variant) -> Vector3: # p 是 M2Client 的网络帧 pos —— 先转到 Metin2World 本地帧,再贴地。 var v: Vector3 = MapCoord.to_world(p) if p is Vector3 else Vector3.ZERO + return _ground_world(v) + +func _ground_world(v: Vector3) -> Vector3: if world and world.has_method("sample_height"): v.y = float(world.call("sample_height", v.x, v.z)) return v +# §3.7 __PushCircle + IncreaseExternalForce(ActorInstanceBattle.cpp / PhysicsObject.cpp): +# dir = normalize(受击方 - 攻击方),Metin2 actor 平面帧(cm)。seam:无 IPhysicsWorld 地形碰撞回调。 +func push_victim(vid: int, dir: Vector2, force: float) -> void: + if client == null or force <= 0.0 or not _by_vid.has(vid): + return + var e: Dictionary = client.get_entity(vid) + if e.is_empty(): + return + var pe: Dictionary = _push.get(vid, {}) + if pe.is_empty(): + pe = {"obj": PhysicsPush.new(), "off": Vector2.ZERO, "base": e.get("pos")} + _push[vid] = pe + pe.obj.set_direction(dir) + pe.obj.increase_external_force(force) + +# CActorInstance::IsPushing -> m_PhysicsObject.isBlending() +func is_pushing(vid: int) -> bool: + return _push.has(vid) and bool(_push[vid].obj.is_blending()) + +# CActorInstance::GetBlendingPosition:blending 中 = 当前位置 + LastPosition(击退终点),否则当前位置。 +func blending_position(vid: int) -> Vector3: + var n: Node3D = _by_vid.get(vid, null) + if n == null: + return Vector3.ZERO + if not is_pushing(vid): + return n.global_position + var last: Vector2 = _push[vid].obj.get_last_position() + return n.global_position + Vector3(last.x / 100.0, 0.0, -last.y / 100.0) + +# CActorInstance::PhysicsProcess:每帧 m_PhysicsObject.Update + AddMovement(GetX/YMovement)。 +# 位移叠在服务器位置上;blend 结束后服务器位置一变,以服务器为准丢弃本地偏移(seam)。 +func _pushed_position(vid: int, e: Dictionary, dt: float, want: Vector3) -> Vector3: + var pe: Dictionary = _push[vid] + var obj = pe.obj + var pos: Variant = e.get("pos") + if not (pos is Vector3): + return want + if pos != pe.base: + if not obj.is_blending(): + _push.erase(vid) + return want + pe.base = pos + if obj.is_blending(): + obj.update(dt) + pe.off += obj.get_movement() + var off: Vector2 = pe.off + return _ground_world(MapCoord.to_world(pos) + Vector3(off.x / 100.0, 0.0, -off.y / 100.0)) + func _make_placeholder(d: Dictionary) -> Node3D: var root := Node3D.new() var mesh := MeshInstance3D.new() diff --git a/project/net_world_push_test.gd b/project/net_world_push_test.gd new file mode 100644 index 00000000..169100b2 --- /dev/null +++ b/project/net_world_push_test.gd @@ -0,0 +1,125 @@ +# net_world_push_test —— CLIENT-GAP §3.7 受击击退的场景侧: +# __PushCircle + IncreaseExternalForce -> net_world.push_victim +# CActorInstance::PhysicsProcess(每帧 AddMovement(GetX/YMovement))-> 节点偏移 +# GetBlendingPosition(blending 中 = 当前位置 + LastPosition)-> OnHit 的 CG_SYNC_POSITION 坐标 +# godot --headless --path project --script net_world_push_test.gd +extends SceneTree + +const NetWorld = preload("res://net_world.gd") +const PhysicsPush = preload("res://physics_push.gd") + +class FakeClient extends Node: + signal entity_spawned(entity: Dictionary) + signal entity_despawned(vid: int) + signal entity_moved(vid: int) + signal entity_main_set(vid: int) + signal entity_info(vid: int, entity: Dictionary) + signal chat(type: int, vid: int, text: String) + signal vitals_changed(vid: int) + signal entity_dead(vid: int) + signal damage(vid: int, amount: int, flag: int) + var ents := {} + func get_entity(vid: int) -> Dictionary: return ents.get(vid, {}) + func get_entities() -> Array: return ents.values() + func get_pvp_relations() -> Array: return [] + func get_duel() -> Dictionary: return {} + func add(vid: int, cm: Vector2, extra := {}) -> Dictionary: + var d := {"vid": vid, "name": "e%d" % vid, "is_main": false, + "func": 0, "moving": false, "angle_deg": 0.0, + "hp": 100, "max_hp": 100, "dead": false, "race": 0, + "affect_flags": 0, "parts": [0, 0, 0, 0]} + d.merge(extra, true) + ents[vid] = d + place(vid, cm) + return d + func place(vid: int, cm: Vector2) -> void: + ents[vid]["pos"] = Vector3(cm.x * 0.01, 0.0, -cm.y * 0.01) + ents[vid]["pos_cm"] = Vector3(cm.x, cm.y, 0.0) + +var _fail := 0 +func _ck(c: bool, m: String) -> void: + if not c: + _fail += 1 + printerr("FAIL: " + m) + +var _done := false + +func _init() -> void: + await _run() + _ck(_done, "test body ran to completion (a SCRIPT ERROR aborted _run)") + if _fail == 0: + print("PASS: net_world_push_test (push_victim / PhysicsProcess offset / GetBlendingPosition)") + quit(0) + else: + printerr("%d check(s) failed" % _fail) + quit(1) + +func _off(v: Vector2) -> Vector3: + return Vector3(v.x / 100.0, 0.0, -v.y / 100.0) + +func _run() -> void: + var mount := Node3D.new() + get_root().add_child(mount) + var fc := FakeClient.new() + get_root().add_child(fc) + var nw: Node = NetWorld.new() + get_root().add_child(nw) + nw.setup(fc, mount) + + fc.add(1, Vector2.ZERO, {"is_main": true}) + fc.entity_main_set.emit(1) + fc.entity_spawned.emit(fc.add(2, Vector2(300, 0))) + await process_frame + nw._process(0.02) + var n: Node3D = nw.node_for(2) + _ck(n != null, "victim node spawned") + if n == null: + return + _ck(not nw.is_pushing(2), "no push -> not pushing") + _ck(nw.blending_position(2).is_equal_approx(n.global_position), "not blending -> GetBlendingPosition = current position") + + # 攻击者在西边:victim - attacker = actor-world (+1, 0) + nw.push_victim(2, Vector2(1, 0), 3.0) + var ref := PhysicsPush.new() + ref.set_direction(Vector2(1, 0)) + ref.increase_external_force(3.0) + var last := ref.get_last_position() + _ck(last.x > 0.0, "reference push has a positive displacement") + _ck(nw.is_pushing(2), "push_victim -> is_pushing") + _ck(nw.blending_position(2).is_equal_approx(n.global_position + _off(last)), + "blending -> GetBlendingPosition = current + LastPosition, got %s" % nw.blending_position(2)) + + var acc := Vector2.ZERO + for i in 60: + nw._process(0.02) + ref.update(0.02) + acc += ref.get_movement() + var want: Vector3 = nw._grounded(fc.ents[2]["pos"]) + _off(acc) + _ck(acc.x > 0.0 and n.position.is_equal_approx(want), + "PhysicsProcess: node follows the accumulated ease-out movement, got %s want %s" % [n.position, want]) + + # blending 中服务器位置变化:偏移叠在新位置上(位移还在进行) + fc.place(2, Vector2(300, 50)) + nw._process(0.02) + ref.update(0.02) + acc += ref.get_movement() + want = nw._grounded(fc.ents[2]["pos"]) + _off(acc) + _ck(n.position.is_equal_approx(want), "server move while blending -> offset kept on top, got %s want %s" % [n.position, want]) + + for i in 100: + nw._process(0.02) + ref.update(0.02) + acc += ref.get_movement() + _ck(not nw.is_pushing(2), "blend over -> not pushing") + want = nw._grounded(fc.ents[2]["pos"]) + _off(acc) + _ck(n.position.is_equal_approx(want), "blend over -> actor stays where the push left it, got %s want %s" % [n.position, want]) + + # blend 结束后服务器位置再变:以服务器位置为准(丢弃本地击退偏移) + fc.place(2, Vector2(320, 50)) + nw._process(0.02) + _ck(n.position.is_equal_approx(nw._grounded(fc.ents[2]["pos"])), "server position after the blend supersedes the push offset") + + nw.push_victim(2, Vector2(0, 1), 3.0) + fc.entity_despawned.emit(2) + _ck(not nw.is_pushing(2) and not nw._push.has(2), "despawn drops the push state") + _done = true diff --git a/project/physics_push.gd b/project/physics_push.gd new file mode 100644 index 00000000..3e1c6596 --- /dev/null +++ b/project/physics_push.gd @@ -0,0 +1,129 @@ +# PhysicsPush —— CLIENT-GAP §3.7:受击击退,逐行移植 GameLib/PhysicsObject.cpp `CPhysicsObject` +# + GameLib/MapUtil.cpp `CEaseOutInterpolation`。单位 = Metin2 cm(平面 x / y)。 +# __PushCircle -> set_direction(normalize(victim - attacker)) +# IncreaseExternalForce(base, fExternalForce) -> increase_external_force(force[, collides]) +# PhysicsProcess:update(dt) 后 AddMovement(get_movement()) +extends RefCounted + +const FRAME_TIME := 0.02 # c_fFrameTime +const EPSILON := 0.001 +const LOOP_VALUE := 100 +const FLT_EPSILON := 1.192092896e-07 + +class EaseOut: + var remaining := 0.0 + var value := 0.0 + var speed := 0.0 + var acceleration := 0.0 + var start_value := 0.0 + var last_value := 0.0 + + func initialize() -> void: + remaining = 0.0 + value = 0.0 + speed = 0.0 + acceleration = 0.0 + start_value = 0.0 + last_value = 0.0 + + func setup(f_start: float, f_end: float, f_time: float) -> void: + if absf(f_time) < FLT_EPSILON: + f_time = 0.01 + value = f_start + start_value = f_start + last_value = f_start + speed = (2.0 * (f_end - f_start)) / f_time + acceleration = 2.0 * (f_end - f_start) / (f_time * f_time) - 2.0 * speed / f_time + remaining = f_time + + func interpolate(dt: float) -> void: + last_value = value + remaining -= dt + speed += acceleration * dt + value += speed * dt + if not is_playing(): + value = 0.0 + last_value = 0.0 + + func is_playing() -> bool: + return remaining > 0.0 + + func changing_value() -> float: + return value - last_value + +var mass := 1.0 +var friction := 0.3 +var direction := Vector3.ZERO +var acceleration := Vector3.ZERO +var velocity := Vector3.ZERO +var last_position := Vector3.ZERO +var _x := EaseOut.new() +var _y := EaseOut.new() + +func initialize() -> void: + mass = 1.0 + friction = 0.3 + direction = Vector3.ZERO + acceleration = Vector3.ZERO + velocity = Vector3.ZERO + last_position = Vector3.ZERO + _x.initialize() + _y.initialize() + +func set_direction(dir: Vector2) -> void: + direction = Vector3(dir.x, dir.y, 0.0) + +func update(dt: float) -> void: + if _x.is_playing(): + _x.interpolate(dt) + if _y.is_playing(): + _y.interpolate(dt) + +func _accumulate(pos: Vector3) -> Vector3: + var force := 0.0 + if absf(velocity.x) < EPSILON or absf(velocity.y) < EPSILON or absf(velocity.z) < EPSILON: + force -= mass * friction + acceleration = direction * (force / mass) + velocity += acceleration + if velocity.x * direction.x < EPSILON: + velocity.x = 0.0 + direction.x = 0.0 + if velocity.y * direction.y < EPSILON: + velocity.y = 0.0 + direction.y = 0.0 + if velocity.z * direction.z < EPSILON: + velocity.z = 0.0 + direction.z = 0.0 + return pos + velocity + +# collides(movement: Vector2) -> bool 对应 IPhysicsWorld::isPhysicalCollision(base + movement); +# 空 Callable = 没有物理世界(参考端 pWorld == NULL 分支)。 +func increase_external_force(force: float, collides := Callable()) -> void: + acceleration = direction * (force / mass) + velocity = acceleration + var movement := Vector3.ZERO + for i in LOOP_VALUE: + movement = _accumulate(movement) + if collides.is_valid() and bool(collides.call(Vector2(movement.x, movement.y))): + initialize() + return + if absf(velocity.x) < EPSILON and absf(velocity.y) < EPSILON and absf(velocity.z) < EPSILON: + break + set_last_position(Vector2(movement.x, movement.y), float(LOOP_VALUE) * FRAME_TIME) + +func set_last_position(p: Vector2, blending_time: float) -> void: + last_position = Vector3(p.x, p.y, 0.0) + _x.setup(0.0, p.x, blending_time) + _y.setup(0.0, p.y, blending_time) + +func get_last_position() -> Vector2: + return Vector2(last_position.x, last_position.y) + +# GetXMovement / GetYMovement:本帧 ease-out 增量。 +func get_movement() -> Vector2: + return Vector2(_x.changing_value(), _y.changing_value()) + +func is_blending() -> bool: + if velocity.length() != 0.0: + return true + return _x.is_playing() or _y.is_playing() diff --git a/project/physics_push_test.gd b/project/physics_push_test.gd new file mode 100644 index 00000000..4b539d20 --- /dev/null +++ b/project/physics_push_test.gd @@ -0,0 +1,72 @@ +# physics_push_test —— §3.7:受击击退物理(GameLib/PhysicsObject.cpp CPhysicsObject + +# MapUtil.cpp CEaseOutInterpolation)headless 自检。 +# godot --headless --path project --script physics_push_test.gd +extends SceneTree + +const PhysicsPush = preload("res://physics_push.gd") + +var _fail := 0 +func _ck(c: bool, m: String) -> void: + if not c: + _fail += 1 + printerr("FAIL: " + m) + +func _init() -> void: + _run() + if _fail == 0: + print("PASS: physics_push_test (external force / friction / ease-out blend)") + quit(0) + else: + printerr("%d check(s) failed" % _fail) + quit(1) + +func _run() -> void: + # 初始:不在 blending,每帧位移 0 + var p := PhysicsPush.new() + _ck(not p.is_blending(), "fresh object -> not blending") + p.update(0.02) + _ck(p.get_movement() == Vector2.ZERO, "fresh object -> zero movement") + + # IncreaseExternalForce(F=3, dir +X):vel 3 -> 每步摩擦 0.3,累计 2.7+2.4+…+0.3 = 13.5 cm + p.set_direction(Vector2(1, 0)) + p.increase_external_force(3.0) + _ck(absf(p.get_last_position().x - 13.5) < 0.001 and absf(p.get_last_position().y) < 0.001, + "F=3 along +X -> last position (13.5, 0), got %s" % p.get_last_position()) + _ck(p.is_blending(), "after push -> blending (ease-out playing)") + + # 逐帧 AddMovement(GetXMovement, GetYMovement):2 秒 ease-out 累计 ≈ 13.5(离散 Euler 少 1/N) + var sum := Vector2.ZERO + var frames := 0 + while p.is_blending() and frames < 1000: + p.update(0.02) + sum += p.get_movement() + frames += 1 + _ck(frames >= 99 and frames <= 101, "blend lasts LoopValue*c_fFrameTime = 2s, got %d frames" % frames) + _ck(absf(sum.x - 13.5) < 0.3 and absf(sum.y) < 0.001, "ease-out total movement ≈ 13.5, got %s" % sum) + p.update(0.02) + _ck(p.get_movement() == Vector2.ZERO, "blend done -> movement 0") + + # 斜向:分量各自摩擦,13.5 * cos45 + var q := PhysicsPush.new() + q.set_direction(Vector2(1, 1).normalized()) + q.increase_external_force(3.0) + _ck(absf(q.get_last_position().x - 13.5 * 0.70710678) < 0.01 and absf(q.get_last_position().y - 13.5 * 0.70710678) < 0.01, + "diagonal push -> per-axis 9.546, got %s" % q.get_last_position()) + + # 分量 vel*dir < EPSILON(0.001) 当步清零(3*0.01*0.01 = 3e-4) + var w := PhysicsPush.new() + w.set_direction(Vector2(0.99995, 0.01)) + w.increase_external_force(3.0) + _ck(w.get_last_position().y == 0.0, "tiny axis component zeroed on first step, got %s" % w.get_last_position()) + + # 力为 0:没有位移,也不 blending(Setup(0,0,2) 仍在播放 -> isBlending 为真,与参考端一致) + var z := PhysicsPush.new() + z.set_direction(Vector2(1, 0)) + z.increase_external_force(0.0) + _ck(z.get_last_position() == Vector2.ZERO, "zero force -> no movement") + + # 撞墙(IPhysicsWorld::isPhysicalCollision 为真)-> Initialize(),不推 + var c := PhysicsPush.new() + c.set_direction(Vector2(1, 0)) + c.increase_external_force(3.0, func(_moved: Vector2) -> bool: return true) + _ck(c.get_last_position() == Vector2.ZERO and not c.is_blending(), "world collision -> Initialize, no push") diff --git a/project/player_controller.gd b/project/player_controller.gd index b6841039..ee89e0f2 100644 --- a/project/player_controller.gd +++ b/project/player_controller.gd @@ -363,9 +363,11 @@ func _process(dt: float) -> void: _mobile_active = true _is_going = false _last_wasd = Vector2.ZERO + # GameCamera 位于 head + (sin yaw, cos yaw)*dist 并看向 head:视线的水平前方是 + # -(sin yaw, cos yaw),屏幕右 = 前方 × UP。 var mobile_yaw: float = (camera.heading() if camera and camera.has_method("heading") else 0.0) - var mobile_fwd := Vector3(sin(mobile_yaw), 0, cos(mobile_yaw)) - var mobile_right := Vector3(mobile_fwd.z, 0, -mobile_fwd.x) + var mobile_fwd := -Vector3(sin(mobile_yaw), 0, cos(mobile_yaw)) + var mobile_right := Vector3(-mobile_fwd.z, 0, mobile_fwd.x) wish = (mobile_fwd * -mobile_axis.y + mobile_right * mobile_axis.x).normalized() elif wasd != Vector2.ZERO: _mobile_active = false @@ -379,8 +381,8 @@ func _process(dt: float) -> void: _last_wasd = wasd _is_going = false # 键盘覆盖点地(NEW_MoveToDirection:m_isGoing = FALSE) var yaw: float = (camera.heading() if camera and camera.has_method("heading") else 0.0) - var fwd := Vector3(sin(yaw), 0, cos(yaw)) - var right := Vector3(fwd.z, 0, -fwd.x) + var fwd := -Vector3(sin(yaw), 0, cos(yaw)) + var right := Vector3(-fwd.z, 0, fwd.x) wish = (fwd * -wasd.y + right * wasd.x).normalized() elif _is_going: _mobile_active = false diff --git a/project/player_move_test.gd b/project/player_move_test.gd index 50666793..acd3aa0e 100644 --- a/project/player_move_test.gd +++ b/project/player_move_test.gd @@ -5,6 +5,7 @@ extends SceneTree const PlayerCtl = preload("res://player_controller.gd") +const GameCam = preload("res://game_camera.gd") var _fail := 0 func _ck(c: bool, m: String) -> void: @@ -142,3 +143,44 @@ func _run() -> void: "mounted: turn clamped to 300 deg/s in one 50ms step (%.3f rad)" % pc.player.rotation.y) _ck(pc.player.position.z < -0.05, "mounted: still translates toward Dst while turning") pc.rotation_speed_deg = pc.ROT_SPEED_DEFAULT_DEG + + # 8) 相机相对移动:W / 摇杆上 = 远离相机(沿视线),D / 摇杆右 = 屏幕右。 + # GameCamera 摆在 head + (sin yaw, ·, cos yaw)*dist 并看向 head,前方取真实相机几何。 + var cam: Camera3D = GameCam.new() + get_root().add_child(cam) + cam.target = pc.player + cam.yaw = deg_to_rad(30.0) + pc.camera = cam + pc.player.rotation.y = 0.0 + var off: Vector3 = cam._desired_pos(true) - (pc.player.global_position + cam.head_offset) + var away := -Vector3(off.x, 0, off.z).normalized() + var screen_right := away.cross(Vector3.UP) + for c in [["mobile axis up", Vector2(0, -1), away], ["mobile axis right", Vector2(1, 0), screen_right]]: + pc.player.position = Vector3.ZERO + pc._is_going = false + pc.set_mobile_axis(c[1]) + pc._process(0.1) + pc.clear_mobile_input() + var moved := Vector3(pc.player.position.x, 0, pc.player.position.z) + _ck(moved.length() > 0.05 and moved.normalized().dot(c[2]) > 0.95, + "%s moves camera-relative (want %s, moved %s)" % [c[0], c[2], moved]) + for c in [[KEY_W, "W", away], [KEY_S, "S", -away], [KEY_D, "D", screen_right]]: + pc.player.position = Vector3.ZERO + pc._is_going = false + var down := InputEventKey.new() + down.keycode = c[0] + down.physical_keycode = c[0] + down.pressed = true + Input.parse_input_event(down) + Input.flush_buffered_events() + _ck(Input.is_key_pressed(c[0]), "synthetic %s press reaches Input" % c[1]) + pc._process(0.1) + var up := down.duplicate() + up.pressed = false + Input.parse_input_event(up) + Input.flush_buffered_events() + var moved := Vector3(pc.player.position.x, 0, pc.player.position.z) + _ck(moved.length() > 0.05 and moved.normalized().dot(c[2]) > 0.95, + "key %s moves camera-relative (want %s, moved %s)" % [c[1], c[2], moved]) + pc.camera = null + cam.free() diff --git a/project/race_motion_assembly_test.gd b/project/race_motion_assembly_test.gd index fbd0bcd4..93a63e91 100644 --- a/project/race_motion_assembly_test.gd +++ b/project/race_motion_assembly_test.gd @@ -33,7 +33,9 @@ func run() -> void: "race %d: hair is from a different resource family" % race) for motion in ["wait", "walk", "run", "attack", "damage", "dead"]: pv.set_anim_state(motion) - check(String(pv.anim.get("anim_path")).get_file() == motion + ".msa", + # damage is registered as two equal-weight variants (damage / damage_1). + var variants: Array = PlayerView.REACTION_FILES.get(motion, [motion]) + check(String(pv.anim.get("anim_path")).get_file().get_basename() in variants, "race %d: missing %s motion" % [race, motion]) for fraction in [0.0, 0.5, 1.0]: pv.anim.set("time", float(pv.anim.call("get_duration")) * fraction) diff --git a/project/skill_test.gd b/project/skill_test.gd index b67be12c..35f4ead0 100644 --- a/project/skill_test.gd +++ b/project/skill_test.gd @@ -189,6 +189,26 @@ func _run() -> void: _ck(st.is_fan_range(91), "skill 91 IsFanRange(bipabu FAN_RANGE)") _ck(st.target_range(91) == 1800, "target_range(91) == 1800(bipabu.msk Range)") _ck(st.target_count(91, 20) == 0, "target_count(91) == 0(bipabu 无 TargetCountFormula -> 仅主目标)") + # LoadLocaleData:RegisterSkillDesc 后 RegisterSkillTable(PythonApplication.cpp:1049-1055)。 + # RegisterSkill(.msk) 在参考端没有调用点,dwTargetRange 只来自 SkillTable.txt 的 + # TARGET_RANGE 列;GetTargetRange(PythonSkill.cpp:1086)对 MELEE/CHARGE_ATTACK 固定 170。 + var tbl := SkillTable.new() + for lang in ["en", "common"]: + if tbl.load_file(assets.path_join("locale/locale/%s/skilldesc.txt" % lang)): + break + var table_loaded: bool = tbl.has_method("load_table") \ + and bool(tbl.call("load_table", assets.path_join("locale/locale/common/skilltable.txt"))) + _ck(table_loaded, "load_table(common/skilltable.txt) succeeds") + if table_loaded: + _ck(tbl.target_range(1) == 0, "SkillTable TARGET_RANGE 0 overrides samyeon.msk Range: %d" % tbl.target_range(1)) + _ck(tbl.target_range(47) == 2500, "target_range(47) == 2500(SkillTable)") + _ck(tbl.target_range(91) == 1800, "target_range(91) == 1800(SkillTable)") + _ck(tbl.target_range(31) == 170, "MELEE_ATTACK 31 -> MELEE_SKILL_TARGET_RANGE 170: %d" % tbl.target_range(31)) + _ck(tbl.target_range(5) == 170, "CHARGE_ATTACK 5 -> MELEE_SKILL_TARGET_RANGE 170: %d" % tbl.target_range(5)) + # 预约趋近要在 Actor 碰撞(两 body 各 55cm)挡住前进入射程。 + for id in tbl._by_id.keys(): + var r := tbl.target_range(int(id)) + _ck(r == 0 or r - 10 > 110, "skill %d range %d is reachable past actor collision" % [id, r]) # 非扇形/圆形技能:fly_shape == SINGLE(0),无 target_range _ck(st.fly_shape(1) == 0, "fly_shape(1) == SINGLE(0)(samyeon 非范围技)") _ck(st.target_count(1, 10) == 0, "target_count(1) == 0(无 TargetCountFormula)") diff --git a/project/ui/equip_model.gd b/project/ui/equip_model.gd index 01d9def5..807a9182 100644 --- a/project/ui/equip_model.gd +++ b/project/ui/equip_model.gd @@ -5,8 +5,10 @@ # em.setup(m2client, item_list, model_getter, assets_root, race) # # 监听 M2Client.inventory_changed(window == EQUIPMENT),读 get_equipment(): -# WEAR_WEAPON(4) -> model.weapon_gr2 = item_list.model(vnum) 解析后的真实路径 -# WEAR_SHIELD(10)-> model.shield_gr2(左手刚体挂点,同 weapon) +# WEAR_WEAPON(4) -> item_list.model(vnum) 解析后的真实路径,按左右手分给 +# model.weapon_gr2(PART_WEAPON)/ model.shield_gr2(PART_WEAPON_LEFT) +# (ActorInstanceAttach.cpp AttachWeapon:匕首双手、弓左手、骑马的扇双手) +# WEAR_SHIELD(10)-> 不渲染(参考端没有盾牌挂件,item_list 的盾也没有模型) # WEAR_HEAD(1) -> 有模型的头盔 = 覆盖发型槽(Metin2 约定:头防替换头发) # WEAR_BODY(0) -> race_spec.shape(armor_shape_of(vnum)) -> model.gr2_path (+ 换肤) # armor_model_map[vnum] 若给了则优先 @@ -34,6 +36,12 @@ const PART_WEAPON := 1 const PART_HEAD := 2 const PART_HAIR := 3 const CHR_EQUIPPART_HAIR := 3 # 兼容旧引用 +# item_length.h EWeaponSubTypes(CItemData::GetWeaponType = bSubType) +const WEAPON_SUB_DAGGER := 1 +const WEAPON_SUB_BOW := 2 +const WEAPON_SUB_FAN := 5 +const HAND_RIGHT := 1 +const HAND_LEFT := 2 const CLASS_OF := ["warrior", "assassin", "sura", "shaman"] var client: Node @@ -47,7 +55,7 @@ var _spec_tried := false var _last_weapon_vnum := -1 var _last_body_vnum := -1 var _last_head_vnum := -2 # -1 是「发型被遮挡」的有效态,用 -2 当「未应用」哨兵 -var _last_shield_vnum := -1 +var _last_weapon_hands := -1 var _last_hair_part := -1 var main_getter: Callable = Callable() # func() -> int:主角 vid(取 parts 用) var _remote_mode := false @@ -77,6 +85,11 @@ func setup(m2client: Node, il: RefCounted, model_getter: Callable, assets := "", client.entity_info.connect(func(v, _d): if main_getter.is_valid() and int(main_getter.call()) == v: refresh()) + # 上 / 下马改变扇子的左右手(__IsLeftHandWeapon: FAN && IsMountingHorse) + if client and client.has_signal("mount_changed"): + client.mount_changed.connect(func(v): + if main_getter.is_valid() and int(main_getter.call()) == v: + refresh()) # 远端 PC 不读取本地 get_equipment();参考端直接消费 # GC_CHAR_ADDITIONAL_INFO / GC_CHARACTER_UPDATE 的 awPart[4]。 @@ -139,17 +152,34 @@ func refresh() -> void: # --- 武器:parts[WEAPON] 优先,为 0 回退 WEAR_WEAPON;过 PartHiding 遮挡 --------- var wpn_vnum := _eff(parts, PART_WEAPON, eq, WEAR_WEAPON) var wpn_eff := PartHiding.effective_weapon(wpn_vnum, raw_shape, is_poly) - if wpn_eff != _last_weapon_vnum: + var hands := _weapon_hands(wpn_eff) + if wpn_eff != _last_weapon_vnum or hands != _last_weapon_hands: _last_weapon_vnum = wpn_eff - model.set("weapon_gr2", _resolve_weapon(wpn_eff)) + _last_weapon_hands = hands + # 左手用 GetSubModelThing():item .msm 加载在参考端被注释掉,恒为同一个 gr2 + var wpath := _resolve_weapon(wpn_eff) + model.set("weapon_gr2", wpath if hands & HAND_RIGHT else "") + model.set("shield_gr2", wpath if hands & HAND_LEFT else "") - # --- 盾(左手挂点,同武器遮挡规则)-------------------------------------------- - if eq.size() > WEAR_SHIELD: - var sh_vnum := int(eq[WEAR_SHIELD].get("vnum", 0)) - var sh_eff := 0 if PartHiding.weapon_hidden(raw_shape, is_poly, sh_vnum) else sh_vnum - if sh_eff != _last_shield_vnum: - _last_shield_vnum = sh_eff - model.set("shield_gr2", _resolve_weapon(sh_eff)) +# ActorInstanceAttach.cpp __IsRightHandWeapon / __IsLeftHandWeapon -> HAND_* 位掩码 +func _weapon_hands(vnum: int) -> int: + if vnum == 0: + return 0 + var sub := -1 + if proto and proto.has_method("item"): + sub = int(proto.item(vnum).get("sub_type", -1)) + if sub == WEAPON_SUB_DAGGER or (sub == WEAPON_SUB_FAN and _is_mounting()): + return HAND_RIGHT | HAND_LEFT + if sub == WEAPON_SUB_BOW: + return HAND_LEFT + return HAND_RIGHT + +# IsMountingHorse:主角实体 mount_vnum 非 0。远端 PC 暂无坐骑状态输入(seam)。 +func _is_mounting() -> bool: + if _remote_mode or not main_getter.is_valid() or client == null or not client.has_method("get_entity"): + return false + var vid: int = main_getter.call() + return vid != 0 and int(client.get_entity(vid).get("mount_vnum", 0)) != 0 # §4.1 修改 4 seam:40250 实体表暂无变身字段(m2_client.cpp entity dict 无 poly_race / # polymorph)。一旦 GC_CHARACTER_UPDATE / GC_CHAR_ADD_INFO 带上变身 race,这里改成读 diff --git a/project/ui/hit_reaction.gd b/project/ui/hit_reaction.gd new file mode 100644 index 00000000..e6139acc --- /dev/null +++ b/project/ui/hit_reaction.gd @@ -0,0 +1,127 @@ +# HitReaction —— CLIENT-GAP §3.7 受击方视图(PlayerView / MobView)共用的受击状态机。 +# __HitGood / __HitGreate / __HitStone(ActorInstanceBattle.cpp)的分支判定、 +# __Shake + ShakeProcess(世界矩阵平移 ±rand()%10 cm)、CGraphicThingInstance::InsertDelay、 +# InterceptOnceMotion + PushOnceMotion + PushLoopMotion 的动作链。 +# 视图只提供「按状态名绑定一段一次性动作」的 Callable(bind(step) -> bool,资源缺失 = false, +# 等同 GetMotionKey 失败时 InterceptOnceMotion / PushOnceMotion 返回 FALSE)。 +extends RefCounted + +const SHAKE_TIME := 0.1 # __Shake(100):m_dwShakeTime = now + 100 ms +const SHAKE_RAND_CM := 10 # ShakeProcess:rand() % 10 +const LOOP_STATES := ["wait", "walk", "run"] +const DAMAGE_STATES := ["damage", "damage_back"] # IsDamage 类 +const KNOCKDOWN_STATES := ["knockdown", "knockdown_back"] # IsKnockDown:DAMAGE_FLYING(_BACK) +const STANDUP_STATES := ["standup", "standup_back"] # __IsStandUpMotion:STAND_UP(_BACK) +# CActorInstance::isLock:NORMAL_ATTACK / COMBO_ATTACK_* / SPECIAL_* / 钓鱼 / 表情跳舞等 +const LOCK_STATES := ["attack", "combo", "skill", "__motion", "emotion", "dig", + "fishing", "fishing_react", "fishing_catch", "fishing_fail"] +const SKILL_STATES := ["skill"] # IsUsingSkill:SKILL 段 / SPECIAL_1..6 + +var delay_left := 0.0 +var speed := 1.0 # 当前动作的 fSpeedRatio(InsertDelay 结束后恢复) +var shake_left := 0.0 +var queue: Array = [] # 剩余的 PushOnceMotion 步 +var resume := "wait" # 链尾 PushLoopMotion 的循环动作 +var active := false # 受击动作链正在播 +var real_dead := false # m_isRealDead:晕眩击倒后不起身 +var _shaking := false +var _shake_base := Vector3.ZERO + +static func is_reaction(state: String) -> bool: + return state in DAMAGE_STATES or state in KNOCKDOWN_STATES or state in STANDUP_STATES + +# __HitGood:返回候选动作链(按顺序尝试,首步绑定成功的那条生效)。 +func good(state: String, scalar: float, stunned: bool) -> Array: + if state in KNOCKDOWN_STATES: + return [] + if stunned: + return [] # IsStun() -> Die():死亡动作由服务器驱动(seam) + shake() + if state in LOCK_STATES: + return [] + if scalar < 0.0: + return [["damage"]] + return [["damage_back"], ["damage"]] + +# __HitGreate +func greate(state: String, scalar: float, stunned: bool) -> Array: + if state in KNOCKDOWN_STATES or state in STANDUP_STATES: + return [] + shake() + if state in SKILL_STATES: + return [] + if stunned: + if scalar < 0.0: + return [["knockdown"]] + return [["knockdown_back"], ["knockdown"]] + if scalar < 0.0: + return [["knockdown", "standup"]] + return [["knockdown_back", "standup_back"], ["knockdown", "standup"]] + +# __HitStone +func stone(stunned: bool) -> void: + if not stunned: + shake() + +func shake() -> void: + shake_left = SHAKE_TIME + +func insert_delay(d: float, anim: Object) -> void: + if d <= 0.0: + return + delay_left = d + if anim: + anim.set("time_scale", 0.0) + +# 绑定新动作时的 time_scale:InsertDelay 期间保持冻结。 +func scale_for(ratio: float) -> float: + speed = ratio + return 0.0 if delay_left > 0.0 else ratio + +# 首步绑定成功 -> 记下链尾要回的循环动作,剩余步入队。 +func start(chains: Array, current: String, bind: Callable, dead_after := false) -> bool: + for chain: Array in chains: + if chain.is_empty() or not bool(bind.call(String(chain[0]))): + continue + if not is_reaction(current): + resume = current if current in LOOP_STATES else "wait" + queue = chain.slice(1) + active = true + real_dead = dead_after + return true + return false + +# 一段受击动作播完:绑下一步(缺资源的步跳过);返回 false = 链结束。 +func advance(bind: Callable) -> bool: + while not queue.is_empty(): + if bool(bind.call(String(queue.pop_front()))): + return true + active = false + return false + +func cancel() -> void: + active = false + queue.clear() + real_dead = false + +func tick(dt: float, anim: Object, model: Node3D) -> void: + if delay_left > 0.0: + delay_left -= dt + if delay_left <= 0.0 and anim: + anim.set("time_scale", speed) + if shake_left > 0.0: + shake_left -= dt + if model == null: + return + if shake_left > 0.0: + if not _shaking: + _shaking = true + _shake_base = model.position + model.position = _shake_base + Vector3(_rand_cm(), _rand_cm(), _rand_cm()) / 100.0 + elif _shaking: + _shaking = false + model.position = _shake_base + +static func _rand_cm() -> float: + var v := float(randi() % SHAKE_RAND_CM) + return v if randi() % 2 == 0 else -v diff --git a/project/ui/mob_view.gd b/project/ui/mob_view.gd index 1fe6a5ad..e67affd8 100644 --- a/project/ui/mob_view.gd +++ b/project/ui/mob_view.gd @@ -21,11 +21,25 @@ const STATE_MOTIONS := { "damage": ["FRONT_DAMAGE", "DAMAGE", "BACK_DAMAGE"], "dead": ["FRONT_DEAD", "DEAD", "BACK_DEAD"], "emotion": ["WAIT"], + # §3.7 受击动作步(motlist 名 -> NAME_DAMAGE_BACK / DAMAGE_FLYING(_BACK) / STAND_UP(_BACK))。 + # 不加别名:缺哪段就让 InterceptOnceMotion / PushOnceMotion 失败,与参考端一致。 + "damage_back": ["BACK_DAMAGE"], + "knockdown": ["FRONT_KNOCKDOWN"], + "knockdown_back": ["BACK_KNOCKDOWN"], + "standup": ["FRONT_STANDUP"], + "standup_back": ["BACK_STANDUP"], } +const HitReaction = preload("res://ui/hit_reaction.gd") +const HitCollision = preload("res://hit_collision.gd") + +# CActorInstance::__SetMotion 尾:动作绑定完成。 +signal motion_bound(state: String) var model: Node # Metin2Model var anim: Node # Metin2AnimPlayer var _dir := "" +var _mesh_stem := "" +var _hit := HitReaction.new() var _motions := {} # MOTION_NAME(String) -> 绝对 .msa 路径 var _state := "" var _last_resolution := {} @@ -89,6 +103,7 @@ func build(assets_root: String, proto: Node, race: int, pump := Callable()) -> b break if _dir == "": return false + _mesh_stem = mesh_stem var gr2 := _dir.path_join(mesh_stem + ".gr2") if not FileAccess.file_exists(gr2): gr2 = _dir.path_join(_dir.get_file() + ".gr2") # 目录同名主网格 @@ -114,6 +129,8 @@ func build(assets_root: String, proto: Node, race: int, pump := Callable()) -> b anim.set("model_path", NodePath("../Metin2Model")) anim.set("blend_time", 0.15) add_child(anim) + if anim.has_signal("playback_finished"): + anim.playback_finished.connect(_on_playback_finished) set_anim_state("wait") CharShadow.attach(self, 1.6) # 怪脚印大一点 return true @@ -125,15 +142,87 @@ func set_audio(audio_node: Node) -> void: func set_anim_state(s: String) -> void: if s == _state or anim == null: return + if HitReaction.is_reaction(s): + _hit.start([[s]], _state, _bind_reaction) + return + # 受击动作链播放中:循环动作请求留到链尾 PushLoopMotion 再回(seam:参考端动作队列) + if _hit.active and s in HitReaction.LOOP_STATES: + _hit.resume = s + return + _hit.cancel() _state = s _last_resolution = resolve_motion(s) var msa := String(_last_resolution.path) if msa == "": # 旧客户端 GetMotionKey 失败时 SetLoopMotion/InterceptMotion 直接返回:保留当前动作。 return - anim.set("loop", s in ["wait", "walk", "run"]) - anim.set("anim_path", msa) - _refresh_sound_script(msa) + _bind(s, msa, s in HitReaction.LOOP_STATES, 1.0) + +# CGraphicThingInstance::InsertDelay(fStiffenTime) +func insert_delay(d: float) -> void: + _hit.insert_delay(d, anim) + +# CActorInstance::__HitGood / __HitGreate / __HitStone(ActorInstanceBattle.cpp) +func hit_good(scalar: float, stunned: bool) -> void: + _hit.start(_hit.good(_state, scalar, stunned), _state, _bind_reaction) + +func hit_greate(scalar: float, stunned: bool) -> void: + _hit.start(_hit.greate(_state, scalar, stunned), _state, _bind_reaction, stunned) + +func hit_stone(stunned: bool) -> void: + _hit.stone(stunned) + +func is_in_hit_reaction() -> bool: + return _hit.active + +# 受击方防御球:怪目录的 .msm(<网格代号>.msm / <目录名>.msm / 目录里第一个 .msm)。 +func get_defending_spheres() -> Array: + if _dir == "": + return [] + var cands: Array = [] + if _mesh_stem != "": + cands.append(_dir.path_join(_mesh_stem + ".msm")) + cands.append(_dir.path_join(_dir.get_file() + ".msm")) + for p: String in cands: + if FileAccess.file_exists(p): + return HitCollision.parse_msm_defending(p) + var da := DirAccess.open(_dir) + if da: + for fn in da.get_files(): + if fn.to_lower().ends_with(".msm"): + return HitCollision.parse_msm_defending(_dir.path_join(fn)) + return [] + +func _on_playback_finished() -> void: + if not _hit.active: + return + if _hit.advance(_bind_reaction) or _hit.real_dead: + return + _state = "" + set_anim_state(_hit.resume) + +func _bind_reaction(step: String) -> bool: + if anim == null: + return false + var r := resolve_motion(step) + _last_resolution = r + var msa := String(r.path) + if msa == "": + return false + _bind(step, msa, false, 1.0) + return true + +func _bind(state: String, path: String, loop: bool, speed_ratio: float) -> void: + if not HitReaction.is_reaction(state): + _hit.cancel() + _state = state + anim.set("loop", loop) + if not loop and String(anim.get("anim_path")) == path: + anim.set("anim_path", "") # 同一段一次性动作再触发:先清,否则原生播放器视为无变化 + anim.set("anim_path", path) + anim.set("time_scale", _hit.scale_for(speed_ratio)) + _refresh_sound_script(path) + motion_bound.emit(state) # Read-only motion resolver used by the forest acceptance harness. Returning # the selected .msa before playback lets the test distinguish a real action @@ -169,7 +258,8 @@ func resolve_motion(state: String) -> Dictionary: func last_motion_resolution() -> Dictionary: return _last_resolution.duplicate() -func _process(_delta: float) -> void: +func _process(delta: float) -> void: + _hit.tick(delta, anim, model as Node3D) # InsertDelay + ShakeProcess if _audio == null or anim == null or _sound_instances.is_empty() or not anim.has_method("get_time") \ or not bool(anim.get("playing")): return diff --git a/project/ui/player_view.gd b/project/ui/player_view.gd index bd6110c3..99e65510 100644 --- a/project/ui/player_view.gd +++ b/project/ui/player_view.gd @@ -13,6 +13,30 @@ extends Node3D # 4=WAR_W 5=ASN_M 6=SURA_W 7=SHA_M const CLASS_OF := ["warrior", "assassin", "sura", "shaman"] const FEMALE_RACES := [1, 3, 4, 6] +# playersettingmodule.py RegisterAttachingBoneName(按职业,男女同名): +# PART_WEAPON -> model.weapon_bone;PART_WEAPON_LEFT -> model.shield_bone("" = 未登记,不挂) +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 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", + 5: "bow", 6: "fan", 7: "bell", 8: "fishing", 9: "horse", 10: "horse_onehand_sword", + 11: "horse_twohand_sword", 12: "horse_dualhand_sword", 13: "horse_bow", 14: "horse_fan", + 15: "horse_bell", 16: "wedding"} +const MOTION_NORMAL_ATTACK := 13 # CRaceMotionData::NAME_NORMAL_ATTACK;COMBO_ATTACK_1 = 14 +# 受击动作步 -> playersettingmodule RegisterMotionData 的文件(同名多份按权重随机,这里等权) +const REACTION_FILES := { + "damage": ["damage", "damage_1"], # NAME_DAMAGE + "damage_back": ["damage_2", "damage_3"], # NAME_DAMAGE_BACK + "knockdown": ["damage_flying"], # NAME_DAMAGE_FLYING + "knockdown_back": ["back_damage_flying"], # NAME_DAMAGE_FLYING_BACK + "standup": ["falling_stand"], # NAME_STAND_UP + "standup_back": ["back_falling_stand"], # NAME_STAND_UP_BACK +} + +# CActorInstance::__SetMotion 尾:动作绑定完成(net_play 据此清 m_HitDataMap / 连击段号)。 +signal motion_bound(state: String) var model: Node # Metin2Model var anim: Node # Metin2AnimPlayer @@ -24,11 +48,15 @@ var _audio: Node var _sound_instances: Array = [] var _sound_frame := -1 var _forward := ["weapon_gr2", "shield_gr2", "gr2_path", "hair_gr2", "hair_skin", "specular_power"] +var _left_bone := "" +var _race := 0 +var _hit := HitReaction.new() # pump 可空:每个重活(gr2 解析 / LOD 扫描 / hair 折叠 / .msa 解析)之间调一下 # 它(= M2Client.net_poll),免得整段阻塞几秒漏 PONG 被服务器踢。 func build(assets_root: String, race: int, pump := Callable()) -> bool: _assets_root = assets_root + _race = race if race < 0 or race > 7 or not ClassDB.class_exists("Metin2Model"): return false var cls: String = CLASS_OF[race & 3] @@ -45,6 +73,12 @@ func build(assets_root: String, race: int, pump := Callable()) -> bool: # PC GR2 surfaces need the same outward winding in bind, CPU and GPU poses. model.set("flip_winding", true) model.set("texture_dir", base) + # 挂点骨骼名要在 gr2_path 之前设:Metin2Model 默认 equip_right_hand 只有战士骨架有, + # 其余职业找不到骨骼就不建 Weapon 节点(武器不在手上)。 + model.set("weapon_bone", WEAPON_BONE[race & 3]) + _left_bone = WEAPON_LEFT_BONE[race & 3] + if _left_bone != "": + model.set("shield_bone", _left_bone) if pump.is_valid(): pump.call() var body := _first_existing([base.path_join("%s_novice.gr2" % cls), base.path_join("%s.gr2" % cls)]) if body == "": @@ -88,14 +122,20 @@ func set_audio(audio_node: Node) -> void: func set_anim_state(s: String) -> void: if s == _state or anim == null or motion_dir == "": return + if HitReaction.is_reaction(s): + _hit.start([[s]], _state, _bind_reaction) + return + # 受击动作链播放中:循环动作请求留到链尾 PushLoopMotion 再回(seam:参考端动作队列) + if _hit.active and s in HitReaction.LOOP_STATES: + _hit.resume = s + return + _hit.cancel() _state = s var msa := motion_dir.path_join(s + ".msa") if not FileAccess.file_exists(msa): msa = motion_dir.path_join(s + ".gr2") if FileAccess.file_exists(msa): - anim.set("loop", s in ["wait", "walk", "run"]) - anim.set("anim_path", msa) - _refresh_sound_script(msa) + _bind(s, msa, s in HitReaction.LOOP_STATES, 1.0) # 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 @@ -163,29 +203,97 @@ func set_motion_id(motion: int, target_race: int = -1) -> bool: msa = motion_dir.path_join(name + ".msa") if not FileAccess.file_exists(msa): return false - _state = "__motion" - anim.set("loop", false) - # A repeated command can name the same clip; clear first because the native - # player treats assigning the current anim_path as a no-op. - if String(anim.get("anim_path")) == msa: - anim.set("anim_path", "") - anim.set("anim_path", msa) - _refresh_sound_script(msa) + _bind("__motion", msa, false, 1.0) return true +# §3.7 CActorInstance::__SetMotion(SSetMotionData{ MAKE_MOTION_KEY(mode, index), fSpeedRatio }): +# 攻击段动作按武器动作模式目录绑定(NORMAL_ATTACK -> attack(_1).msa,COMBO_ATTACK_N -> combo_0N.msa), +# 模式目录缺该段时退回 general 的 attack(_1).msa(GENERAL 模式的 COMBO_ATTACK_* 也注册这两份)。 +func play_attack_motion(mode: int, index: int, speed_ratio: float) -> void: + if anim == null or motion_dir == "": + return + var mode_dir := motion_dir.get_base_dir().path_join(String(MOTION_MODE_DIRS.get(mode, "general"))) + var names: Array = ["attack", "attack_1"] + if index > MOTION_NORMAL_ATTACK: + names = ["combo_%02d" % (index - MOTION_NORMAL_ATTACK)] + var msa := _pick_msa(mode_dir, names) + if msa == "": + msa = _pick_msa(motion_dir, ["attack", "attack_1"]) + if msa == "": + return + _bind("attack", msa, false, speed_ratio if speed_ratio > 0.0 else 1.0) + +# CGraphicThingInstance::InsertDelay(fStiffenTime):动作冻结,结束后恢复 fSpeedRatio。 +func insert_delay(d: float) -> void: + _hit.insert_delay(d, anim) + +# CActorInstance::__HitGood / __HitGreate / __HitStone(ActorInstanceBattle.cpp)。 +# scalar = dot(攻击方朝向, 受击方朝向):< 0 面对面 -> 正面受击动作。 +func hit_good(scalar: float, stunned: bool) -> void: + _hit.start(_hit.good(_state, scalar, stunned), _state, _bind_reaction) + +func hit_greate(scalar: float, stunned: bool) -> void: + _hit.start(_hit.greate(_state, scalar, stunned), _state, _bind_reaction, stunned) + +func hit_stone(stunned: bool) -> void: + _hit.stone(stunned) + +# 受击方 m_DefendingPointInstanceList 的来源:root/msm/_.msm 的 DEFENDING 球。 +func get_defending_spheres() -> Array: + var sex := "w" if _race in FEMALE_RACES else "m" + return HitCollision.parse_msm_defending( + _assets_root.path_join("root/msm/%s_%s.msm" % [CLASS_OF[_race & 3], sex])) + func _on_playback_finished() -> void: - # CLIENT-GAP §3.7: the hit reaction ("damage") is a one-shot knockback clip. - # When it ends the actor leaves the pushed state (CActorInstance::IsPushing), - # so fall back to idle — net_play polls is_in_hit_reaction() to drop its gate. - if _state == "__motion" or _state == "damage": + # §3.7 受击动作链:InterceptOnceMotion -> PushOnceMotion(STAND_UP…) -> PushLoopMotion。 + # 链尾回循环动作 —— net_play polls is_in_hit_reaction() to drop its knockback gate. + if _hit.active: + if _hit.advance(_bind_reaction) or _hit.real_dead: + return + _state = "" + set_anim_state(_hit.resume) + return + if _state == "__motion": _state = "" set_anim_state("wait") -# True while the one-shot hit/knockback clip is still playing. +# True while the one-shot hit/knockback chain is still playing. func is_in_hit_reaction() -> bool: - return _state == "damage" + return _hit.active -func _process(_delta: float) -> void: +func _bind_reaction(step: String) -> bool: + if anim == null or motion_dir == "": + return false + var msa := _pick_msa(motion_dir, REACTION_FILES.get(step, [])) + if msa == "": + return false + _bind(step, msa, false, 1.0) + return true + +func _bind(state: String, path: String, loop: bool, speed_ratio: float) -> void: + if not HitReaction.is_reaction(state): + _hit.cancel() + _state = state + anim.set("loop", loop) + # A repeated one-shot can name the same clip; clear first because the native + # player treats assigning the current anim_path as a no-op. + if not loop and String(anim.get("anim_path")) == path: + anim.set("anim_path", "") + anim.set("anim_path", path) + anim.set("time_scale", _hit.scale_for(speed_ratio)) + _refresh_sound_script(path) + motion_bound.emit(state) + +func _pick_msa(dir: String, names: Array) -> String: + var found: Array = [] + for n in names: + var p := dir.path_join(String(n) + ".msa") + if FileAccess.file_exists(p): + found.append(p) + return "" if found.is_empty() else String(found[randi() % found.size()]) + +func _process(delta: float) -> void: + _hit.tick(delta, anim, model as Node3D) # InsertDelay + ShakeProcess if _audio == null or anim == null or _sound_instances.is_empty() or not anim.has_method("get_time") \ or not bool(anim.get("playing")): return @@ -207,6 +315,9 @@ func _refresh_sound_script(motion_path: String) -> void: func _set(prop: StringName, val: Variant) -> bool: if String(prop) in _forward and model: + # ActorInstanceAttach.cpp AttachWeapon:该职业没登记 PART_WEAPON_LEFT 骨骼就不挂左手 + if String(prop) == "shield_gr2" and _left_bone == "": + val = "" model.set(prop, val) if String(prop) == "gr2_path": call_deferred("_ground_model") diff --git a/project/ui/skill_table.gd b/project/ui/skill_table.gd index a5d4ebde..7a353cfb 100644 --- a/project/ui/skill_table.gd +++ b/project/ui/skill_table.gd @@ -162,6 +162,60 @@ func load_file(path: String) -> bool: count = _by_id.size() return count > 0 +# RegisterSkillTable(PythonSkill.cpp:90):SkillTable.txt 按 TAB 切分,列数不等于 +# TABLE_TOKEN_TYPE_MAX_NUM 的行、skilldesc 里没有的 vnum 都跳过。参考端 RegisterSkill(.msk) +# 没有调用点,TSkillData 构造时 dwTargetRange = 0,只由这里非空的 TARGET_RANGE 列覆盖; +# 所以表生效后不再沿用 .msk 的 Range。第 2 列是 CP949 技能名,按字节切分避免解码告警。 +const TABLE_TOKEN_TYPE_VNUM := 0 +const TABLE_TOKEN_TYPE_TARGET_RANGE := 25 +const TABLE_TOKEN_TYPE_MAX_NUM := 27 + +func load_table(path: String) -> bool: + if _by_id.is_empty() or not FileAccess.file_exists(path): + return false + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + return false + var bytes := file.get_buffer(file.get_length()) + var ranges := {} + var start := 0 + while start < bytes.size(): + var stop := bytes.find(10, start) + if stop < 0: + stop = bytes.size() + var fields := _tab_fields(bytes.slice(start, stop)) + start = stop + 1 + if fields.size() != TABLE_TOKEN_TYPE_MAX_NUM: + continue + var vnum := int(_ascii_token(fields[TABLE_TOKEN_TYPE_VNUM])) + if not _by_id.has(vnum): + continue + ranges[vnum] = _ascii_token(fields[TABLE_TOKEN_TYPE_TARGET_RANGE]) + if ranges.is_empty(): + return false + for id in _by_id: + _by_id[id]["target_range"] = 0 + for vnum in ranges: + if String(ranges[vnum]) != "": + _by_id[vnum]["target_range"] = int(ranges[vnum]) + return true + +static func _tab_fields(line: PackedByteArray) -> Array: + var fields := [] + var begin := 0 + for i in line.size() + 1: + if i == line.size() or line[i] == 9: + fields.append(line.slice(begin, i)) + begin = i + 1 + return fields + +static func _ascii_token(field: PackedByteArray) -> String: + var out := PackedByteArray() + for b in field: + if b >= 32 and b <= 126: + out.append(b) + return out.get_string_from_ascii().strip_edges() + func has(id: int) -> bool: return _by_id.has(id) @@ -270,9 +324,14 @@ func target_count(id: int, level: int) -> int: return int(floor(float(value))) # __GetSkillTargetRange(PythonPlayerSkill.cpp:391)= rkSkillData.GetTargetRange() -# + GetStatus(POINT_BOW_DISTANCE)*100。这里只给 .msk 的基础射程(cm);主角弓距加成 -# 由 net_play(有玩家状态)叠加。 +# + GetStatus(POINT_BOW_DISTANCE)*100。这里是 GetTargetRange(PythonSkill.cpp:1086): +# MELEE_ATTACK / CHARGE_ATTACK 固定 MELEE_SKILL_TARGET_RANGE,否则 dwTargetRange +# (load_table 之后取 SkillTable.txt TARGET_RANGE);主角弓距加成由 player_skill 叠加。 +const MELEE_SKILL_TARGET_RANGE := 170 + func target_range(id: int) -> int: + if is_melee(id) or is_charge_skill(id): + return MELEE_SKILL_TARGET_RANGE return int(_by_id.get(id, {}).get("target_range", 0)) # skilldesc / .msk 的 FAN_RANGE / CIRCLE_RANGE → NetPlay.FlyShape(SINGLE/FAN/CIRCLE)。 diff --git a/project/ui/ui_manager.gd b/project/ui/ui_manager.gd index 07db0c9c..5e1ba0be 100644 --- a/project/ui/ui_manager.gd +++ b/project/ui/ui_manager.gd @@ -177,9 +177,8 @@ func close_top() -> bool: func top() -> Control: return _stack[-1] if not _stack.is_empty() else null -# wndMgr 的输入边界:打开的窗口先拿到键盘 / 鼠标语义,世界控制不能从窗口 -# 下方穿透。非模态窗口保留原版常用的开关键,便于 I/K/V/N 等键关闭当前窗; -# 模态确认框只允许 ESC 回到窗口栈。 +# wndMgr 的输入边界:鼠标不能从打开的窗口下方穿透到世界;模态确认框(LockWindow) +# 只允许 ESC 回到窗口栈。键盘按 CWindowManager::RunKeyDown:非模态窗口不截键。 func blocks_game_input(event: InputEvent) -> bool: var popup := _mobile_modal_popup() if popup != null: @@ -197,10 +196,9 @@ func blocks_game_input(event: InputEvent) -> bool: return true if event is InputEventMouse: return top_window.get_global_rect().has_point(event.position) - if event is InputEventKey: - if event.keycode == KEY_ESCAPE: - return false - return event.keycode not in [KEY_I, KEY_K, KEY_V, KEY_N, KEY_B, KEY_L, KEY_M, KEY_O] + # 键盘:没有 LockWindow 时键先给 ActiveWindow(输入框——Godot 里由获得焦点的控件在 + # gui_input 中自行吃掉),其余落到 game.py OnKeyDown,所以技能窗 / 背包开着时 + # 快捷栏 1-4 / F1-F4、Space 普攻和开关窗键都照常生效。 return false func _input(event: InputEvent) -> void: diff --git a/project/weapon_attach_test.gd b/project/weapon_attach_test.gd new file mode 100644 index 00000000..27a3c401 --- /dev/null +++ b/project/weapon_attach_test.gd @@ -0,0 +1,81 @@ +# weapon_attach_test —— 武器挂点按种族 + 左右手(真 PlayerView + 真 gr2)。 +# godot --headless --path project --script weapon_attach_test.gd +# +# 参考端 playersettingmodule.py RegisterAttachingBoneName: +# warrior PART_WEAPON=equip_right_hand (661) +# assassin PART_WEAPON=equip_right PART_WEAPON_LEFT=equip_left (873-874) +# sura PART_WEAPON=equip_right (999) +# shaman PART_WEAPON=equip_right PART_WEAPON_LEFT=equip_left (1190-1191) +# ActorInstanceAttach.cpp AttachWeapon:GetAttachingBoneName 失败(该职业没登记左手)就不挂。 +# PlayerView 的 shield_gr2 槽 = PART_WEAPON_LEFT(参考端没有盾牌挂件)。 +extends SceneTree + +const PlayerView = preload("res://ui/player_view.gd") + +const RIGHT_BONE := ["equip_right_hand", "equip_right", "equip_right", "equip_right"] +const LEFT_BONE := ["", "equip_left", "", "equip_left"] +const MAX_GRIP_M := 0.10 + +var _fail := 0 +func _ck(c: bool, m: String) -> void: + if not c: + _fail += 1 + printerr("FAIL: " + m) + +func _init() -> void: + await _run() + if _fail == 0: + print("PASS: weapon_attach_test (race attaching bones + left/right hand)") + quit(0) + else: + printerr("%d check(s) failed" % _fail) + quit(1) + +func _run() -> void: + if not ClassDB.class_exists("Metin2Model"): + print(" (skip: no Metin2Model extension)") + return + var assets := AssetRoot.path() + var weapon_dir := assets.path_join("item/ymir work/item/weapon") + var sword := weapon_dir.path_join("00010.gr2") + var dagger := weapon_dir.path_join("04000.gr2") + if not FileAccess.file_exists(sword) or not FileAccess.file_exists(dagger): + print(" (skip: weapon gr2 assets missing)") + return + await process_frame + for race in 8: + var job := race & 3 + var pv = PlayerView.new() + get_root().add_child(pv) + if not pv.build(assets, race): + print(" (skip race %d: body assets missing)" % race) + pv.free() + continue + pv.set("weapon_gr2", sword) + pv.set("shield_gr2", dagger) + for i in 3: + await process_frame + var right := pv.model.get_node_or_null("Weapon") as Node3D + _ck(right != null, "race %d: right-hand weapon attached (%s)" % [race, RIGHT_BONE[job]]) + if right: + _ck(_near(pv, right, RIGHT_BONE[job]), "race %d: weapon sits on %s" % [race, RIGHT_BONE[job]]) + var left := pv.model.get_node_or_null("Shield") as Node3D + if LEFT_BONE[job] == "": + _ck(left == null, "race %d: no PART_WEAPON_LEFT bone registered -> nothing on left hand" % race) + else: + _ck(left != null, "race %d: left-hand weapon attached (%s)" % [race, LEFT_BONE[job]]) + if left: + _ck(_near(pv, left, LEFT_BONE[job]), "race %d: left weapon sits on %s" % [race, LEFT_BONE[job]]) + pv.queue_free() + await process_frame + +func _near(pv: Node, node: Node3D, bone: String) -> bool: + var pose: Dictionary = pv.anim.call("get_effect_bone_pose", bone) + if pose.is_empty(): + printerr(" bone %s has no pose" % bone) + return false + var hand: Vector3 = (pv.model as Node3D).global_transform * (pose["transform"] as Transform3D).origin + var d := node.global_position.distance_to(hand) + if d > MAX_GRIP_M: + printerr(" %s is %.3f m from %s" % [node.name, d, bone]) + return d <= MAX_GRIP_M