Files
mtgodot-poc/project/combat_fx_test.gd
T
shenleiandClaude Opus 5 1933a5ceda 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
2026-09-12 13:15:28 +09:00

681 lines
31 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# combat_fx_test —— P4:受击硬直 / combo / 抖屏 / 状态图标 / 死亡窗 headless 自检。
# godot --headless --path project --script combat_fx_test.gd
extends SceneTree
const NetPlay = preload("res://net_play.gd")
const GameCamera = preload("res://game_camera.gd")
const DeathUI = preload("res://ui/death_ui.gd")
class FakeClient extends Node:
signal entity_main_set(vid: int)
signal entity_moved(vid: int)
signal entity_despawned(vid: int)
signal entity_dead(vid: int)
signal vitals_changed(vid: int)
signal points_changed(points: Dictionary)
signal target_info(vid: int, hp_percent: int)
signal damage(vid: int, amount: int, flag: int)
signal fishing_event(subheader: int, info: int, dir: int)
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 := []
var synced := []
var said := []
var affects := []
var fly_targets := [] # §3.6CG_FLY_TARGETING
var shots := [] # §3.6CG_SHOOT
func is_in_game() -> bool: return true
func get_main_vid() -> int: return main
func get_entity(vid) -> Dictionary: return ents.get(vid, {})
func get_entities() -> Array: return ents.values()
func get_affects() -> Array: return affects
func attack(motion, vid) -> bool: attacks.append([motion, vid]); return true
func add_fly_targeting(vid, x, y) -> bool: fly_targets.append([vid, x, y]); return true
func shoot(skill) -> bool: shots.append(skill); return true
func set_target(v) -> bool: return true
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 := {}
func item(vnum: int) -> Dictionary: return items.get(vnum, {})
class FakePC extends Node:
signal target_selected(node: Node3D)
signal moved(pos: Vector3)
signal anim_state(state: String)
var player: Node3D
var frozen := false
class FakeAnim extends Node:
var md := {}
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 := []
func set_affects(a): affects = a
var _fail := 0
func _ck(c: bool, m: String) -> void:
if not c:
_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:
print("PASS: combat_fx_test (hit-gate / combo / shake / affects / death)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
func _run() -> void:
var fc := FakeClient.new()
var pc := FakePC.new()
var view: Node3D = FakeView.new()
var hud := FakeHud.new()
var cam: Camera3D = GameCamera.new()
pc.player = Node3D.new()
for n in [fc, pc, view, hud, cam, pc.player]:
get_root().add_child(n)
cam.target = pc.player
var np: Node = NetPlay.new()
get_root().add_child(np)
np.setup(fc, pc, null, hud)
np.camera = cam
np.player_view = view
np._main_vid = 1000
# --- 受击:播击退动作 + 抖屏;「命中」不靠固定硬直秒数锁输入(§3.7)---
fc.damage.emit(1000, 20, 0) # 命中(非闪避)
_ck(np._knock_down, "hit -> knock_down latch set")
_ck(np.is_stunned(), "hit (knock_down) -> cannot act")
_ck(cam._shake > 0.0, "hit -> camera shake queued")
_ck("damage" in view.states, "hit -> view set_anim_state('damage')")
np._process(0.016)
_ck(pc.frozen == true, "knock_down -> pc.frozen = true (got %s)" % pc.frozen)
# 击退动作结束 -> 解除(动作时长驱动)
np._knock_down = false
_ck(not np.is_stunned(), "knockback motion ended -> can act")
# 服务端 stun 标志独立驱动闸门
fc.ents[1000]["stunned"] = true
_ck(np.is_stunned(), "server stunned flag -> cannot act")
_ck(not np._can_attack(), "server stunned -> _can_attack() false")
fc.ents[1000]["stunned"] = false
_ck(not np.is_stunned(), "server stun cleared -> can act")
# 闪避不播击退
np._knock_down = false
fc.damage.emit(1000, 0, 1 << 2) # DODGE
_ck(not np._knock_down, "dodge -> no knock_down")
_ck(not np.is_stunned(), "dodge -> can act")
# 别人受击不影响自己
np._knock_down = false
fc.damage.emit(2000, 30, 0)
_ck(not np._knock_down, "other's damage -> no knock_down")
# Fishing motion subheaders drive the local player view; FISH item outcome is ignored.
np.player_view = view
fc.fishing_event.emit(0, 1000, 18)
_ck(view.states.back() == "fishing", "fishing start -> local fishing animation")
fc.fishing_event.emit(3, 1000, 0)
_ck(view.states.back() == "fishing_catch", "fishing success -> catch animation")
var before: int = view.states.size()
fc.fishing_event.emit(5, 30001, 0)
_ck(view.states.size() == before, "fishing fish item -> no local motion")
# --- combo:连续攻击 motion 递增 ---
np._knock_down = false
np._target_vid = 2000
np.attack_period = 0.0
# §3.3:普攻节奏来自动作数据 / 攻速系数,不再是硬编码常量
_ck(is_equal_approx(np._current_attack_period(), np.DEFAULT_ATTACK_PERIOD),
"no motion data -> attack period falls back to DEFAULT_ATTACK_PERIOD")
np._apply_attack_speed(150)
_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
np._combo_tables = {0: {K: PackedInt32Array([14, 15, 16, 17])}}
np.combo_class = 0
np.combo_motion_mode = np.MOTION_MODE_GENERAL
view.anim.md = {
"has_combo_input": true, "duration": 1.0,
"pre_input_time": 0.10, "direct_input_time": 0.20, "input_limit_time": 0.40,
"next_combo": 0.20,
}
var pcn := Node3D.new(); get_root().add_child(pcn)
np.pc.player = pcn
np._vid_reserved = 2000
fc.attacks.clear()
await process_frame # SceneTree 脚本:等一帧让 add_child 的节点真正入树(global_position 才有效)
# 第一次输入:m_dwcurComboIndex 0 -> __RunNextCombo -> 段 1(段号 14
np._do_attack_swing(pcn, {})
_ck(np._combo_index == 1, "first input -> combo index 1, got %d" % np._combo_index)
_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,不推进(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._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._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._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._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)")
# 参考端 ComboProcess 没有「超过整段时长就清连击」:elapsed > duration 段号保持
np._motion_start_t = np._local_time - 1.5
np._combo_process()
_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);攻速系数原样传给动作播放
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)
# --- __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 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, "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:模型 -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": [{"time": 0.1, "last_pos": Vector3(-60, -80, 100), "pos": Vector3(60, -80, 100)}],
}],
"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 = {}
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")
# 采样时刻 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")
# AttackingProcessrVictim.__isInvisible() -> FALSE
np._hit_dedup.clear()
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._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, {})
_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
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_SWORD, false, false, false) \
== np.MOTION_MODE_ONEHAND_SWORD, "sword -> ONEHAND_SWORD")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_TWO_HANDED, false, false, false) \
== np.MOTION_MODE_TWOHAND_SWORD, "two-handed -> TWOHAND_SWORD")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_DAGGER, false, false, false) \
== np.MOTION_MODE_DUALHAND_SWORD, "dagger -> DUALHAND_SWORD")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_BOW, false, false, false) \
== np.MOTION_MODE_BOW, "bow -> BOW")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_FAN, false, false, false) \
== np.MOTION_MODE_FAN, "fan -> FAN")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_BELL, false, false, false) \
== np.MOTION_MODE_BELL, "bell -> BELL")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_ARROW, false, false, false) \
== np.MOTION_MODE_GENERAL, "arrow / bare hand -> GENERAL")
_ck(np.motion_mode_for(np.ITEM_TYPE_ROD, 0, false, false, false) \
== np.MOTION_MODE_FISHING, "fishing rod -> FISHING")
_ck(np.motion_mode_for(np.ITEM_TYPE_ROD, 0, true, false, false) \
== np.MOTION_MODE_HORSE, "rod while mounted -> HORSE")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_SWORD, true, false, false) \
== np.MOTION_MODE_HORSE_ONEHAND_SWORD, "mounted sword -> HORSE_ONEHAND_SWORD")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_BOW, true, false, false) \
== np.MOTION_MODE_HORSE_BOW, "mounted bow -> HORSE_BOW")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_ARROW, true, false, false) \
== np.MOTION_MODE_HORSE, "mounted arrow -> HORSE")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_SWORD, false, true, false) \
== np.MOTION_MODE_GENERAL, "poly overrides weapon -> GENERAL")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_SWORD, true, false, true) \
== np.MOTION_MODE_WEDDING_DRESS, "wedding dress overrides all -> WEDDING_DRESS")
# 端到端:proto 桩 + parts[WEAPON] -> _refresh_motion_mode() -> combo_motion_mode 变,且清连击
var fp := FakeProto.new()
get_root().add_child(fp)
fp.items[19] = {"type": np.ITEM_TYPE_WEAPON, "sub_type": np.WEAPON_SUB_DAGGER}
np.proto = fp
np._main_vid = 1000
np.combo_motion_mode = np.MOTION_MODE_GENERAL
np._combo_index = 3
fc.ents[1000]["parts"] = [0, 19, 0, 0]
np._refresh_motion_mode()
_ck(np.combo_motion_mode == np.MOTION_MODE_DUALHAND_SWORD, "parts[WEAPON]=dagger -> DUALHAND_SWORD")
_ck(np._combo_index == 0, "motion mode change clears the running combo")
fc.ents[1000]["mount_vnum"] = 20101
np._refresh_motion_mode()
_ck(np.combo_motion_mode == np.MOTION_MODE_HORSE_DUALHAND_SWORD, "mount up -> HORSE_DUALHAND_SWORD")
fc.ents[1000].erase("parts")
fc.ents[1000].erase("mount_vnum")
np.proto = null
# --- §3.5 增量 85:弓箭 __GetBowRange() → _clickable_distance ---
np._bow_distance_bonus = 0.0
np.combo_motion_mode = np.MOTION_MODE_GENERAL
_ck(is_equal_approx(np._clickable_distance({"ch_type": 0}), np.CLICK_DIST_DEFAULT_CM / np.CM),
"non-bow: 玩家 / 怪目标 reach = 150cm")
np.combo_motion_mode = np.MOTION_MODE_BOW
_ck(is_equal_approx(np._bow_range_cm(), 2500.0 - 100.0), "__GetBowRange 基础 = 2500-100")
_ck(is_equal_approx(np._clickable_distance({"ch_type": 0}), (2500.0 - 100.0) / np.CM),
"bow mode: 非 NPC / 非采集物目标 reach = __GetBowRange()")
_ck(is_equal_approx(np._clickable_distance({"ch_type": 1}), np.CLICK_DIST_NPC_CM / np.CM),
"NPC 覆盖弓箭距离 → 500cm")
_ck(is_equal_approx(np._clickable_distance({"ch_type": 3}), np.CLICK_DIST_RESOURCE_CM / np.CM),
"采集物覆盖弓箭距离 → 100cm")
np.combo_motion_mode = np.MOTION_MODE_HORSE_BOW
_ck(np._is_bow_mode(), "HORSE_BOW 也算 bow mode")
np._bow_distance_bonus = 300.0
_ck(is_equal_approx(np._clickable_distance({"ch_type": 0}), (2500.0 - 100.0 + 300.0) / np.CM),
"POINT_BOW_DISTANCE 加成叠加进 __GetBowRange()")
np._on_points({"attack_speed": 0, "bow_distance": 0})
_ck(is_equal_approx(np._bow_distance_bonus, 0.0), "_on_points 读取 bow_distance → _bow_distance_bonus")
np.combo_motion_mode = np.MOTION_MODE_GENERAL
# --- §3.6 增量 86:弓 FLY 事件(OnSetFlyTarget / OnShoot---
var nw2 := FakeNetWorld.new(); get_root().add_child(nw2)
var tnode := Node3D.new(); get_root().add_child(tnode)
await process_frame
tnode.global_position = Vector3(3.0, 0.0, 4.0)
nw2.nodes[2000] = tnode
np.net_world = nw2
np._target_vid = 2000
np._swing_skill = 0
np._combo_tables = {}
view.anim.md = {}
fc.fly_targets.clear()
fc.attacks.clear()
var got_shot := []
np.bow_shot_fired.connect(func(sk: int): got_shot.append(sk))
# 非弓模式:起手不发 CG_FLY_TARGETING,也不冒 bow_shot_fired
np.combo_motion_mode = np.MOTION_MODE_GENERAL
np._emit_swing(np.NAME_NORMAL_ATTACK, false)
_ck(fc.fly_targets.is_empty() and got_shot.is_empty(), "非弓模式挥击 → 无 FLY 事件")
# 弓模式:起手即发 CG_FLY_TARGETING(目标VID, 目标平面坐标) + 冒 bow_shot_fired(uSkill)
np.combo_motion_mode = np.MOTION_MODE_BOW
np._emit_swing(np.NAME_NORMAL_ATTACK, false)
_ck(fc.fly_targets.size() == 1 and fc.fly_targets[0][0] == 2000,
"弓挥击起手 → OnSetFlyTarget 发 CG_FLY_TARGETING(vid 2000)")
var exp_xy := MapCoord.to_server_cm(Vector3(3.0, 0.0, 4.0))
_ck(fc.fly_targets[0][1] == int(exp_xy.x) and fc.fly_targets[0][2] == int(exp_xy.y),
"CG_FLY_TARGETING 坐标 = 目标节点 server-XYOnGetFlyTargetPosition 平面分量)")
_ck(got_shot == [0], "弓挥击 → bow_shot_fired(uSkill=0)OnShoot 延后到 .msa FLY 帧)")
# 无目标:不发
np._target_vid = 0
np._vid_reserved = 0
fc.fly_targets.clear()
np._send_fly_target()
_ck(fc.fly_targets.is_empty(), "无目标 → 不发 CG_FLY_TARGETING")
np.net_world = null
np.combo_motion_mode = np.MOTION_MODE_GENERAL
np._swing_skill = 0
# --- 状态图标条 ---
fc.affects = [{"type": 7001, "point_idx": 19, "value": 25, "duration": 300}]
# game_scene 里连的是 refresh lambda;这里直接调 hud
hud.set_affects(fc.get_affects())
_ck(hud.affects.size() == 1 and int(hud.affects[0]["type"]) == 7001, "hud.set_affects")
# --- 死亡窗 ---
var canvas := CanvasLayer.new()
get_root().add_child(canvas)
var death: Node = DeathUI.new()
get_root().add_child(death)
death.setup(fc, canvas)
_ck(not death._root.visible, "death dialog hidden initially")
fc.ents[1000]["dead"] = true
fc.entity_dead.emit(1000)
_ck(death._root.visible, "entity_dead(main) -> death dialog shown")
# 点「在此复活」
var hb: Button = null
for n in death._root.find_children("*", "Button", true, false):
if n.text == "在此复活":
hb = n
_ck(hb != null, "restart-here button present")
if hb:
hb.pressed.emit()
_ck(fc.said.size() == 1 and fc.said[0] == [0, "/restart_here"], "restart -> say(0, '/restart_here')")
# 复活:hp 回来 -> 自动关
fc.ents[1000]["dead"] = false
fc.ents[1000]["hp"] = 100
fc.vitals_changed.emit(1000)
_ck(not death._root.visible, "revive (hp>0) -> dialog auto-hides")