# 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) var main := 1000 var ents := {1000: {"vid": 1000, "hp": 100, "dead": false}, 2000: {"vid": 2000, "hp": 50, "dead": false, "pos": Vector3(1, 0, 0)}} var attacks := [] var said := [] var affects := [] 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_affects() -> Array: return affects func attack(motion, vid) -> bool: attacks.append([motion, vid]); return true func set_target(v) -> bool: return true func move(a, b, c, d, e) -> bool: return true func say(t, s) -> bool: said.append([t, s]); return true 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 FakeView extends Node3D: var states := [] func set_anim_state(s): states.append(s) 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 _init() -> void: await _run() if _fail == 0: print("PASS: combat_fx_test (hitstun / 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 # --- 受击硬直 + 抖屏 --- fc.damage.emit(1000, 20, 0) # 命中(非闪避) _ck(np.is_stunned(), "hit -> stunned") _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, "stun -> pc.frozen = true (got %s)" % pc.frozen) # 闪避不硬直 np._hitstun_until = 0.0 fc.damage.emit(1000, 0, 1 << 2) # DODGE _ck(not np.is_stunned(), "dodge -> not stunned") # 别人受击不影响自己 np._hitstun_until = 0.0 fc.damage.emit(2000, 30, 0) _ck(not np.is_stunned(), "other's damage -> not stunned") # 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._hitstun_until = 0.0 np._target_vid = 2000 np.attack_period = 0.0 np.ATTACK_RANGE # (const, no-op) # 让目标在攻击距离内 # net_world 为 null -> _process 里 node_for 走不到;直接测 combo 计数逻辑: np._last_attack_t = np._now() np._combo = 0 # 模拟三次「窗口内攻击」 for i in 3: np._combo = (np._combo + 1) % 3 if (np._now() - np._last_attack_t) < np.COMBO_WINDOW else 0 np._last_attack_t = np._now() _ck(np._combo == 0, "combo cycles 1->2->0 over 3 hits, got %d" % np._combo) # --- 状态图标条 --- 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")