fix(combat): 战斗逐项对齐 40250,修复移动 / 技能释放与武器挂点

- 连击类型由 combo_changed 驱动(SetComboSkillFlag,技能 122 等级)
- 命中判定改为 .msa 刀尖 Z 圆柱 vs .msm 防御球(hit_collision.gd 逐行移植)
- _register_hit 改为 map::insert 语义,修正命中上限
- 攻速同时缩放 GetAttackingElapsedTime 与攻击动作速率;按武器动作模式目录绑定攻击段
- 去掉本地连击超时,motion_bound 清命中表 / 连击段号
- __ProcessDataAttackSuccess:InsertDelay 硬直、physics_push.gd 击退 + GetBlendingPosition 同步、
  命中特效、__HitGood / __HitGreate / __HitStone 受击动作链与抖动(ui/hit_reaction.gd)
- ClassicSession::send_use_skill 不再夹带 CG_FLY_TARGETING
- mac 前进 / 后退方向与技能释放修复;武器按职业骨骼挂到手上
- 新增 hit_collision / physics_push / hit_view / net_world_push / weapon_attach 测试;
  gpu_pose_bounds / race_motion_assembly 适配 damage 随机变体
- 文档:CLIENT-GAP.md、CLIENT-GAP-FIX.md §3.5 / §3.7 与 C.5 增量 130

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft3kXpNdLbKEfg5uDLfqVF
This commit is contained in:
shenlei
2026-09-12 13:15:28 +09:00
co-authored by Claude Opus 5
parent 96c29d2bb7
commit 1933a5ceda
30 changed files with 2353 additions and 209 deletions
+150 -7
View File
@@ -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()` 装配 ctxobserver / 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 个 canonical25 组断言);`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:12026-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 批次记录 · 增量 130W2 / §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_geometryreach + 正面 ±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>.msmMobView 取模型目录 .msm,都经 get_defending_spheres() 提供。
· 没有 .msm 的占位节点退化为单球 HIT_FALLBACK_SPHEREseam)。
3. _register_hit
改为 map::insert 语义:已登记的目标不刷新冷却,插入后超上限才返回 FALSE。
4. 攻速:
_atk_speed_factorbAttackSpeed/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_stoneHIT_TYPE_GOOD → hit_goodHIT_TYPE_GREAT → hit_greate
scalar = cos(攻击方 yaw 受击方 yaw)。状态机 ui/hit_reaction.gd 见 §3.7 增量 130。
· 最后 _on_hitSetTarget + 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_testCPU / GPU 两个视图绑定前用同一 seed,保证选中同一段。
· race_motion_assembly_testdamage 允许任一注册变体。
这两个测试在本批次改动后曾失败(CPU / GPU 选到不同段、断言写死 damage.msa),调整后通过。
回归(godot --headless --path project --script <test>.gd):
新增 PASShit_collision_test、physics_push_test、hit_view_test、net_world_push_test。
PASScombat_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 build22/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
View File
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
View File
@@ -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::SetAttackSpeeduAtkSpd > 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")
# --- SetComboSkillFlagPythonPlayerSkill.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")
# ComboProcesselapsed 过了 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)")
# 动作回到 Waitelapsed > 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 myaw 0 -> 前向 +Z
vnode.global_position = Vector3(0, 0, 1.0) # 攻击者正前方 1 myaw 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) # 转到身后
# AttackingProcessrVictim.__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")
# __CanPushDestActorowner 不是攻击者 / 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_RACE500 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_modeRefreshState 的 SetMotionMode 分支)---
# 纯映射表:CItemData 类型/子类型 + 骑乘/变身 -> CRaceMotionData::EMode
+60 -11
View File
@@ -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 桩:只给武器 subtypeWEAPON_DAGGER=1 BOW=2 FAN=5item_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"),
+1 -1
View File
@@ -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:
+8
View File
@@ -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
# LoadLocaleDataRegisterSkillDesc 之后 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()
+4
View File
@@ -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_dwBattleHitEffectIDnet_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")
+3
View File
@@ -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]:
+160
View File
@@ -0,0 +1,160 @@
# HitCollision —— CLIENT-GAP §3.5 修改 2`CActorInstance::__NormalAttackProcess` 用到的碰撞几何,
# 逐行移植(单位 = Metin2 cm,Z 为高度):
# IntersectLineSegments / FindNearestPointOnLineSegment / FindNearestPointOfParallelLineSegments /
# AdjustNearestPointsEterLib/lineintersect_utils.cppMY_EPSILON 0.1
# DetectCollisionDynamicZCylinderVSDynamicZCylinderGameLib/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_RACEInstanceBase.cpp
static var _msm_cache := {}
# FindNearestPointOnLineSegment|L|² < ε² 时 Nearest = A1parameter 保持调用方原值。
# 返回 [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
# FindNearestPointOfParallelLineSegmentss[] 已被 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_RACEInstanceBase.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
+78
View File
@@ -0,0 +1,78 @@
# hit_collision_test —— §3.5__NormalAttackProcess 的碰撞几何 headless 自检。
# IntersectLineSegmentsEterLib/lineintersect_utils.cpp
# DetectCollisionDynamicZCylinderVSDynamicZCylinderGameLib/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 = B1OutA = 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)")
# 退化 AL11 < ε²)
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_RACEInstanceBase.cpp
_ck(HitCollision.is_huge_race(2493) and not HitCollision.is_huge_race(101), "IS_HUGE_RACE: only 2493")
+254
View File
@@ -0,0 +1,254 @@
# hit_view_test —— CLIENT-GAP §3.7 受击方 / 攻击方视图(PlayerView / MobViewheadless 自检:
# __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)
# __HitGoodfScalar < 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")
# __HitGreateDAMAGE_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
+20 -1
View File
@@ -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
View File
@@ -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_fAttackRadiusActorInstanceCollisionDetection.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::EHitTypeGameType.h
const HIT_TYPE_NONE := 0
const HIT_TYPE_GREAT := 1
const HIT_TYPE_GOOD := 2
const HIT_DISTANCE_CM := 300.0 # __NormalAttackProcessfDistance >= 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_dwBattleHitEffectIDplayersettingmodule.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 / 1000.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_wcurComboTypeSetComboType;连击技能
var _combo_index := 0 # m_dwcurComboIndex0 = 不在连击中)
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 每帧 += elapsedInsertDelay 冻结(命中硬直)。
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_windowsTHitDataContainer
var _hit_motion_type := 0 # get_motion_data().motion_typeNRaceData::EMotionType
var _hit_limit_count := 0 # get_motion_data().hit_limit_countSKILL 类的每窗命中上限)
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_timefInvisibleTime
var _hit_type := HIT_TYPE_NONE # get_motion_data().hitting_typeiHittingType
var _hit_stiffen := 0.0 # get_motion_data().stiffen_timefStiffenTime
var _hit_external_force := 0.0 # get_motion_data().external_forcefExternalForce
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::SetComboSkillFlagPythonPlayerSkill.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::UpdateTimem_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::__GetBowRangeInstanceBase.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::IsAttackableInstanceInstanceBase.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::ComboProcessActorInstanceBattle.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` 取 TMotionAttackDataTHitDataContainer + 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::AttackProcessInstanceBaseBattle.cpp:413+ CActorInstance::__NormalAttackProcess
# ActorInstanceCollisionDetection.cpp:333——挥击动作播放中,每帧按 hit_window [start,end]
# 对所有可攻击实体做扫掠球几何判定,命中即 OnHitSendAttackPacket)。
# CInstanceBase::AttackProcessInstanceBaseBattle.cpp:413-> CActorInstance::AttackingProcess ->
# __NormalAttackProcessActorInstanceCollisionDetection.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
# AttackingProcessrVictim.__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 -> 角色世界 cmX, 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 # 该窗命中数超上限 -> 本帧不再处理这个实体
# OnHitSetTarget(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 上限 16SKILL 用 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::__ProcessDataAttackSuccessActorInstanceBattle.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::__CanPushDestActorActorInstanceBattle.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::OnHitSetTarget(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::FlushVictimListPythonPlayerEventHandler.cpp:194)——帧末把
# 被击退目标的位置一次性 CG_SYNC_POSITION 上行,上限 SYNC_POSITION_COUNT_LIMIT=16。
+57
View File
@@ -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 + IncreaseExternalForceActorInstanceBattle.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::GetBlendingPositionblending 中 = 当前位置 + 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()
+125
View File
@@ -0,0 +1,125 @@
# net_world_push_test —— CLIENT-GAP §3.7 受击击退的场景侧:
# __PushCircle + IncreaseExternalForce -> net_world.push_victim
# CActorInstance::PhysicsProcess(每帧 AddMovement(GetX/YMovement)-> 节点偏移
# GetBlendingPositionblending 中 = 当前位置 + 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
+129
View File
@@ -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])
# PhysicsProcessupdate(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()
+72
View File
@@ -0,0 +1,72 @@
# physics_push_test —— §3.7:受击击退物理(GameLib/PhysicsObject.cpp CPhysicsObject +
# MapUtil.cpp CEaseOutInterpolationheadless 自检。
# 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:没有位移,也不 blendingSetup(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")
+6 -4
View File
@@ -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_MoveToDirectionm_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
+42
View File
@@ -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()
+3 -1
View File
@@ -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)
+20
View File
@@ -189,6 +189,26 @@ func _run() -> void:
_ck(st.is_fan_range(91), "skill 91 IsFanRangebipabu FAN_RANGE")
_ck(st.target_range(91) == 1800, "target_range(91) == 1800bipabu.msk Range")
_ck(st.target_count(91, 20) == 0, "target_count(91) == 0bipabu 无 TargetCountFormula -> 仅主目标)")
# LoadLocaleDataRegisterSkillDesc 后 RegisterSkillTablePythonApplication.cpp:1049-1055)。
# RegisterSkill(.msk) 在参考端没有调用点,dwTargetRange 只来自 SkillTable.txt 的
# TARGET_RANGE 列;GetTargetRangePythonSkill.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) == 2500SkillTable")
_ck(tbl.target_range(91) == 1800, "target_range(91) == 1800SkillTable")
_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
View File
@@ -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_gr2PART_WEAPON/ model.shield_gr2PART_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 EWeaponSubTypesCItemData::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 seam40250 实体表暂无变身字段(m2_client.cpp entity dict 无 poly_race /
# polymorph)。一旦 GC_CHARACTER_UPDATE / GC_CHAR_ADD_INFO 带上变身 race,这里改成读
+127
View File
@@ -0,0 +1,127 @@
# HitReaction —— CLIENT-GAP §3.7 受击方视图(PlayerView / MobView)共用的受击状态机。
# __HitGood / __HitGreate / __HitStoneActorInstanceBattle.cpp)的分支判定、
# __Shake + ShakeProcess(世界矩阵平移 ±rand()%10 cm)、CGraphicThingInstance::InsertDelay、
# InterceptOnceMotion + PushOnceMotion + PushLoopMotion 的动作链。
# 视图只提供「按状态名绑定一段一次性动作」的 Callablebind(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 # ShakeProcessrand() % 10
const LOOP_STATES := ["wait", "walk", "run"]
const DAMAGE_STATES := ["damage", "damage_back"] # IsDamage 类
const KNOCKDOWN_STATES := ["knockdown", "knockdown_back"] # IsKnockDownDAMAGE_FLYING(_BACK)
const STANDUP_STATES := ["standup", "standup_back"] # __IsStandUpMotionSTAND_UP(_BACK)
# CActorInstance::isLockNORMAL_ATTACK / COMBO_ATTACK_* / SPECIAL_* / 钓鱼 / 表情跳舞等
const LOCK_STATES := ["attack", "combo", "skill", "__motion", "emotion", "dig",
"fishing", "fishing_react", "fishing_catch", "fishing_fail"]
const SKILL_STATES := ["skill"] # IsUsingSkillSKILL 段 / SPECIAL_1..6
var delay_left := 0.0
var speed := 1.0 # 当前动作的 fSpeedRatioInsertDelay 结束后恢复)
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_scaleInsertDelay 期间保持冻结。
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
View File
@@ -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 / __HitStoneActorInstanceBattle.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
View File
@@ -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_bonePART_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_ATTACKCOMBO_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).msaCOMBO_ATTACK_N -> combo_0N.msa),
# 模式目录缺该段时退回 general 的 attack(_1).msaGENERAL 模式的 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 / __HitStoneActorInstanceBattle.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")
+61 -2
View File
@@ -162,6 +162,60 @@ func load_file(path: String) -> bool:
count = _by_id.size()
return count > 0
# RegisterSkillTablePythonSkill.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)))
# __GetSkillTargetRangePythonPlayerSkill.cpp:391= rkSkillData.GetTargetRange()
# + GetStatus(POINT_BOW_DISTANCE)*100。这里只给 .msk 的基础射程(cm);主角弓距加成
# 由 net_play(有玩家状态)叠加。
# + GetStatus(POINT_BOW_DISTANCE)*100。这里是 GetTargetRangePythonSkill.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.FlyShapeSINGLE/FAN/CIRCLE)。
+5 -7
View File
@@ -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:
+81
View File
@@ -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 AttachWeaponGetAttachingBoneName 失败(该职业没登记左手)就不挂。
# 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