Merge combat 40250 parity fixes
This commit is contained in:
+150
-7
@@ -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/<class>_<m|w>.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/<class>_<m|w>.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 <test>.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;真实服务器战斗验收。
|
||||
```
|
||||
|
||||
+22
-5
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
|
||||
@@ -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) ---
|
||||
{
|
||||
|
||||
+299
-38
@@ -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
|
||||
|
||||
+60
-11
@@ -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"),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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/<class>_<m|w>.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 -> <dir>/<stem>.msm, got %s" % [sp])
|
||||
|
||||
pv.queue_free()
|
||||
mv.queue_free()
|
||||
_done = true
|
||||
@@ -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
|
||||
|
||||
+287
-90
@@ -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
|
||||
# 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)
|
||||
|
||||
# Godot 世界 m -> 角色世界 cm(X, Y, 高度)
|
||||
static func _actor_cm(g: Vector3) -> Vector3:
|
||||
return Vector3(g.x * CM, -g.z * CM, g.y * CM)
|
||||
|
||||
static func _godot_m(a: Vector3) -> Vector3:
|
||||
return Vector3(a.x / CM, a.z / CM, -a.y / CM)
|
||||
|
||||
# 模型本地 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]
|
||||
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:
|
||||
if t < float(w.get("start_time", 0.0)) or (t - dt) > float(w.get("end_time", 0.0)):
|
||||
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)
|
||||
|
||||
# m_HitDataMap 查重:COMBO 动作同窗同目标只一次;其余按 fInvisibleTime 冷却。
|
||||
func _hit_deduped(wi: int, vid: int) -> bool:
|
||||
# m_HitDataMap:同窗已命中过 -> COMBO 永不再判;其余要等 fInvisibleTime 过去
|
||||
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()
|
||||
|
||||
# 记一次命中并做每窗命中上限校验: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
|
||||
|
||||
# 扫掠球几何近似: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))
|
||||
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 samples is Array:
|
||||
for s in samples:
|
||||
var st := float(s.get("time", 0.0))
|
||||
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。
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)")
|
||||
|
||||
+42
-12
@@ -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,这里改成读
|
||||
|
||||
@@ -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
|
||||
+94
-4
@@ -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
|
||||
|
||||
+129
-18
@@ -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/<class>_<m|w>.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")
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user