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
+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