test: 八项无头回归测试(道具/弹道/Boss/海面/地面单位/浮游炮/炸弹/冒烟模拟)

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-08-31 00:31:48 +08:00
parent 3c6f31b503
commit 5f6258e3d4
16 changed files with 1113 additions and 0 deletions
+154
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://5avhpka6nv87
+82
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://cd2jn0sw1u738
+101
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://dv8mr86bn8gmx
+297
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://b5qylgkck5nlg
+166
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://cfut4inswm23u
+46
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://bp3enf7navowk
+194
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://cep52sqhqlynf
+65
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://bpb3766pd54l