# 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.6:CG_FLY_TARGETING var shots := [] # §3.6:CG_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::SetAttackSpeed:uAtkSpd > 1100 -> 0;否则 /100,无上下限 np._apply_attack_speed(400) _ck(is_equal_approx(np._atk_speed_factor, 4.0), "attack_speed 400 -> factor 4.0 (no clamp)") np._apply_attack_speed(1200) _ck(np._atk_speed_factor == 0.0, "attack_speed > 1100 -> factor 0") _ck(is_finite(np._current_attack_period()), "factor 0 -> attack period stays finite") np._apply_attack_speed(100) # --- CGraphicThingInstance::InsertDelay / UpdateTime:本地时间 --- np._local_time = 5.0 np._delay = 0.05 np._advance_local_time(0.02) _ck(is_equal_approx(np._local_time, 5.0) and is_equal_approx(np._delay, 0.03), "delay > elapsed -> local time frozen, delay shrinks") np._advance_local_time(0.05) _ck(is_equal_approx(np._local_time, 5.02) and np._delay == 0.0, "delay < elapsed -> local time advances by the remainder") np._apply_attack_speed(200) np._motion_start_t = np._local_time - 0.1 _ck(is_equal_approx(np._attacking_elapsed(), 0.2), "GetAttackingElapsedTime = (local - start) * fSpeedRatio") np._apply_attack_speed(100) var lt0: float = np._local_time np._process(0.016) _ck(np._local_time > lt0, "_process advances the local time") # --- SetComboSkillFlag(PythonPlayerSkill.cpp):GC 连击开关 -> SetComboType(MIN(lv,2)) --- fc.skills = [{"id": 122, "level": 3, "master": 0}] fc.combo_changed.emit(true) _ck(np._combo_type == 2, "combo on + skill 122 lv3 -> combo type MIN(3,2)=2, got %d" % np._combo_type) fc.combo_changed.emit(false) _ck(np._combo_type == 0, "combo off -> combo type 0") fc.skills = [{"id": 122, "level": 1, "master": 0}] fc.combo_changed.emit(true) _ck(np._combo_type == 1, "combo on + skill 122 lv1 -> combo type 1") fc.skills = [{"id": 122, "level": 0, "master": 0}] fc.combo_changed.emit(false) _ck(np._combo_type == 1, "skill 122 level 0 -> return before SetComboType") fc.skills = [] fc.combo_changed.emit(false) _ck(np._combo_type == 1, "no skill 122 slot -> return before SetComboType") np._combo_type = 0 # --- §3.5 连击状态机:InputComboAttackCommand / __RunNextCombo / ComboProcess --- # 合成一张 4 段的 1H type1 段表(段号 14..17),并给 player_view 一个可控 .msa。 var K: int = (np.MOTION_MODE_GENERAL << 16) | 0 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") # ComboProcess:elapsed 过了 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 m(yaw 0:模型 -Y = Godot +Z) vnode.rotation.y = 0.0 pcn.global_position = Vector3.ZERO pcn.rotation.y = 0.0 # 一个采样:t=0.1 时刀尖球从模型本地 (-60,-80) 横扫到 (60,-80),高 1 m view.anim.md = { "has_attacking_data": true, "hit_windows": [{ "start_time": 0.0, "end_time": 0.5, "bone": "equip_right_hand", "weapon_length": 120.0, "samples": [{"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") # AttackingProcess:rVictim.__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") # __CanPushDestActor:owner 不是攻击者 / owner 超 3 秒 -> 不推;晕眩 -> 推 _reset_hit(np, fc, nw, fx) fc.ents[2000]["owner_vid"] = 0 np._attack_process(0.016) np._flush_victim_list() _ck(fc.attacks.size() == 1 and nw.pushes.is_empty() and fc.synced.is_empty(), "victim not owned by the attacker -> hit, no push, no CG_SYNC_POSITION") _reset_hit(np, fc, nw, fx) fc.ents[2000]["owner_vid"] = 1000 np._owner_seen[2000] = [1000, np._now() - 3.5] np._attack_process(0.016) _ck(fc.attacks.size() == 1 and nw.pushes.is_empty(), "owner time > 3 s -> no push") _reset_hit(np, fc, nw, fx) fc.ents[2000]["stunned"] = true np._attack_process(0.016) _ck(nw.pushes.size() == 1, "stunned victim -> always pushable") fc.ents[2000]["stunned"] = false np._owner_seen.erase(2000) # HIT_TYPE_NONE:__ProcessDataAttackSuccess 直接 return(不硬直、不发包);GREAT -> __HitGreate _reset_hit(np, fc, nw, fx) view.delays.clear() vnode.delays.clear() vnode.reactions.clear() np._hit_type = np.HIT_TYPE_NONE np._attack_process(0.016) _ck(fc.attacks.is_empty() and view.delays.is_empty() and vnode.delays.is_empty() and fx.spawned.is_empty(), "HIT_TYPE_NONE -> no delay / effect / OnHit") _reset_hit(np, fc, nw, fx) np._hit_type = np.HIT_TYPE_GREAT np._attack_process(0.016) _ck(vnode.reactions.size() == 1 and vnode.reactions[0][0] == "greate", "HIT_TYPE_GREAT -> __HitGreate, got %s" % [vnode.reactions]) np._hit_type = np.HIT_TYPE_GOOD # 距离怪癖:v3Distance = (dX, dZ, dZ) —— 平面 Y 不参与,高度差算两次 _reset_hit(np, fc, nw, fx) vnode.global_position = Vector3(3.5, 0, 1.0) vnode.spheres = [{"radius": 45.0, "pos": Vector3(-350, 0, 100), "bone": ""}] np._attack_process(0.016) _ck(fc.attacks.is_empty(), "|dX| 350 >= 300 -> distance reject although the spheres touch") _reset_hit(np, fc, nw, fx) vnode.global_position = Vector3(0, 0, 3.5) vnode.spheres = [{"radius": 45.0, "pos": Vector3(0, 270, 100), "bone": ""}] np._attack_process(0.016) _ck(fc.attacks.size() == 1, "|dY| 350 is not part of the distance check -> hit") _reset_hit(np, fc, nw, fx) vnode.global_position = Vector3(0, 2.2, 1.0) vnode.spheres = [{"radius": 45.0, "pos": Vector3(0, 0, 100), "bone": ""}] np._attack_process(0.016) _ck(fc.attacks.is_empty(), "height 2.2 m -> 2 * 220² >= 300² -> reject") _reset_hit(np, fc, nw, fx) vnode.global_position = Vector3(0, 2.0, 1.0) np._attack_process(0.016) _ck(fc.attacks.size() == 1, "height 2.0 m -> 2 * 200² < 300², Z cylinder ignores height -> hit") # IS_HUGE_RACE:500 cm 距离、不推、特效在 (攻击者 + 防御球) * 0.5 _reset_hit(np, fc, nw, fx) fc.ents[2000]["race"] = 2493 vnode.global_position = Vector3(4.0, 0, 1.0) vnode.spheres = [{"radius": 45.0, "pos": Vector3(-400, 0, 100), "bone": ""}] np._attack_process(0.016) np._flush_victim_list() _ck(fc.attacks.size() == 1 and nw.pushes.is_empty() and fc.synced.is_empty(), "IS_HUGE_RACE: 400 cm < 500 -> hit, no push, no sync") _ck(fx.spawned.size() == 1 and Vector3(fx.spawned[0][1]).is_equal_approx(Vector3(0, 0.5, 0.5)), "IS_HUGE_RACE: effect at (attacker + defending sphere) * 0.5, got %s" % [fx.spawned]) fc.ents[2000]["race"] = 101 # 防御球随受击方朝向旋转;__HitGood 的 scalar = cos(攻击者朝向 - 受击方朝向) _reset_hit(np, fc, nw, fx) vnode.reactions.clear() vnode.global_position = Vector3(0, 0, 1.6) vnode.spheres = [{"radius": 25.0, "pos": Vector3(0, -60, 100), "bone": ""}] vnode.rotation.y = 0.0 np._attack_process(0.016) _ck(fc.attacks.is_empty(), "victim yaw 0 -> defending sphere 220 cm ahead -> miss") _reset_hit(np, fc, nw, fx) vnode.rotation.y = PI np._attack_process(0.016) _ck(fc.attacks.size() == 1, "victim yaw π -> sphere swings to 100 cm -> hit") _ck(vnode.reactions.size() == 1 and is_equal_approx(float(vnode.reactions[0][1]), -1.0), "__HitGood scalar = cos(0 - π) = -1, got %s" % [vnode.reactions]) vnode.rotation.y = 0.0 # 身后:扫掠球够不到 _reset_hit(np, fc, nw, fx) vnode.global_position = Vector3(0, 0, -1.0) vnode.spheres = [{"radius": 45.0, "pos": Vector3(0, 0, 100), "bone": ""}] np._attack_process(0.016) _ck(fc.attacks.is_empty(), "victim behind the attacker -> sweep misses") # 占位胶囊(无 .msm):退化防御球 var ph := Node3D.new(); get_root().add_child(ph) await process_frame ph.global_position = Vector3(0, 0, 1.0) nw.nodes[2000] = ph _reset_hit(np, fc, nw, fx) np._attack_process(0.016) _ck(fc.attacks.size() == 1, "node without defending spheres -> fallback sphere -> hit") nw.nodes[2000] = vnode vnode.global_position = Vector3(0, 0, 1.0) np._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_mode(RefreshState 的 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-XY(OnGetFlyTargetPosition 平面分量)") _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")