From 5f6258e3d4939cb0717d447c65cc76862e5b9ade Mon Sep 17 00:00:00 2001 From: starmen1265 Date: Mon, 31 Aug 2026 00:31:48 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E5=85=AB=E9=A1=B9=E6=97=A0=E5=A4=B4?= =?UTF-8?q?=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95=EF=BC=88=E9=81=93=E5=85=B7?= =?UTF-8?q?/=E5=BC=B9=E9=81=93/Boss/=E6=B5=B7=E9=9D=A2/=E5=9C=B0=E9=9D=A2?= =?UTF-8?q?=E5=8D=95=E4=BD=8D/=E6=B5=AE=E6=B8=B8=E7=82=AE/=E7=82=B8?= =?UTF-8?q?=E5=BC=B9/=E5=86=92=E7=83=9F=E6=A8=A1=E6=8B=9F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- tests/bomb_test.gd | 154 ++++++++++++++++++ tests/bomb_test.gd.uid | 1 + tests/boss_test.gd | 82 ++++++++++ tests/boss_test.gd.uid | 1 + tests/bullet_traj_test.gd | 101 ++++++++++++ tests/bullet_traj_test.gd.uid | 1 + tests/funnel_test.gd | 297 ++++++++++++++++++++++++++++++++++ tests/funnel_test.gd.uid | 1 + tests/ground_test.gd | 166 +++++++++++++++++++ tests/ground_test.gd.uid | 1 + tests/ocean_test.gd | 46 ++++++ tests/ocean_test.gd.uid | 1 + tests/powerup_test.gd | 194 ++++++++++++++++++++++ tests/powerup_test.gd.uid | 1 + tests/smoke_sim.gd | 65 ++++++++ tests/smoke_sim.gd.uid | 1 + 16 files changed, 1113 insertions(+) create mode 100644 tests/bomb_test.gd create mode 100644 tests/bomb_test.gd.uid create mode 100644 tests/boss_test.gd create mode 100644 tests/boss_test.gd.uid create mode 100644 tests/bullet_traj_test.gd create mode 100644 tests/bullet_traj_test.gd.uid create mode 100644 tests/funnel_test.gd create mode 100644 tests/funnel_test.gd.uid create mode 100644 tests/ground_test.gd create mode 100644 tests/ground_test.gd.uid create mode 100644 tests/ocean_test.gd create mode 100644 tests/ocean_test.gd.uid create mode 100644 tests/powerup_test.gd create mode 100644 tests/powerup_test.gd.uid create mode 100644 tests/smoke_sim.gd create mode 100644 tests/smoke_sim.gd.uid diff --git a/tests/bomb_test.gd b/tests/bomb_test.gd new file mode 100644 index 0000000..986b29b --- /dev/null +++ b/tests/bomb_test.gd @@ -0,0 +1,154 @@ +extends SceneTree +## 炸弹系统回归测试(时间驱动): +## 初始 2 枚 → 引爆清敌清弹-1 → 用尽 → 空仓无效 → Boss 击破 +1 +## → B 道具拾取 +1 → 上限 5 +## 输入映射层:fire 含 J、bomb 含 K +## 运行:godot --headless -s tests/bomb_test.gd + +const GLOBAL_TIMEOUT := 25.0 + +var _phase := 0 +var _phase_time := 0.0 +var _time := 0.0 +var _game: Game +var _fails: Array[String] = [] + + +func _initialize() -> void: + _game = (load("res://scenes/game.tscn") as PackedScene).instantiate() + root.add_child(_game) + print("[bomb_test] game instantiated") + + +func _fail(msg: String) -> void: + _fails.append(msg) + print("[FAIL] " + msg) + + +func _next_phase() -> void: + _phase += 1 + _phase_time = 0.0 + + +func _finish() -> bool: + if _fails.is_empty(): + print("[bomb_test] PASS") + else: + print("[bomb_test] FAIL: %d issue(s)" % _fails.size()) + quit(0 if _fails.is_empty() else 1) + return true + + +## 输入映射检查(真实按键输入无法无头模拟,检查映射配置本身) +func _check_input_maps() -> void: + var fire_ok := false + if InputMap.has_action("fire"): + for event in InputMap.action_get_events("fire"): + if event is InputEventKey and (event as InputEventKey).physical_keycode == 74: + fire_ok = true + if not fire_ok: + _fail("fire action missing J key") + var bomb_ok := false + if InputMap.has_action("bomb"): + for event in InputMap.action_get_events("bomb"): + if event is InputEventKey and (event as InputEventKey).physical_keycode == 75: + bomb_ok = true + if not bomb_ok: + _fail("bomb action missing K key") + + +func _process(delta: float) -> bool: + _time += delta + if _time > GLOBAL_TIMEOUT: + _fail("global timeout in phase %d" % _phase) + return _finish() + if _game._enemies == null: + return false + + _phase_time += delta + match _phase: + 0: + _check_input_maps() + if _game.player.bombs != 2: + _fail("initial bombs=%d expected 2" % _game.player.bombs) + # 场上放 3 架敌机 + 2 发敌弹 + for i in 3: + var enemy := EnemySmall.new() + enemy.setup(150.0 + 120.0 * i, 300.0 + 60.0 * i, 100.0) + _game._enemies.add_child(enemy) + for i in 2: + var bullet := EnemyBullet.new() + bullet.position = Vector2(120.0 + 200.0 * i, 400.0) + _game._enemy_bullets.add_child(bullet) + _game.player.use_bomb() + _next_phase() + 1: + # 首波 spawner 敌机 0.8s 后才出现,检查点在 0.5s 安全 + if _phase_time >= 0.5: + var enemies_left := _game._enemies.get_child_count() + var bullets_left := _game._enemy_bullets.get_child_count() + if enemies_left != 0: + _fail("bomb left enemies: %d" % enemies_left) + if bullets_left != 0: + _fail("bomb left enemy bullets: %d" % bullets_left) + if _game.player.bombs != 1: + _fail("bombs=%d expected 1" % _game.player.bombs) + else: + print("[bomb_test] detonate ok: enemies/bullets cleared, bombs 2->1, score=%d" % _game.score) + _next_phase() + 2: + _game.player.use_bomb() + _next_phase() + 3: + if _phase_time >= 0.3: + if _game.player.bombs != 0: + _fail("second bomb not consumed: %d" % _game.player.bombs) + _game.player.use_bomb() # 空仓 + _next_phase() + 4: + if _phase_time >= 0.3: + if _game.player.bombs != 0: + _fail("empty stock still fired: %d" % _game.player.bombs) + else: + print("[bomb_test] stock drain + empty guard ok") + var boss := _game.spawn_boss(1.0) + boss.apply_damage(99999) + _next_phase() + 5: + if _phase_time >= 1.0: + if _game.player.bombs != 1: + _fail("boss kill did not grant bomb: %d" % _game.player.bombs) + else: + print("[bomb_test] boss kill +1 bomb ok") + # B 道具拾取链路:1 → 2,再补到 5 验证上限 + _collect_bomb_pickup() + _next_phase() + 6: + if _phase_time >= 0.5: + if _game.player.bombs != 2: + _fail("B pickup did not add bomb: %d" % _game.player.bombs) + else: + print("[bomb_test] B pickup +1 ok") + _collect_bomb_pickup() + _collect_bomb_pickup() + _collect_bomb_pickup() + _collect_bomb_pickup() + _next_phase() + 7: + if _phase_time >= 0.5: + if _game.player.bombs != 5: + _fail("bomb cap broken: %d expected 5" % _game.player.bombs) + else: + print("[bomb_test] bomb cap at 5 ok") + return _finish() + return false + + +func _collect_bomb_pickup() -> void: + var powerups := _game.get_node_or_null("Powerups") as Node2D + var pickup := Powerup.new() + pickup.kind = Powerup.Kind.BOMB + pickup.position = _game.player.global_position + powerups.add_child(pickup) + # 直接触发玩家拾取路径(与 area_entered 信号同一入口) + _game.player._collect_powerup(pickup) diff --git a/tests/bomb_test.gd.uid b/tests/bomb_test.gd.uid new file mode 100644 index 0000000..6aadff7 --- /dev/null +++ b/tests/bomb_test.gd.uid @@ -0,0 +1 @@ +uid://5avhpka6nv87 diff --git a/tests/boss_test.gd b/tests/boss_test.gd new file mode 100644 index 0000000..5e82434 --- /dev/null +++ b/tests/boss_test.gd @@ -0,0 +1,82 @@ +extends SceneTree +## Boss 回归测试(时间驱动,与无头模式实际帧率无关): +## A) 独立 Boss:5s 内进场并开始弹幕;秒杀后 4s 内完成爆炸链释放 +## B) Game 集成:spawn_boss 血条显示→击杀后血条隐藏、得分 +5000 +## 运行:godot --headless -s tests/boss_test.gd + +var _phase := 0 +var _time := 0.0 +var _boss: Boss +var _game: Game +var _bullets: Node2D +var _bullet_time := 0.0 +var _phase_time := 0.0 +var _score_before := 0 +var _fails: Array[String] = [] + + +func _initialize() -> void: + _bullets = Node2D.new() + _bullets.add_to_group("enemy_bullets") + root.add_child(_bullets) + _boss = Boss.new() + _boss.setup(1.0) + root.add_child(_boss) + print("[boss_test] phase A: standalone boss") + + +func _fail(msg: String) -> void: + _fails.append(msg) + print("[FAIL] " + msg) + + +func _process(delta: float) -> bool: + _time += delta + _phase_time += delta + match _phase: + 0: + if _bullets.get_child_count() > 0: + _bullet_time += delta + if _phase_time >= 5.0: + if not _boss._entered: + _fail("boss did not enter (pos.y=%.1f)" % _boss.position.y) + if _bullet_time < 0.4: + _fail("boss fired too little (%.2fs bullet time)" % _bullet_time) + _boss.apply_damage(99999) + _next_phase() + 1: + if not is_instance_valid(_boss): + print("[boss_test] phase A done: %.2fs bullet time, death chain freed boss" % _bullet_time) + _next_phase() + elif _phase_time > 4.0: + _fail("death chain did not free boss in 4s") + _next_phase() + 2: + _game = (load("res://scenes/game.tscn") as PackedScene).instantiate() + root.add_child(_game) + _score_before = _game.score + _boss = _game.spawn_boss(1.0) + if not _game._boss_bar_bg.visible: + _fail("boss bar not shown after spawn") + _boss.apply_damage(99999) + _next_phase() + 3: + if _phase_time >= 4.0: + if _game._boss_bar_bg.visible: + _fail("boss bar still visible after death") + if _game.score < _score_before + 5000: + _fail("score not credited: %d -> %d" % [_score_before, _game.score]) + else: + print("[boss_test] phase B done: score %d -> %d" % [_score_before, _game.score]) + if _fails.is_empty(): + print("[boss_test] PASS") + else: + print("[boss_test] FAIL: %d issue(s)" % _fails.size()) + quit(0 if _fails.is_empty() else 1) + return true + return false + + +func _next_phase() -> void: + _phase += 1 + _phase_time = 0.0 diff --git a/tests/boss_test.gd.uid b/tests/boss_test.gd.uid new file mode 100644 index 0000000..bad0283 --- /dev/null +++ b/tests/boss_test.gd.uid @@ -0,0 +1 @@ +uid://cd2jn0sw1u738 diff --git a/tests/bullet_traj_test.gd b/tests/bullet_traj_test.gd new file mode 100644 index 0000000..3ddbea9 --- /dev/null +++ b/tests/bullet_traj_test.gd @@ -0,0 +1,101 @@ +extends SceneTree +## 玩家子弹弹道回归测试(时间驱动): +## L4 齐射后快照所有子弹位置,0.25s 后比对实际位移方向, +## 必须命中 UP / ±22° / ±30° 五个弹道之一,且各组数量正确。 +## 在 spawner 首波敌机(0.8s)出现前完成测量,保证确定性。 +## 运行:godot --headless -s tests/bullet_traj_test.gd + +const SNAP_DELAY := 0.1 +const FLY_TIME := 0.25 +const DOT_EPS := 0.995 +const GLOBAL_TIMEOUT := 15.0 + +var _time := 0.0 +var _snapped := false +var _game: Game +var _pbullets: Node2D +var _fails: Array[String] = [] +var _snapshot: Array = [] # [bullet: Node2D, pos: Vector2] + + +func _initialize() -> void: + _game = (load("res://scenes/game.tscn") as PackedScene).instantiate() + root.add_child(_game) + print("[traj_test] game instantiated") + + +func _fail(msg: String) -> void: + _fails.append(msg) + print("[FAIL] " + msg) + + +func _ensure_refs() -> bool: + if _pbullets == null: + _pbullets = _game.get_node_or_null("PlayerBullets") as Node2D + return _pbullets != null + + +func _process(delta: float) -> bool: + _time += delta + if _time > GLOBAL_TIMEOUT: + _fail("global timeout") + return _finish() + if not _ensure_refs(): + return false + + if not _snapped and _time >= SNAP_DELAY: + _snapped = true + # 先射一梭子 L4(fire 从首帧按住,此处已有一发齐射在飞) + _game.player.weapon_level = 4 + Input.action_release("fire") + _game.player._fire() + _snapshot = [] + for bullet in _pbullets.get_children(): + _snapshot.append([bullet, (bullet as Node2D).position]) + if _snapshot.size() != 12: + _fail("L4 volley=%d expected 12" % _snapshot.size()) + return false + + if _snapped and _time >= SNAP_DELAY + FLY_TIME: + var allowed := [ + Vector2.UP, + Vector2.UP.rotated(deg_to_rad(-22.0)), + Vector2.UP.rotated(deg_to_rad(22.0)), + Vector2.UP.rotated(deg_to_rad(-30.0)), + Vector2.UP.rotated(deg_to_rad(30.0)), + ] + var counts := [0, 0, 0, 0, 0] + var classified := 0 + for entry in _snapshot: + var bullet: Node2D = entry[0] + var start: Vector2 = entry[1] + if not is_instance_valid(bullet): + _fail("bullet freed before measurement window ended") + continue + var disp: Vector2 = bullet.position - start + if disp.length() < 50.0: + _fail("bullet barely moved: %.1f px" % disp.length()) + continue + var dir := disp.normalized() + for i in allowed.size(): + if dir.dot(allowed[i]) >= DOT_EPS: + counts[i] += 1 + classified += 1 + break + if classified != _snapshot.size(): + _fail("only %d/%d bullets on valid trajectories" % [classified, _snapshot.size()]) + elif counts[0] != 6 or counts[1] != 2 or counts[2] != 2 or counts[3] != 1 or counts[4] != 1: + _fail("trajectory group counts wrong: straight=%d L22=%d R22=%d L30=%d R30=%d" % counts) + else: + print("[traj_test] trajectories ok: straight=%d ±22=%d+%d ±30=%d+%d" % [counts[0], counts[1], counts[2], counts[3], counts[4]]) + return _finish() + return false + + +func _finish() -> bool: + if _fails.is_empty(): + print("[traj_test] PASS") + else: + print("[traj_test] FAIL: %d issue(s)" % _fails.size()) + quit(0 if _fails.is_empty() else 1) + return true diff --git a/tests/bullet_traj_test.gd.uid b/tests/bullet_traj_test.gd.uid new file mode 100644 index 0000000..4936688 --- /dev/null +++ b/tests/bullet_traj_test.gd.uid @@ -0,0 +1 @@ +uid://dv8mr86bn8gmx diff --git a/tests/funnel_test.gd b/tests/funnel_test.gd new file mode 100644 index 0000000..fcb1f29 --- /dev/null +++ b/tests/funnel_test.gd @@ -0,0 +1,297 @@ +extends SceneTree +## 浮游炮机型回归测试(时间驱动): +## A) 默认战斗机无浮游炮;GameSession 传递变体 → 新局为浮游炮机 +## B) 等级 1/2/3/4 → 浮游炮 0/2/4/6 台 +## C) 常态散开:侧位浮游炮激光打得中侧旁堡垒,打不中中线堡垒 +## D) 按 I 聚焦:中线高血堡垒被合体粗激光快速击毁 +## E) focus 输入映射含 I 键 +## 运行:godot --headless -s tests/funnel_test.gd + +const GLOBAL_TIMEOUT := 40.0 + +var _phase := 0 +var _step := 0 +var _phase_time := 0.0 +var _time := 0.0 +var _game: Game +var _fort_center: EnemyFortress +var _fort_left: EnemyFortress +var _fort_right: EnemyFortress +var _fort_focus: EnemyFortress +var _fails: Array[String] = [] + + +func _initialize() -> void: + GameSession.plane_variant = 0 + _game = (load("res://scenes/game.tscn") as PackedScene).instantiate() + root.add_child(_game) + print("[funnel_test] game A instantiated (fighter)") + + +func _fail(msg: String) -> void: + _fails.append(msg) + print("[FAIL] " + msg) + + +func _next_phase() -> void: + _phase += 1 + _step = 0 + _phase_time = 0.0 + + +func _restart_step() -> void: + _step += 1 + _phase_time = 0.0 + + +func _finish() -> bool: + GameSession.plane_variant = 0 + if _fails.is_empty(): + print("[funnel_test] PASS") + else: + print("[funnel_test] FAIL: %d issue(s)" % _fails.size()) + quit(0 if _fails.is_empty() else 1) + return true + + +func _clear_field() -> void: + for enemy in _game._enemies.get_children(): + (enemy as Node).queue_free() + _game.clear_enemy_bullets() + # 防止上一阶段流弹干扰断言 + _game.player.invuln = 6.0 + + +func _spawn_fortress(x: float, y: float, hp: int) -> EnemyFortress: + var fortress := EnemyFortress.new() + fortress.setup(x, y, 1.0) + _game._enemies.add_child(fortress) + fortress.hp = hp + return fortress + + +func _process(delta: float) -> bool: + _time += delta + if _time > GLOBAL_TIMEOUT: + _fail("global timeout in phase %d" % _phase) + return _finish() + if _game._enemies == null: + return false + + _phase_time += delta + match _phase: + 0: + # A: 战斗机默认无浮游炮 + if _step == 0: + if _game.player._funnels.size() != 0: + _fail("fighter has funnels: %d" % _game.player._funnels.size()) + else: + print("[funnel_test] fighter has no funnels ok") + _restart_step() + elif _phase_time >= 0.1: + _next_phase() + 1: + # A2: 切换变体 → 新局生效(旧 game 立即移除,避免跨实例物理污染) + if _step == 0: + GameSession.plane_variant = 1 + var game_b := (load("res://scenes/game.tscn") as PackedScene).instantiate() + root.add_child(game_b) + _game.queue_free() + _game = game_b + _restart_step() + elif _game.player != null: + if _game.player.variant != Player.Variant.FUNNEL: + _fail("variant not applied: %d" % _game.player.variant) + elif _game.player._funnels.size() != 0: + _fail("L1 should have 0 funnels, got %d" % _game.player._funnels.size()) + else: + print("[funnel_test] variant applied, L1=0 funnels ok") + _next_phase() + 2: + # B: 等级→浮游炮数 + 展开位置分散(两两间距 ≥30px) + if _step == 0: + for level in [2, 3, 4]: + _game.player.weapon_level = level + _game.player._sync_funnels() + var expected: int = (level - 1) * 2 + if _game.player._funnels.size() != expected: + _fail("L%d funnels=%d expected %d" % [level, _game.player._funnels.size(), expected]) + if _fails.is_empty(): + print("[funnel_test] L2/L3/L4 = 2/4/6 funnels ok") + _restart_step() + elif _phase_time >= 0.9: + var positions: Array = [] + for funnel in _game.player._funnels: + positions.append((funnel as Node2D).position) + var min_dist := 9999.0 + for a in positions.size(): + for b in range(a + 1, positions.size()): + min_dist = minf(min_dist, (positions[a] as Vector2).distance_to(positions[b])) + if min_dist < 30.0: + _fail("funnels not spread: min pairwise dist %.1f" % min_dist) + else: + print("[funnel_test] spread formation min pairwise dist %.1f ok" % min_dist) + _next_phase() + 3: + # E: focus 映射含 I(73) + if _step == 0: + var focus_ok := false + for event in InputMap.action_get_events("focus"): + if event is InputEventKey and (event as InputEventKey).physical_keycode == 73: + focus_ok = true + if not focus_ok: + _fail("focus action missing I key") + else: + print("[funnel_test] focus=I mapping ok") + _next_phase() + 4: + # C: 散开覆盖(L2:±38 侧位)。目标放高处、玩家放低处,保证足够照射窗口 + match _step: + 0: + _clear_field() + _game.player.weapon_level = 2 + _game.player._sync_funnels() + _game.player.position = Vector2(270.0, 700.0) + _fort_center = _spawn_fortress(270.0, 150.0, 60) # 仅机体激光 18dps + _fort_left = _spawn_fortress(234.0, 150.0, 10) # 左浮游炮位(-36)7dps + _fort_right = _spawn_fortress(306.0, 150.0, 10) # 右浮游炮位(+36) + Input.action_press("fire") + _restart_step() + 1: + if _phase_time >= 2.2: + var center_stands := is_instance_valid(_fort_center) and not _fort_center.dead + var left_dead := (not is_instance_valid(_fort_left)) or _fort_left.dead + var right_dead := (not is_instance_valid(_fort_right)) or _fort_right.dead + if center_stands and left_dead and right_dead: + print("[funnel_test] spread: sides killed, center survives (hp=%d) ok" % [_fort_center.hp]) + else: + _fail("spread outcome wrong: center_stands=%s L=%s R=%s" % [str(center_stands), str(left_dead), str(right_dead)]) + Input.action_release("fire") + _next_phase() + 5: + # D: 聚焦粗激光(L4 合体 = (18+6×7)×1.5 = 90dps) + match _step: + 0: + _clear_field() + _game.player.weapon_level = 4 + _game.player._sync_funnels() + _game.player.position = Vector2(270.0, 700.0) + _fort_focus = _spawn_fortress(270.0, 150.0, 60) # 60hp / 90dps ≈ 0.67s + Input.action_press("fire") + Input.action_press("focus") + _restart_step() + 1: + var killed := (not is_instance_valid(_fort_focus)) or _fort_focus.dead + if killed: + var dps := _game.player._focus_beam.dps + if dps < 89.0 or dps > 91.0: + _fail("focus dps=%.1f expected 90.0" % dps) + elif _phase_time < 1.6: + print("[funnel_test] focused beam dps=%.0f, killed 60hp in %.2fs ok" % [dps, _phase_time]) + else: + _fail("focused beam too slow: %.2fs" % _phase_time) + Input.action_release("fire") + Input.action_release("focus") + _next_phase() + elif _phase_time > 3.0: + _fail("focused beam failed to kill (hp=%d)" % _fort_focus.hp) + Input.action_release("fire") + Input.action_release("focus") + _next_phase() + 6: + # E2: 聚焦期间移动速度减半(0.5s 期望位移 ~85px,全速为 170px) + match _step: + 0: + _game.player.position = Vector2(100.0, 700.0) + Input.action_press("fire") + Input.action_press("focus") + Input.action_press("move_right") + _restart_step() + 1: + if _phase_time >= 0.5: + Input.action_release("move_right") + var dx := _game.player.position.x - 100.0 + if dx < 45.0 or dx > 125.0: + _fail("focus speed wrong: dx=%.0f px in 0.5s (expect ~85)" % dx) + else: + print("[funnel_test] focus half-speed ok (dx=%.0f px)" % dx) + Input.action_release("fire") + Input.action_release("focus") + _next_phase() + 7: + # E3: 侧身帧状态切换;机头不歪不变量(无旋转、无缩放变换) + match _step: + 0: + Input.action_press("move_left") + _restart_step() + 1: + if _phase_time >= 0.5: + var state := _game.player._bank_state + if state != -1: + _fail("bank state=%d expected -1 (left)" % state) + elif absf(_game.player.sprite.rotation) > 0.001 or absf(_game.player.sprite.scale.x - 1.0) > 0.001: + _fail("bank must be sprite-swap only: rot=%.3f scale.x=%.3f" % [_game.player.sprite.rotation, _game.player.sprite.scale.x]) + else: + print("[funnel_test] bank-left state ok (nose straight, no transform)") + Input.action_release("move_left") + _restart_step() + 2: + if _phase_time >= 0.6: + if _game.player._bank_state != 0: + _fail("bank not restored: state=%d" % _game.player._bank_state) + else: + print("[funnel_test] bank restored to neutral ok") + _next_phase() + 8: + # E4: 战斗机(P-38)侧身帧真实切换(素材已生成时应换贴图) + match _step: + 0: + var fighter := Player.new() + fighter.variant = Player.Variant.FIGHTER + fighter.position = Vector2(270.0, 700.0) + _game.add_child(fighter) + Input.action_press("move_left") + _restart_step() + 1: + if _phase_time >= 0.5: + var p := _game.get_child(_game.get_child_count() - 1) as Player + if p == null: + _fail("fighter not found in tree") + elif p._tex_bank_left == null: + _fail("P-38-bank-left.png not loaded (missing art?)") + elif p.sprite.texture != p._tex_bank_left: + _fail("bank texture not swapped for fighter") + elif p._bank_state != -1: + _fail("fighter bank state=%d expected -1" % p._bank_state) + else: + print("[funnel_test] fighter P-38 bank texture swap ok") + Input.action_release("move_left") + _next_phase() + 9: + # E5: 雷光(FUNNEL)侧身帧真实切换 + 平飞帧已是美术 PNG + match _step: + 0: + var funnel_p := Player.new() + funnel_p.variant = Player.Variant.FUNNEL + funnel_p.position = Vector2(270.0, 700.0) + _game.add_child(funnel_p) + Input.action_press("move_right") + _restart_step() + 1: + if _phase_time >= 0.5: + var p := _game.get_child(_game.get_child_count() - 1) as Player + if p == null: + _fail("funnel player not found in tree") + elif p._tex_neutral != null and p._tex_neutral == Player.TEX_FUNNEL: + _fail("funnel neutral still SVG placeholder (plane_funnel.png not loaded?)") + elif p._tex_bank_right == null: + _fail("plane_funnel-bank-right.png not loaded (missing art?)") + elif p.sprite.texture != p._tex_bank_right: + _fail("bank texture not swapped for funnel variant") + elif p._bank_state != 1: + _fail("funnel bank state=%d expected 1" % p._bank_state) + else: + print("[funnel_test] funnel plane_funnel bank texture swap ok") + Input.action_release("move_right") + return _finish() + return false diff --git a/tests/funnel_test.gd.uid b/tests/funnel_test.gd.uid new file mode 100644 index 0000000..eaf70a8 --- /dev/null +++ b/tests/funnel_test.gd.uid @@ -0,0 +1 @@ +uid://b5qylgkck5nlg diff --git a/tests/ground_test.gd b/tests/ground_test.gd new file mode 100644 index 0000000..f0aa15f --- /dev/null +++ b/tests/ground_test.gd @@ -0,0 +1,166 @@ +extends SceneTree +## 地面单位回归测试(时间驱动): +## A) 独立堡垒:随地面滚动(~120px/s)、有开火产出敌弹 +## B) Game 集成:堡垒击杀 +300、船击杀 +800 +## C) 碰撞规则:玩家机体叠在堡垒上不掉命;玩家子弹真实命中堡垒 +## 运行:godot --headless -s tests/ground_test.gd + +const GLOBAL_TIMEOUT := 25.0 + +var _phase := 0 +var _step := 0 +var _phase_time := 0.0 +var _time := 0.0 +var _fortress: EnemyFortress +var _ship: EnemyShip +var _game: Game +var _bullets: Node2D +var _start_y := 0.0 +var _bullet_time := 0.0 +var _score_before := 0 +var _lives_before := 3 +var _fails: Array[String] = [] + + +func _initialize() -> void: + _bullets = Node2D.new() + _bullets.add_to_group("enemy_bullets") + root.add_child(_bullets) + _fortress = EnemyFortress.new() + _fortress.setup(270.0, 200.0, 1.0) + root.add_child(_fortress) + _start_y = 200.0 + print("[ground_test] phase A: standalone fortress") + + +func _fail(msg: String) -> void: + _fails.append(msg) + print("[FAIL] " + msg) + + +func _next_phase() -> void: + _phase += 1 + _step = 0 + _phase_time = 0.0 + + +func _restart_step() -> void: + _step += 1 + _phase_time = 0.0 + + +func _finish() -> bool: + if _fails.is_empty(): + print("[ground_test] PASS") + else: + print("[ground_test] FAIL: %d issue(s)" % _fails.size()) + quit(0 if _fails.is_empty() else 1) + return true + + +## 清场(敌机+敌弹),保证后续断言确定性 +func _clear_field() -> void: + for enemy in _game._enemies.get_children(): + (enemy as Node).queue_free() + for bullet in _game._enemy_bullets.get_children(): + bullet.queue_free() + + +func _process(delta: float) -> bool: + _time += delta + if _time > GLOBAL_TIMEOUT: + _fail("global timeout in phase %d" % _phase) + return _finish() + + _phase_time += delta + match _phase: + 0: + if _bullets.get_child_count() > 0: + _bullet_time += delta + if _phase_time >= 4.0: + var moved := _fortress.position.y - _start_y + if moved < 380.0 or moved > 620.0: + _fail("fortress scroll wrong: moved %.0f px in 4s (expect ~480)" % moved) + elif _bullet_time < 0.3: + _fail("fortress fired too little: %.2fs" % _bullet_time) + else: + print("[ground_test] fortress scroll+fire ok (moved %.0f px, bullet %.1fs)" % [moved, _bullet_time]) + _fortress.apply_damage(999) + _next_phase() + 1: + if not is_instance_valid(_fortress): + _next_phase() + elif _phase_time > 2.0: + _fail("fortress not freed after kill") + _next_phase() + 2: + if _game == null: + _game = (load("res://scenes/game.tscn") as PackedScene).instantiate() + root.add_child(_game) + elif _game._enemies != null: + _ship = EnemyShip.new() + _ship.setup(270.0, 300.0, 1.0) + _game._enemies.add_child(_ship) + var f2 := EnemyFortress.new() + f2.setup(150.0, 300.0, 1.0) + _game._enemies.add_child(f2) + _score_before = _game.score + f2.apply_damage(999) + _next_phase() + 3: + if _phase_time >= 0.6: + if _game.score != _score_before + 300: + _fail("fortress score: %d -> %d (expected +300)" % [_score_before, _game.score]) + else: + print("[ground_test] fortress kill +300 ok") + _score_before = _game.score + _ship.apply_damage(999) + _next_phase() + 4: + if _phase_time >= 0.6: + if _game.score != _score_before + 800: + _fail("ship score: %d -> %d (expected +800)" % [_score_before, _game.score]) + else: + print("[ground_test] ship kill +800 ok") + _next_phase() + 5: + match _step: + 0: + _clear_field() + _lives_before = _game.lives + var f3 := EnemyFortress.new() + f3.setup(_game.player.global_position.x, _game.player.global_position.y, 1.0) + _game._enemies.add_child(f3) + _restart_step() + 1: + if _phase_time >= 0.8: + if _game.lives != _lives_before or not _game.player.active: + _fail("ramming fortress hurt player: lives %d->%d active=%s" % [ + _lives_before, _game.lives, str(_game.player.active)]) + else: + print("[ground_test] player overlaps fortress unharmed ok") + _next_phase() + 6: + match _step: + 0: + _clear_field() + var f4 := EnemyFortress.new() + f4.setup(270.0, 300.0, 1.0) + _game._enemies.add_child(f4) + var bullet := PlayerBullet.new() + bullet.direction = Vector2.UP + (_game.get_node("PlayerBullets") as Node2D).add_child(bullet) + bullet.position = Vector2(270.0, 430.0) + _restart_step() + 1: + if _phase_time >= 0.7: + if _game._enemies.get_child_count() == 0: + _fail("fortress missing before bullet-hit check") + else: + var target := _game._enemies.get_child(0) as EnemyFortress + if target.hp != 3: + _fail("player bullet did not hit fortress: hp=%d expected 3" % target.hp) + else: + print("[ground_test] player bullet hits fortress ok") + return _finish() + return false diff --git a/tests/ground_test.gd.uid b/tests/ground_test.gd.uid new file mode 100644 index 0000000..6dcf8e2 --- /dev/null +++ b/tests/ground_test.gd.uid @@ -0,0 +1 @@ +uid://cfut4inswm23u diff --git a/tests/ocean_test.gd b/tests/ocean_test.gd new file mode 100644 index 0000000..9f3865c --- /dev/null +++ b/tests/ocean_test.gd @@ -0,0 +1,46 @@ +extends SceneTree +## 海面覆盖回归测试:滚动两个完整周期(1200 帧), +## 逐帧断言贴图联合覆盖包含整屏 Rect2(0,0,540,960)。 +## 运行:godot --headless -s tests/ocean_test.gd + +const SCREEN := Rect2(0.0, 0.0, 540.0, 960.0) +const TILE_HALF := Vector2(270.0, 270.0) +const FRAMES := 1200 + +var _ocean: OceanScroll +var _frames := 0 +var _fails := 0 + + +func _initialize() -> void: + _ocean = OceanScroll.new() + root.add_child(_ocean) + print("[ocean_test] ocean instantiated") + + +func _process(_delta: float) -> bool: + _frames += 1 + # 第 1 帧本回调先于海面节点的首个 _process,跳过(贴图尚未定位) + if _frames >= 2: + var covered := Rect2() + var first := true + for tile in _ocean._tiles: + var rect := Rect2(tile.position - TILE_HALF, TILE_HALF * 2.0) + if first: + covered = rect + first = false + else: + covered = covered.merge(rect) + if not covered.encloses(SCREEN): + _fails += 1 + if _fails <= 3: + print("[FAIL] frame %d coverage=%s tiles=%s" % [_frames, covered, _ocean._tiles.map(func(t): return t.position)]) + + if _frames >= FRAMES: + if _fails == 0: + print("[ocean_test] PASS: %d 帧整屏全覆盖" % FRAMES) + else: + print("[ocean_test] FAIL: %d/%d 帧覆盖不足" % [_fails, FRAMES]) + quit(1 if _fails > 0 else 0) + return true + return false diff --git a/tests/ocean_test.gd.uid b/tests/ocean_test.gd.uid new file mode 100644 index 0000000..1523944 --- /dev/null +++ b/tests/ocean_test.gd.uid @@ -0,0 +1 @@ +uid://bp3enf7navowk diff --git a/tests/powerup_test.gd b/tests/powerup_test.gd new file mode 100644 index 0000000..4319f16 --- /dev/null +++ b/tests/powerup_test.gd @@ -0,0 +1,194 @@ +extends SceneTree +## 道具系统回归测试(时间驱动): +## L1 双发 → 拾取升级 L2 四发 → 直设 L3 六发(4 直射+2 斜射) +## → 直设 L4 十二发(6 直射+4×22°+2×30°) +## → 满级拾取 +500 分 → 死亡降一级 → 掉落标记敌机死亡产出道具 +## 注意:SceneTree._initialize 中 add_child 后节点 _ready 会在首帧才执行, +## 因此容器引用在首个 _process 里惰性获取。 +## 运行:godot --headless -s tests/powerup_test.gd + +const GLOBAL_TIMEOUT := 30.0 + +var _phase := 0 +var _step := 0 +var _phase_time := 0.0 +var _time := 0.0 +var _game: Game +var _pbullets: Node2D +var _powerups: Node2D +var _score_before := 0 +var _fails: Array[String] = [] + + +func _initialize() -> void: + _game = (load("res://scenes/game.tscn") as PackedScene).instantiate() + root.add_child(_game) + print("[powerup_test] game instantiated") + + +func _fail(msg: String) -> void: + _fails.append(msg) + print("[FAIL] " + msg) + + +func _next_phase() -> void: + _phase += 1 + _step = 0 + _phase_time = 0.0 + + +func _restart_step_timer() -> void: + _step += 1 + _phase_time = 0.0 + + +## 容器引用就绪前不进入任何阶段(_ready 在首帧执行) +func _ensure_refs() -> bool: + if _pbullets == null: + _pbullets = _game.get_node_or_null("PlayerBullets") as Node2D + _powerups = _game.get_node_or_null("Powerups") as Node2D + return _pbullets != null and _powerups != null + + +## 清空玩家弹容器并返回清空前数量 +func _count_and_clear_bullets() -> int: + var children := _pbullets.get_children() + var n := children.size() + for bullet in children: + _pbullets.remove_child(bullet) + (bullet as Node).queue_free() + return n + + +func _spawn_pickup_at_player() -> void: + var pickup := Powerup.new() + pickup.position = _game.player.global_position + _powerups.add_child(pickup) + + +func _check_volley(expected: int, level_name: String) -> void: + var n := _count_and_clear_bullets() + Input.action_release("fire") + if n != expected: + _fail("%s volley=%d expected %d" % [level_name, n, expected]) + else: + print("[powerup_test] %s volley ok: %d bullets" % [level_name, n]) + + +func _finish() -> bool: + if _fails.is_empty(): + print("[powerup_test] PASS") + else: + print("[powerup_test] FAIL: %d issue(s)" % _fails.size()) + quit(0 if _fails.is_empty() else 1) + return true + + +func _process(delta: float) -> bool: + _time += delta + if _time > GLOBAL_TIMEOUT: + _fail("global timeout in phase %d" % _phase) + return _finish() + if not _ensure_refs(): + return false + + _phase_time += delta + match _phase: + 0: + if _step == 0: + Input.action_press("fire") + _restart_step_timer() + elif _phase_time >= 0.1: + _check_volley(2, "L1") + _next_phase() + 1: + match _step: + 0: + _spawn_pickup_at_player() + _restart_step_timer() + 1: + if _phase_time >= 0.6: + if _game.player.weapon_level != 2: + _fail("pickup did not level up: level=%d" % _game.player.weapon_level) + elif _powerups.get_child_count() != 0: + _fail("pickup not consumed: %d left" % _powerups.get_child_count()) + else: + print("[powerup_test] pickup -> L2 ok") + Input.action_press("fire") + _restart_step_timer() + 2: + if _phase_time >= 0.1: + _check_volley(4, "L2") + _next_phase() + 2: + match _step: + 0: + _game.player.weapon_level = 3 + Input.action_press("fire") + _restart_step_timer() + 1: + if _phase_time >= 0.1: + _check_volley(6, "L3") + _game.player.weapon_level = 4 + Input.action_press("fire") + _restart_step_timer() + 2: + if _phase_time >= 0.1: + _check_volley(12, "L4") + _next_phase() + 3: + if _step == 0: + _score_before = _game.score + _spawn_pickup_at_player() + _restart_step_timer() + elif _phase_time >= 0.6: + if _game.player.weapon_level != 4: + _fail("capped pickup changed level: %d" % _game.player.weapon_level) + elif _game.score != _score_before + 500: + _fail("capped pickup score: %d -> %d (expected +500)" % [_score_before, _game.score]) + else: + print("[powerup_test] capped pickup +500 ok") + _next_phase() + 4: + if _step == 0: + _game.player.die() + _restart_step_timer() + elif _phase_time >= 0.1: + if _game.player.weapon_level != 3: + _fail("death downgrade: level=%d expected 3" % _game.player.weapon_level) + else: + print("[powerup_test] death downgrade 4->3 ok") + _next_phase() + 5: + if _step == 0: + var enemy := EnemySmall.new() + enemy.setup(270.0, 200.0, 100.0) + enemy.drops_powerup = true + _game._enemies.add_child(enemy) + enemy.apply_damage(99) + _restart_step_timer() + elif _phase_time >= 0.5: + if _powerups.get_child_count() != 1: + _fail("dropper enemy produced %d powerups (expected 1)" % _powerups.get_child_count()) + else: + print("[powerup_test] dropper spawn ok") + _next_phase() + 6: + if _step == 0: + # 清掉前面阶段残留的道具再验证 Boss 必掉 P + for pickup in _powerups.get_children(): + _powerups.remove_child(pickup) + (pickup as Node).queue_free() + var boss := _game.spawn_boss(1.0) + boss.apply_damage(99999) + _restart_step_timer() + elif _phase_time >= 1.2: + var kinds := [] + for pickup in _powerups.get_children(): + kinds.append((pickup as Powerup).kind) + if kinds != [Powerup.Kind.POWER]: + _fail("boss drop kinds=%s expected [POWER]" % str(kinds)) + else: + print("[powerup_test] boss always drops POWER ok") + return _finish() + return false diff --git a/tests/powerup_test.gd.uid b/tests/powerup_test.gd.uid new file mode 100644 index 0000000..4eb41fe --- /dev/null +++ b/tests/powerup_test.gd.uid @@ -0,0 +1 @@ +uid://cep52sqhqlynf diff --git a/tests/smoke_sim.gd b/tests/smoke_sim.gd new file mode 100644 index 0000000..8329ace --- /dev/null +++ b/tests/smoke_sim.gd @@ -0,0 +1,65 @@ +extends SceneTree +## 冒烟测试(时间驱动,与无头模式实际帧率无关): +## 主动模式:模拟移动+按住开火跑满 110s,覆盖波次→Boss 战→下一轮全链路; +## idle 模式:站桩 75s 被打死,覆盖掉命/游戏结束。 +## 运行:godot --headless -s tests/smoke_sim.gd [-- idle] + +const ACTIVE_DURATION := 110.0 +const IDLE_DURATION := 75.0 + +var _time := 0.0 +var _duration := ACTIVE_DURATION +var _next_print := 10.0 +var _next_bomb := 30.0 +var game: Game +var idle := false + + +func _initialize() -> void: + idle = OS.get_cmdline_user_args().has("idle") + if idle: + _duration = IDLE_DURATION + var scene := load("res://scenes/game.tscn") as PackedScene + game = scene.instantiate() + root.add_child(game) + if not idle: + Input.action_press("fire") + print("[smoke] game scene instantiated (idle=%s)" % str(idle)) + + +func _process(delta: float) -> bool: + # 游戏结束确认后会切回标题场景,模拟随之终止 + if not is_instance_valid(game): + print("[smoke] left game scene (back to title) — stopping simulation") + return true + _time += delta + if not idle: + # 左右往返扫射(1.5s 周期),模拟真实操作 + if fmod(_time, 1.5) < 0.75: + Input.action_release("move_left") + Input.action_press("move_right") + else: + Input.action_release("move_right") + Input.action_press("move_left") + + # 每 10 秒打印一次局面快照 + if _time >= _next_print: + _next_print += 10.0 + var boss_alive := is_instance_valid(game._boss) + print("[smoke] t=%ds state=%d score=%d lives=%d bombs=%d boss=%s bar=%s enemies=%d pbullets=%d ebullets=%d" % [ + int(_time), game.state, game.score, game.lives, game.player.bombs, + str(boss_alive), str(game._boss_bar_bg.visible and boss_alive), + (game.get_node("Enemies") as Node2D).get_child_count(), + (game.get_node("PlayerBullets") as Node2D).get_child_count(), + (game.get_node("EnemyBullets") as Node2D).get_child_count(), + ]) + + # 主动模式每 30 秒投一颗炸弹(无头无法可靠模拟 just_pressed 边沿,直调公开接口) + if not idle and _time >= _next_bomb: + _next_bomb += 30.0 + game.player.use_bomb() + + if _time >= _duration: + print("[smoke] finished, no crash") + return true + return false diff --git a/tests/smoke_sim.gd.uid b/tests/smoke_sim.gd.uid new file mode 100644 index 0000000..c2f2830 --- /dev/null +++ b/tests/smoke_sim.gd.uid @@ -0,0 +1 @@ +uid://bpb3766pd54l