feat: 场景与核心玩法脚本(双机型/波次/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:29 +08:00
parent 024447fbda
commit 3c6f31b503
36 changed files with 1692 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/game.gd" id="1_game"]
[node name="Game" type="Node2D"]
script = ExtResource("1_game")
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/title.gd" id="1_title"]
[node name="Title" type="Node2D"]
script = ExtResource("1_title")
+118
View File
@@ -0,0 +1,118 @@
class_name Boss
extends EnemyBase
## 关底 Boss:四发重型轰炸机。
## 进场悬停后左右巡逻;弹幕双模式交替(瞄准连发 / 扇形散布),
## 半血进入狂暴阶段(弹速、频率、弹幕密度提升)。
## 死亡时爆炸链 + 震屏 + 清除全场敌弹。
const TEX := preload("res://assets/svg/boss_plane.svg")
const CENTER_X := 270.0
const ENTER_Y := 150.0
const ENTER_SPEED := 100.0
const PATROL_AMP := 145.0
const MUZZLE := Vector2(0.0, 55.0)
var difficulty := 1.0
var max_hp := 120
var _t := 0.0
var _entered := false
var _pattern_index := 0
var _pattern_timer := 1.4
var _burst_left := 0
var _burst_tick := 0.0
func setup(p_difficulty: float) -> void:
difficulty = p_difficulty
hp = int(60.0 + 60.0 * difficulty)
max_hp = hp
score_value = 5000
drops_powerup = true
hit_radius = 62.0
position = Vector2(CENTER_X, -120.0)
func _ready() -> void:
collision_layer = 4
collision_mask = 0
var sprite := Sprite2D.new()
sprite.texture = TEX
add_child(sprite)
var shape := CollisionShape2D.new()
var rect := RectangleShape2D.new()
rect.size = Vector2(190.0, 84.0)
shape.shape = rect
add_child(shape)
func _process(delta: float) -> void:
if dead:
return
if not _entered:
position.y += ENTER_SPEED * delta
if position.y >= ENTER_Y:
position.y = ENTER_Y
_entered = true
return
_t += delta * (1.25 if _in_phase2() else 1.0)
position.x = CENTER_X + sin(_t * 0.55) * PATROL_AMP
position.y = ENTER_Y + sin(_t * 1.6) * 16.0
if _burst_left > 0:
_burst_tick -= delta
if _burst_tick <= 0.0:
_burst_tick = 0.14
_burst_left -= 1
_fire_bullet(_aim_direction() * _bullet_speed(), MUZZLE)
else:
_pattern_timer -= delta
if _pattern_timer <= 0.0:
_fire_pattern()
func _in_phase2() -> bool:
return hp <= max_hp / 2.0
func _bullet_speed() -> float:
return (235.0 + 40.0 * difficulty) * (1.12 if _in_phase2() else 1.0)
func _fire_pattern() -> void:
var phase2 := _in_phase2()
if _pattern_index % 2 == 0:
# 瞄准连发
_burst_left = 5 if phase2 else 3
_burst_tick = 0.0
_pattern_timer = 1.5 if phase2 else 2.0
else:
# 扇形散布
var count := 8 if phase2 else 5
var spread := 50.0 if phase2 else 36.0
var speed := _bullet_speed()
for i in count:
var angle := deg_to_rad(-spread + spread * 2.0 * float(i) / float(count - 1))
_fire_bullet(Vector2.DOWN.rotated(angle) * speed, MUZZLE)
_pattern_timer = 1.7 if phase2 else 2.2
_pattern_index += 1
func _die() -> void:
_spawn_drop(Powerup.Kind.POWER)
var game := get_tree().get_first_node_in_group("game") as Game
if game != null and is_instance_valid(game):
game.add_score(score_value)
game.add_bomb()
game.clear_enemy_bullets()
game.shake(9.0)
for i in 8:
var offset := Vector2(randf_range(-92.0, 92.0), randf_range(-56.0, 56.0))
if game != null and is_instance_valid(game):
game.spawn_explosion(global_position + offset, randf_range(1.3, 2.0))
await get_tree().create_timer(0.12).timeout
if game != null and is_instance_valid(game):
game.spawn_explosion(global_position, 3.0)
await get_tree().create_timer(0.3).timeout
queue_free()
+1
View File
@@ -0,0 +1 @@
uid://bu1qx3d8xsx2
+73
View File
@@ -0,0 +1,73 @@
class_name EnemyBase
extends Area2D
## 敌机基类:血量、受击闪白、死亡结算与爆炸。
var hp := 1
var score_value := 100
var dead := false
var drops_powerup := false
## 激光命中判定的近似半径(水平覆盖 + 垂直截断)
var hit_radius := 16.0
func apply_damage(dmg: int) -> void:
if dead:
return
hp -= dmg
if hp <= 0:
dead = true
_die()
else:
_flash()
func _die() -> void:
_spawn_drop()
var game := get_tree().get_first_node_in_group("game") as Game
if game != null:
game.add_score(score_value)
game.spawn_explosion(global_position, 1.0)
queue_free()
## 死亡时按标记掉落道具(可能在物理信号回调中调用,需延迟入树)。
## forced 为 -1 时随机 P/B(各 50%)。
func _spawn_drop(forced := -1) -> void:
if not drops_powerup:
return
var container := get_tree().get_first_node_in_group("powerups")
if container == null:
return
var pickup := Powerup.new()
pickup.position = global_position
if forced >= 0:
pickup.kind = forced
else:
pickup.kind = Powerup.Kind.BOMB if randf() < 0.5 else Powerup.Kind.POWER
container.call_deferred("add_child", pickup)
func _flash() -> void:
modulate = Color(3.0, 3.0, 3.0)
create_tween().tween_property(self, "modulate", Color.WHITE, 0.12)
## 朝玩家方向的单位向量;玩家不存在或不活跃时朝正下方
func _aim_direction() -> Vector2:
var game := get_tree().get_first_node_in_group("game") as Game
if game != null and is_instance_valid(game.player) and game.player.active:
var to_player: Vector2 = game.player.global_position - global_position
if to_player.length() > 1.0:
return to_player.normalized()
return Vector2.DOWN
## 向敌方弹幕容器发射一发子弹
func _fire_bullet(velocity: Vector2, muzzle_offset := Vector2(0.0, 14.0)) -> void:
var container := get_tree().get_first_node_in_group("enemy_bullets")
if container == null:
return
var bullet := EnemyBullet.new()
bullet.velocity = velocity
container.add_child(bullet)
bullet.global_position = global_position + muzzle_offset
+1
View File
@@ -0,0 +1 @@
uid://tj1k463onld4
+26
View File
@@ -0,0 +1,26 @@
class_name EnemyBullet
extends Area2D
## 敌方弹丸:直线飞行,出屏即回收。
const TEX := preload("res://assets/svg/bullet_enemy.svg")
var velocity := Vector2(0.0, 260.0)
func _ready() -> void:
collision_layer = 8
collision_mask = 0
var sprite := Sprite2D.new()
sprite.texture = TEX
add_child(sprite)
var shape := CollisionShape2D.new()
var circle := CircleShape2D.new()
circle.radius = 5.0
shape.shape = circle
add_child(shape)
func _process(delta: float) -> void:
position += velocity * delta
if position.y < -40.0 or position.y > 1000.0 or position.x < -40.0 or position.x > 580.0:
queue_free()
+1
View File
@@ -0,0 +1 @@
uid://dd3gtsnqjt3i3
+58
View File
@@ -0,0 +1,58 @@
class_name EnemyFortress
extends EnemyBase
## 地面固定堡垒:随地面同速下滚,炮塔持续追踪玩家并瞄准射击。
const TEX := preload("res://assets/svg/fortress.svg")
const TURRET_TEX := preload("res://assets/svg/fortress_turret.svg")
const SCROLL_SPEED := 120.0
const MUZZLE_LEN := 26.0
var difficulty := 1.0
var fire_interval := 2.6
var bullet_speed := 220.0
var _turret: Sprite2D
var _fire_timer := 0.0
func setup(p_x: float, p_y: float, p_difficulty := 1.0) -> void:
position = Vector2(p_x, p_y)
difficulty = p_difficulty
fire_interval = maxf(1.5, 2.6 / p_difficulty)
bullet_speed = 220.0 * p_difficulty
func _ready() -> void:
# 层 32:地面单位——玩家子弹可命中,但玩家机体不与其碰撞(飞越上空)
collision_layer = 32
collision_mask = 0
hp = 4
score_value = 300
hit_radius = 26.0
var base := Sprite2D.new()
base.texture = TEX
add_child(base)
_turret = Sprite2D.new()
_turret.texture = TURRET_TEX
add_child(_turret)
var shape := CollisionShape2D.new()
var circle := CircleShape2D.new()
circle.radius = 24.0
shape.shape = circle
add_child(shape)
_fire_timer = randf_range(0.8, 1.8)
func _process(delta: float) -> void:
position.y += SCROLL_SPEED * delta
if position.y > 1020.0:
queue_free()
return
var dir := _aim_direction()
# 炮塔美术默认朝下(+Y):rotate 对齐瞄准方向
_turret.rotation = dir.angle() - PI / 2.0
if position.y > 60.0 and position.y < 720.0:
_fire_timer -= delta
if _fire_timer <= 0.0:
_fire_timer = fire_interval
_fire_bullet(dir * bullet_speed, dir * MUZZLE_LEN)
+1
View File
@@ -0,0 +1 @@
uid://cbxqadj716vyc
+53
View File
@@ -0,0 +1,53 @@
class_name EnemyMedium
extends EnemyBase
## 中型轰炸机:从屏幕一侧横掠,正弦起伏,朝玩家方向瞄准射击。
const TEX := preload("res://assets/svg/enemy_medium.svg")
var side := 1 ## 1 = 从左向右,-1 = 从右向左
var base_y := 220.0
var vx := 110.0
var fire_interval := 1.6
var bullet_speed := 235.0
var _t := 0.0
var _fire_timer := 1.0
func setup(p_side: int, p_y: float, p_vx := 110.0, p_bullet_speed := 235.0, p_fire_interval := 1.6) -> void:
side = p_side
base_y = p_y
vx = p_vx
bullet_speed = p_bullet_speed
fire_interval = p_fire_interval
drops_powerup = true
position = Vector2(-70.0 if side == 1 else 610.0, base_y)
func _ready() -> void:
collision_layer = 4
collision_mask = 0
hp = 6
score_value = 400
hit_radius = 30.0
var sprite := Sprite2D.new()
sprite.texture = TEX
add_child(sprite)
var shape := CollisionShape2D.new()
var rect := RectangleShape2D.new()
rect.size = Vector2(54.0, 30.0)
shape.shape = rect
add_child(shape)
func _process(delta: float) -> void:
_t += delta
position.x += vx * float(side) * delta
position.y = base_y + sin(_t * 1.1) * 46.0
if (side == 1 and position.x > 610.0) or (side == -1 and position.x < -70.0):
queue_free()
return
_fire_timer -= delta
if _fire_timer <= 0.0:
_fire_timer = fire_interval
_fire_bullet(_aim_direction() * bullet_speed)
+1
View File
@@ -0,0 +1 @@
uid://dgt7piqw0kwrx
+51
View File
@@ -0,0 +1,51 @@
class_name EnemyShip
extends EnemyBase
## 驱逐舰:随海面航行(比地面稍快),舰桥瞄准玩家射击。
const TEX := preload("res://assets/svg/ship.svg")
const MUZZLE := Vector2(0.0, 0.0)
var difficulty := 1.0
var scroll_speed := 160.0
var fire_interval := 2.4
var bullet_speed := 230.0
var _fire_timer := 0.0
func setup(p_x: float, p_y: float, p_difficulty := 1.0) -> void:
position = Vector2(p_x, p_y)
difficulty = p_difficulty
scroll_speed = 150.0 + 20.0 * p_difficulty
fire_interval = maxf(1.6, 2.4 / p_difficulty)
bullet_speed = 230.0 * p_difficulty
func _ready() -> void:
# 层 32:海面单位——玩家子弹可命中,但玩家机体不与其碰撞(飞越上空)
collision_layer = 32
collision_mask = 0
hp = 8
score_value = 800
hit_radius = 46.0
var sprite := Sprite2D.new()
sprite.texture = TEX
add_child(sprite)
var shape := CollisionShape2D.new()
var rect := RectangleShape2D.new()
rect.size = Vector2(38.0, 112.0)
shape.shape = rect
add_child(shape)
_fire_timer = randf_range(1.0, 2.2)
func _process(delta: float) -> void:
position.y += scroll_speed * delta
if position.y > 1100.0:
queue_free()
return
if position.y > 60.0 and position.y < 760.0:
_fire_timer -= delta
if _fire_timer <= 0.0:
_fire_timer = fire_interval
_fire_bullet(_aim_direction() * bullet_speed, MUZZLE)
+1
View File
@@ -0,0 +1 @@
uid://h8rqsoqx57oo
+71
View File
@@ -0,0 +1,71 @@
class_name EnemySmall
extends EnemyBase
## 小型敌机:直线下压 + 可选横向摆动,部分会朝正下方开火。
const TEX := preload("res://assets/svg/enemy_small.svg")
var base_x := 270.0
var vy := 150.0
var sway_amp := 0.0
var sway_freq := 2.0
var phase := 0.0
var shoot := false
var bullet_speed := 250.0
var _t := 0.0
var _shoot_timer := 0.0
func setup(
p_base_x: float,
p_y: float,
p_vy: float,
p_sway_amp := 0.0,
p_sway_freq := 2.0,
p_phase := 0.0,
p_shoot := false,
p_bullet_speed := 250.0
) -> void:
base_x = p_base_x
position = Vector2(p_base_x, p_y)
vy = p_vy
sway_amp = p_sway_amp
sway_freq = p_sway_freq
phase = p_phase
shoot = p_shoot
bullet_speed = p_bullet_speed
func _ready() -> void:
collision_layer = 4
collision_mask = 0
hp = 1
score_value = 100
hit_radius = 14.0
var sprite := Sprite2D.new()
sprite.texture = TEX
# 掉落道具的敌机染成红色涂装(1942 惯例:红机掉宝)
if drops_powerup:
sprite.modulate = Color(1.0, 0.5, 0.4)
add_child(sprite)
var shape := CollisionShape2D.new()
var capsule := CapsuleShape2D.new()
capsule.radius = 12.0
capsule.height = 34.0
shape.shape = capsule
add_child(shape)
_shoot_timer = randf_range(1.2, 2.4)
func _process(delta: float) -> void:
_t += delta
position.y += vy * delta
position.x = base_x + sin(_t * sway_freq + phase) * sway_amp
if position.y > 1010.0 or position.x < -50.0 or position.x > 590.0:
queue_free()
return
if shoot and position.y > 90.0 and position.y < 640.0:
_shoot_timer -= delta
if _shoot_timer <= 0.0:
_shoot_timer = randf_range(1.8, 3.0)
_fire_bullet(Vector2(0.0, bullet_speed))
+1
View File
@@ -0,0 +1 @@
uid://b1eyvldxsrtcg
+33
View File
@@ -0,0 +1,33 @@
class_name Explosion
extends Node2D
## 爆炸特效:双火球缩放淡出后自动回收。
const TEX := preload("res://assets/svg/explosion.svg")
var size := 1.0
var _core: Sprite2D
var _ring: Sprite2D
func _ready() -> void:
_core = Sprite2D.new()
_core.texture = TEX
_core.scale = Vector2(0.2, 0.2) * size
add_child(_core)
_ring = Sprite2D.new()
_ring.texture = TEX
_ring.scale = Vector2(0.55, 0.55) * size
_ring.modulate = Color(1.0, 0.55, 0.25, 0.9)
add_child(_ring)
var tween := create_tween()
tween.set_parallel(true)
tween.tween_property(_core, "scale", Vector2(1.5, 1.5) * size, 0.3) \
.set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)
tween.tween_property(_core, "modulate:a", 0.0, 0.38)
tween.tween_property(_ring, "scale", Vector2(2.6, 2.6) * size, 0.42) \
.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
tween.tween_property(_ring, "modulate:a", 0.0, 0.42)
tween.chain().tween_callback(queue_free)
+1
View File
@@ -0,0 +1 @@
uid://dml5cxody73k0
+339
View File
@@ -0,0 +1,339 @@
class_name Game
extends Node2D
## 游戏总装:世界构建、HUD、生命与游戏结束流程。
## 场景树全部由代码构建,入口场景只挂本脚本。
enum State { PLAYING, GAME_OVER }
const VIEW_SIZE := Vector2(540.0, 960.0)
const START_LIVES := 3
const BOMB_ICON := preload("res://assets/svg/bomb_icon.svg")
const BOSS_BAR_WIDTH := 260.0
const BOMB_DAMAGE := 30
var score := 0
var lives := START_LIVES
var state := State.PLAYING
var player: Player
var _hud_score: Label
var _lives_box: Node2D
var _overlay: CanvasLayer
var _final_score_label: Label
var _over_since_msec := 0
var _effects: Node2D
var _enemy_bullets: Node2D
var _enemies: Node2D
var _boss: Boss
var _boss_bar_bg: ColorRect
var _boss_bar_fill: ColorRect
var _warning: Label
var _shake_amp := 0.0
var _bombs_box: Node2D
var _flash_rect: ColorRect
var _flash_tween: Tween
func _ready() -> void:
add_to_group("game")
randomize()
_build_world()
_build_hud()
_build_overlay()
_update_lives()
_update_score_label()
func _process(delta: float) -> void:
if state == State.GAME_OVER:
var accept := Input.is_action_just_pressed("ui_accept") or Input.is_action_just_pressed("fire")
if accept and Time.get_ticks_msec() - _over_since_msec > 800:
get_tree().change_scene_to_file("res://scenes/title.tscn")
return
_update_boss_bar()
_update_shake(delta)
func _update_boss_bar() -> void:
# 注意:已释放实例 == null 也为真,判存活必须用 is_instance_valid
if not is_instance_valid(_boss):
if _boss_bar_bg.visible:
_boss_bar_bg.visible = false
_boss_bar_fill.visible = false
return
var ratio := clampf(float(_boss.hp) / float(_boss.max_hp), 0.0, 1.0)
_boss_bar_fill.size.x = BOSS_BAR_WIDTH * ratio
func _update_shake(delta: float) -> void:
if _shake_amp > 0.2:
_shake_amp = lerpf(_shake_amp, 0.0, 8.0 * delta)
position = Vector2(randf_range(-1.0, 1.0), randf_range(-1.0, 1.0)) * _shake_amp
elif _shake_amp != 0.0:
_shake_amp = 0.0
position = Vector2.ZERO
func _build_world() -> void:
add_child(OceanScroll.new())
var player_bullets := Node2D.new()
player_bullets.name = "PlayerBullets"
player_bullets.add_to_group("player_bullets")
add_child(player_bullets)
_enemy_bullets = Node2D.new()
_enemy_bullets.name = "EnemyBullets"
_enemy_bullets.add_to_group("enemy_bullets")
add_child(_enemy_bullets)
_enemies = Node2D.new()
_enemies.name = "Enemies"
_enemies.add_to_group("enemies")
add_child(_enemies)
var powerups := Node2D.new()
powerups.name = "Powerups"
powerups.add_to_group("powerups")
add_child(powerups)
player = Player.new()
player.variant = GameSession.plane_variant as Player.Variant
player.position = VIEW_SIZE * Vector2(0.5, 0.82)
player.hit.connect(_on_player_hit)
add_child(player)
_effects = Node2D.new()
_effects.name = "Effects"
add_child(_effects)
add_child(WaveSpawner.new())
func _build_hud() -> void:
var layer := CanvasLayer.new()
layer.layer = 10
add_child(layer)
_hud_score = Label.new()
_hud_score.position = Vector2(16.0, 10.0)
_hud_score.add_theme_font_size_override("font_size", 26)
_hud_score.add_theme_color_override("font_color", Color.WHITE)
_hud_score.add_theme_color_override("font_shadow_color", Color(0.0, 0.0, 0.0, 0.6))
_hud_score.add_theme_constant_override("shadow_offset_x", 2)
_hud_score.add_theme_constant_override("shadow_offset_y", 2)
layer.add_child(_hud_score)
_lives_box = Node2D.new()
_lives_box.position = Vector2(520.0, 38.0)
layer.add_child(_lives_box)
# 炸弹计数(生命图标下方)
_bombs_box = Node2D.new()
_bombs_box.position = Vector2(520.0, 72.0)
layer.add_child(_bombs_box)
_update_bombs()
# 炸弹白闪层(最顶层)
var flash_layer := CanvasLayer.new()
flash_layer.layer = 30
add_child(flash_layer)
_flash_rect = ColorRect.new()
_flash_rect.color = Color.WHITE
_flash_rect.set_anchors_preset(Control.PRESET_FULL_RECT)
_flash_rect.modulate.a = 0.0
flash_layer.add_child(_flash_rect)
# Boss 血条(顶栏中央)
_boss_bar_bg = ColorRect.new()
_boss_bar_bg.position = Vector2(140.0, 54.0)
_boss_bar_bg.size = Vector2(BOSS_BAR_WIDTH + 4.0, 14.0)
_boss_bar_bg.color = Color(0.0, 0.0, 0.0, 0.65)
_boss_bar_bg.visible = false
layer.add_child(_boss_bar_bg)
_boss_bar_fill = ColorRect.new()
_boss_bar_fill.position = Vector2(142.0, 56.0)
_boss_bar_fill.size = Vector2(BOSS_BAR_WIDTH, 10.0)
_boss_bar_fill.color = Color(0.85, 0.2, 0.15)
_boss_bar_fill.visible = false
layer.add_child(_boss_bar_fill)
# Boss 登场警告横幅
_warning = Label.new()
_warning.text = "WARNING!!"
_warning.position = Vector2(0.0, 330.0)
_warning.size = Vector2(540.0, 70.0)
_warning.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_warning.add_theme_font_size_override("font_size", 56)
_warning.add_theme_color_override("font_color", Color(1.0, 0.25, 0.2))
_warning.add_theme_color_override("font_shadow_color", Color(0.0, 0.0, 0.0, 0.7))
_warning.add_theme_constant_override("shadow_offset_x", 3)
_warning.add_theme_constant_override("shadow_offset_y", 3)
_warning.visible = false
layer.add_child(_warning)
func _build_overlay() -> void:
_overlay = CanvasLayer.new()
_overlay.layer = 20
var dim := ColorRect.new()
dim.color = Color(0.0, 0.0, 0.0, 0.55)
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
_overlay.add_child(dim)
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
_overlay.add_child(center)
var vbox := VBoxContainer.new()
vbox.add_theme_constant_override("separation", 18)
vbox.alignment = BoxContainer.ALIGNMENT_CENTER
center.add_child(vbox)
var title := Label.new()
title.text = "GAME OVER"
title.add_theme_font_size_override("font_size", 52)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
vbox.add_child(title)
_final_score_label = Label.new()
_final_score_label.add_theme_font_size_override("font_size", 30)
_final_score_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
vbox.add_child(_final_score_label)
var hint := Label.new()
hint.text = "回车 / 空格 重新开始"
hint.add_theme_font_size_override("font_size", 22)
hint.modulate = Color(0.8, 0.8, 0.8)
hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
vbox.add_child(hint)
_overlay.visible = false
add_child(_overlay)
func add_score(value: int) -> void:
score += value
_update_score_label()
func spawn_explosion(pos: Vector2, explosion_scale: float = 1.0) -> void:
var explosion := Explosion.new()
explosion.size = explosion_scale
_effects.add_child(explosion)
explosion.global_position = pos
## 生成关底 Boss 并显示血条
func spawn_boss(p_difficulty: float) -> Boss:
var boss := Boss.new()
boss.setup(p_difficulty)
_enemies.add_child(boss)
_boss = boss
_boss_bar_bg.visible = true
_boss_bar_fill.visible = true
return boss
## Boss 登场前红色闪烁警告
func show_boss_warning() -> void:
_warning.visible = true
var tween := create_tween().set_loops(4)
tween.tween_property(_warning, "modulate:a", 0.1, 0.22)
tween.tween_property(_warning, "modulate:a", 1.0, 0.22)
tween.finished.connect(func() -> void: _warning.visible = false)
## 屏幕震动(强度衰减)
func shake(amp: float) -> void:
_shake_amp = maxf(_shake_amp, amp)
## 清除全场敌方弹幕
func clear_enemy_bullets() -> void:
for bullet in _enemy_bullets.get_children():
bullet.queue_free()
## 炸弹引爆:白闪 + 清弹 + 全场伤害 + 震屏(伤害数值由调用方计数)
func detonate_bomb() -> void:
_update_bombs()
if _flash_tween != null and _flash_tween.is_valid():
_flash_tween.kill()
_flash_rect.modulate.a = 0.85
_flash_tween = create_tween()
_flash_tween.tween_property(_flash_rect, "modulate:a", 0.0, 0.45) \
.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
clear_enemy_bullets()
for enemy in _enemies.get_children():
if enemy is EnemyBase:
(enemy as EnemyBase).apply_damage(BOMB_DAMAGE)
shake(7.0)
## 炸弹 +1(上限 Player.BOMBS_MAX)
func add_bomb() -> void:
if player != null and is_instance_valid(player):
player.bombs = mini(player.bombs + 1, Player.BOMBS_MAX)
_update_bombs()
## 拾取 B 道具:+1 炸弹并刷新 HUD
func notify_bomb_pickup() -> void:
_update_bombs()
func _update_bombs() -> void:
if _bombs_box == null:
return
for icon in _bombs_box.get_children():
icon.queue_free()
var count := player.bombs if player != null else 0
for i in count:
var icon := Sprite2D.new()
icon.texture = BOMB_ICON
icon.scale = Vector2.ONE * 0.8
icon.position = Vector2(-22.0 * i, 0.0)
_bombs_box.add_child(icon)
func _on_player_hit() -> void:
if state != State.PLAYING or not player.active:
return
lives -= 1
_update_lives()
spawn_explosion(player.global_position, 1.6)
clear_enemy_bullets()
player.die()
if lives <= 0:
state = State.GAME_OVER
_show_game_over()
else:
await get_tree().create_timer(0.9).timeout
if state == State.PLAYING:
player.respawn(VIEW_SIZE * Vector2(0.5, 0.82))
func _show_game_over() -> void:
_final_score_label.text = "得分 " + str(score).pad_zeros(6)
_overlay.visible = true
_over_since_msec = Time.get_ticks_msec()
func _update_score_label() -> void:
_hud_score.text = "SCORE " + str(score).pad_zeros(6)
func _update_lives() -> void:
for icon in _lives_box.get_children():
icon.queue_free()
for i in maxi(lives - 1, 0):
var icon := Sprite2D.new()
icon.texture = Player.TEX
icon.scale = Vector2.ONE * 0.42
icon.position = Vector2(-36.0 * i, 0.0)
_lives_box.add_child(icon)
+1
View File
@@ -0,0 +1 @@
uid://cer2qb8xqv72q
+7
View File
@@ -0,0 +1,7 @@
class_name GameSession
extends RefCounted
## 跨场景会话数据(静态变量,进程内共享):标题界面 → 游戏场景传递选择。
## 0 = Fighter(战斗机) 1 = Funnel(浮游炮型)
static var plane_variant := 0
+1
View File
@@ -0,0 +1 @@
uid://ciff1ryon7nvd
+78
View File
@@ -0,0 +1,78 @@
class_name LaserBeam
extends Node2D
## 直线激光束:从所在位置垂直向上延伸,被第一个命中的敌人截断,
## 对其造成持续伤害(dps)。视觉为核心线 + 外发光双层 Line2D。
var dps := 10.0
var width := 5.0
var color := Color(0.55, 0.9, 1.0)
var active := false
var _core: Line2D
var _glow: Line2D
var _acc := 0.0
var _t := 0.0
func _ready() -> void:
z_index = 5
_glow = Line2D.new()
_glow.width = width * 2.8
_glow.default_color = Color(color, 0.28)
_glow.points = [Vector2.ZERO, Vector2(0.0, -10.0)]
add_child(_glow)
_core = Line2D.new()
_core.width = width
_core.default_color = color
_core.points = [Vector2.ZERO, Vector2(0.0, -10.0)]
add_child(_core)
visible = false
## 从自身祖先中解析所属游戏的敌容器(避免多实例时全局组查找拿错容器)
func _get_enemies_container() -> Node2D:
var node := get_parent()
while node != null:
var candidate := node.get_node_or_null("Enemies")
if candidate != null:
return candidate as Node2D
node = node.get_parent()
return null
func _process(delta: float) -> void:
if not active:
if visible:
visible = false
return
visible = true
_t += delta
var muzzle := global_position
var hit_y := -40.0
var target: EnemyBase = null
var container := _get_enemies_container()
if container != null:
for enemy in container.get_children():
if enemy is EnemyBase and not (enemy as EnemyBase).dead:
var e := enemy as EnemyBase
if absf(e.global_position.x - muzzle.x) < e.hit_radius + width * 0.5:
# 光束向上:命中敌人底缘;若炮口已进入敌人范围则从炮口起算
var hy := minf(muzzle.y, e.global_position.y + e.hit_radius)
if hy >= hit_y:
hit_y = hy
target = e
var length := maxf(muzzle.y - hit_y, 0.0)
var pulse := 1.0 + 0.1 * sin(_t * 42.0)
_core.width = width * pulse
_glow.width = width * 2.8 * pulse
_core.points = [Vector2.ZERO, Vector2(0.0, -length)]
_glow.points = [Vector2.ZERO, Vector2(0.0, -length)]
if target != null and is_instance_valid(target):
_acc += dps * delta
if _acc >= 1.0:
var whole := int(_acc)
_acc -= float(whole)
target.apply_damage(whole)
+1
View File
@@ -0,0 +1 @@
uid://b8663segsokte
+59
View File
@@ -0,0 +1,59 @@
class_name OceanScroll
extends Node2D
## 滚动海面:三贴图循环 + 岛屿装饰随机飘过。
## 视口 540x960,贴图世界尺寸 540:3 块连续贴图在任意滚动相位下
## 都能覆盖 [0,960],2 块会在屏幕底部留缺口。
const TILE := preload("res://assets/svg/ocean_tile.svg")
const ISLAND := preload("res://assets/svg/island.svg")
const SPEED := 120.0
const TILE_WORLD := 540.0
const CENTER_X := 270.0
const TILE_COUNT := 3
var _offset := 0.0
var _tiles: Array[Sprite2D] = []
var _islands: Array[Sprite2D] = []
var _island_timer := 4.0
func _ready() -> void:
var tile_scale := TILE_WORLD / 256.0
for i in TILE_COUNT:
var tile := Sprite2D.new()
tile.texture = TILE
tile.scale = Vector2(tile_scale, tile_scale)
tile.position.x = CENTER_X
add_child(tile)
_tiles.append(tile)
func _process(delta: float) -> void:
_offset = fmod(_offset + SPEED * delta, TILE_WORLD)
for i in TILE_COUNT:
_tiles[i].position.y = _offset - TILE_WORLD * 0.5 + TILE_WORLD * float(i)
_update_islands(delta)
func _update_islands(delta: float) -> void:
_island_timer -= delta
if _island_timer <= 0.0:
_island_timer = randf_range(5.0, 9.0)
if _islands.size() < 3:
_spawn_island()
for island in _islands:
island.position.y += SPEED * delta
for island in _islands.duplicate():
if island.position.y > 1060.0:
_islands.erase(island)
island.queue_free()
func _spawn_island() -> void:
var island := Sprite2D.new()
island.texture = ISLAND
island.position = Vector2(randf_range(90.0, 450.0), -90.0)
island.scale = Vector2.ONE * randf_range(0.7, 1.15)
add_child(island)
_islands.append(island)
+1
View File
@@ -0,0 +1 @@
uid://bfw6u5ejmhxn1
+307
View File
@@ -0,0 +1,307 @@
class_name Player
extends Area2D
## 玩家战机:移动钳制、无敌闪烁与受击。
## Variant.FIGHTER:机枪弹幕(PlayerBullet)。
## Variant.FUNNEL:机体+浮游炮持续激光;按 focus(I) 浮游炮聚拢合体粗激光。
signal hit
enum Variant { FIGHTER, FUNNEL }
const SPEED := 340.0
const FIRE_INTERVAL := 0.13
const INVULN_ON_RESPAWN := 2.5
const WEAPON_LEVEL_MAX := 4
const BOMBS_MAX := 5
const BOMB_INVULN := 1.0
const TEX := preload("res://assets/art/P-38.png")
const TEX_FUNNEL := preload("res://assets/svg/plane_funnel.svg")
const FUNNEL_TEX := preload("res://assets/svg/funnel.svg")
const PLANE_LASER_DPS := 18.0
const FUNNEL_LASER_DPS := 7.0
const FOCUS_MULT := 1.5
const FOCUS_SPEED_MULT := 0.5
const PLANE_MUZZLE := Vector2(0.0, -30.0)
## 侧身帧切换:三帧贴图(平飞/左倾/右倾),机头始终垂直
const BANK_LERP := 12.0
const BANK_ENTER := 0.45
const BANK_EXIT := 0.25
## 展开阵列(左侧三槽,右侧镜像):错开的三角编队
const FUNNEL_SPREAD_LEFT: Array[Vector2] = [
Vector2(-36.0, -4.0),
Vector2(-62.0, -22.0),
Vector2(-40.0, -46.0),
]
const FUNNEL_FOCUS_X := 12.0
var variant: Variant = Variant.FIGHTER
var weapon_level := 1
var bombs := 2
var fire_timer := 0.0
var invuln := 0.0
var active := true
var sprite: Sprite2D
var _bank := 0.0
var _bank_state := 0
var _tex_neutral: Texture2D
var _tex_bank_left: Texture2D
var _tex_bank_right: Texture2D
var _funnels: Array[Node2D] = []
var _funnel_beams: Array[LaserBeam] = []
var _plane_beam: LaserBeam
var _focus_beam: LaserBeam
func _ready() -> void:
collision_layer = 1
collision_mask = 4 | 8 | 16
sprite = Sprite2D.new()
if variant == Variant.FUNNEL:
_tex_neutral = _load_optional_texture("res://assets/art/plane_funnel.png")
if _tex_neutral == null:
_tex_neutral = TEX_FUNNEL
_tex_bank_left = _load_optional_texture("res://assets/art/plane_funnel-bank-left.png")
_tex_bank_right = _load_optional_texture("res://assets/art/plane_funnel-bank-right.png")
else:
_tex_neutral = TEX
_tex_bank_left = _load_optional_texture("res://assets/art/P-38-bank-left.png")
_tex_bank_right = _load_optional_texture("res://assets/art/P-38-bank-right.png")
sprite.texture = _tex_neutral
add_child(sprite)
var shape := CollisionShape2D.new()
var capsule := CapsuleShape2D.new()
capsule.radius = 13.0
capsule.height = 44.0
shape.shape = capsule
add_child(shape)
area_entered.connect(_on_area_entered)
if variant == Variant.FUNNEL:
_plane_beam = _make_beam(PLANE_LASER_DPS, 5.0, Color(0.6, 0.85, 1.0), PLANE_MUZZLE)
_focus_beam = _make_beam(0.0, 16.0, Color(0.92, 0.98, 1.0), PLANE_MUZZLE)
_sync_funnels()
func _make_beam(p_dps: float, p_width: float, p_color: Color, offset: Vector2) -> LaserBeam:
var beam := LaserBeam.new()
beam.dps = p_dps
beam.width = p_width
beam.color = p_color
beam.position = offset
add_child(beam)
return beam
func _process(delta: float) -> void:
if not active:
return
var direction := Input.get_vector("move_left", "move_right", "move_up", "move_down")
# 聚焦合体期间:威力换机动,移动速度减半(仍可移动)
var speed := SPEED
if variant == Variant.FUNNEL and Input.is_action_pressed("fire") and Input.is_action_pressed("focus"):
speed *= FOCUS_SPEED_MULT
position += direction * speed * delta
# 侧身帧:带迟滞的三态切换(左倾/平飞/右倾),纯贴图切换、机头不歪
var dir_x := clampf(direction.x, -1.0, 1.0)
_bank = lerpf(_bank, dir_x, minf(BANK_LERP * delta, 1.0))
if _bank_state == 0:
if _bank > BANK_ENTER:
_bank_state = 1
elif _bank < -BANK_ENTER:
_bank_state = -1
elif absf(_bank) < BANK_EXIT:
_bank_state = 0
match _bank_state:
-1:
if _tex_bank_left != null:
sprite.texture = _tex_bank_left
1:
if _tex_bank_right != null:
sprite.texture = _tex_bank_right
_:
sprite.texture = _tex_neutral
position.x = clampf(position.x, 22.0, 518.0)
position.y = clampf(position.y, 90.0, 930.0)
if variant == Variant.FIGHTER:
fire_timer -= delta
if Input.is_action_pressed("fire") and fire_timer <= 0.0:
fire_timer = FIRE_INTERVAL
_fire()
else:
_update_funnel_weapons(delta)
if Input.is_action_just_pressed("bomb") and active and bombs > 0:
use_bomb()
if invuln > 0.0:
invuln -= delta
sprite.modulate.a = 1.0 if fmod(invuln * 8.0, 2.0) < 1.0 else 0.3
else:
sprite.modulate.a = 1.0
## 浮游炮机体武器:常态机体激光 + 各浮游炮激光;按 I 聚焦合体粗激光。
## 合体总威力 = 散开总威力 × FOCUS_MULT。
func _update_funnel_weapons(delta: float) -> void:
var firing := Input.is_action_pressed("fire")
var focus := firing and Input.is_action_pressed("focus")
_plane_beam.active = firing and not focus
_focus_beam.active = focus
if focus:
_focus_beam.dps = (PLANE_LASER_DPS + float(_funnels.size()) * FUNNEL_LASER_DPS) * FOCUS_MULT
for beam in _funnel_beams:
beam.active = firing and not focus
for i in _funnels.size():
var left := i % 2 == 0
var rank := i / 2
var target: Vector2
if focus:
target = Vector2(-FUNNEL_FOCUS_X if left else FUNNEL_FOCUS_X, -28.0 - 20.0 * float(rank))
else:
var slot: Vector2 = FUNNEL_SPREAD_LEFT[rank]
target = slot if left else Vector2(-slot.x, slot.y)
var funnel: Node2D = _funnels[i]
funnel.position = funnel.position.lerp(target, minf(10.0 * delta, 1.0))
## 按当前火力等级同步浮游炮数量(每侧 level-1 台)
func _sync_funnels() -> void:
if variant != Variant.FUNNEL:
return
var wanted := (weapon_level - 1) * 2
while _funnels.size() > wanted:
var removed: Node2D = _funnels.pop_back()
_funnel_beams.pop_back()
removed.queue_free()
while _funnels.size() < wanted:
var funnel := Node2D.new()
var icon := Sprite2D.new()
icon.texture = FUNNEL_TEX
funnel.add_child(icon)
var beam := LaserBeam.new()
beam.dps = FUNNEL_LASER_DPS
beam.width = 4.0
beam.color = Color(0.45, 0.9, 1.0)
beam.position = Vector2(0.0, -8.0)
funnel.add_child(beam)
add_child(funnel)
funnel.position = Vector2(0.0, 20.0)
_funnels.append(funnel)
_funnel_beams.append(beam)
func _fire() -> void:
var container := get_tree().get_first_node_in_group("player_bullets")
if container == null:
return
match weapon_level:
1:
_spawn_volley(container, [-11.0, 11.0], Vector2.UP)
2:
_spawn_volley(container, [-17.0, -6.0, 6.0, 17.0], Vector2.UP)
3:
_spawn_volley(container, [-17.0, -6.0, 6.0, 17.0], Vector2.UP)
_spawn_bullet(container, Vector2(-22.0, -4.0), Vector2.UP.rotated(deg_to_rad(-12.0)))
_spawn_bullet(container, Vector2(22.0, -4.0), Vector2.UP.rotated(deg_to_rad(12.0)))
_:
# L4:6 直射 + 双道 22° 斜射 + 30° 斜射
_spawn_volley(container, [-25.0, -15.0, -5.0, 5.0, 15.0, 25.0], Vector2.UP)
_spawn_bullet(container, Vector2(-32.0, -4.0), Vector2.UP.rotated(deg_to_rad(-22.0)))
_spawn_bullet(container, Vector2(-32.0, 6.0), Vector2.UP.rotated(deg_to_rad(-22.0)))
_spawn_bullet(container, Vector2(32.0, -4.0), Vector2.UP.rotated(deg_to_rad(22.0)))
_spawn_bullet(container, Vector2(32.0, 6.0), Vector2.UP.rotated(deg_to_rad(22.0)))
_spawn_bullet(container, Vector2(-20.0, 10.0), Vector2.UP.rotated(deg_to_rad(-30.0)))
_spawn_bullet(container, Vector2(20.0, 10.0), Vector2.UP.rotated(deg_to_rad(30.0)))
func _spawn_volley(container: Node, x_offsets: Array, dir: Vector2) -> void:
for x in x_offsets:
_spawn_bullet(container, Vector2(x, -8.0), dir)
func _spawn_bullet(container: Node, offset: Vector2, dir: Vector2) -> void:
var bullet := PlayerBullet.new()
bullet.direction = dir
container.add_child(bullet)
bullet.global_position = global_position + offset
func _collect_powerup(pickup: Powerup) -> void:
pickup.queue_free()
match pickup.kind:
Powerup.Kind.BOMB:
var game := get_tree().get_first_node_in_group("game") as Game
bombs = mini(bombs + 1, BOMBS_MAX)
if game != null:
game.notify_bomb_pickup()
_:
if weapon_level < WEAPON_LEVEL_MAX:
weapon_level += 1
_sync_funnels()
else:
var game := get_tree().get_first_node_in_group("game") as Game
if game != null:
game.add_score(500)
## 投放炸弹:清屏伤害 + 清除敌弹 + 白闪光效 + 短暂无敌
func use_bomb() -> void:
if not active or bombs <= 0:
return
bombs -= 1
invuln = maxf(invuln, BOMB_INVULN)
var game := get_tree().get_first_node_in_group("game") as Game
if game != null:
game.detonate_bomb()
func _on_area_entered(area: Area2D) -> void:
if area is Powerup:
_collect_powerup(area)
return
if not active or invuln > 0.0:
return
if area is EnemyBullet:
area.queue_free()
emit_signal("hit")
elif area is EnemyBase:
emit_signal("hit")
## 被击中后的消隐(禁用碰撞需延迟,避免在物理回调中直接修改);火力降一级
func die() -> void:
active = false
weapon_level = maxi(1, weapon_level - 1)
_sync_funnels()
if _plane_beam != null:
_plane_beam.active = false
if _focus_beam != null:
_focus_beam.active = false
for beam in _funnel_beams:
beam.active = false
visible = false
set_deferred("monitoring", false)
## 复活并给予无敌时间
func respawn(pos: Vector2) -> void:
position = pos
active = true
visible = true
invuln = INVULN_ON_RESPAWN
fire_timer = 0.0
_bank = 0.0
_bank_state = 0
sprite.texture = _tex_neutral
set_deferred("monitoring", true)
## 可选素材:文件存在才加载(侧身帧未生成时回退平飞帧)
func _load_optional_texture(path: String) -> Texture2D:
if not ResourceLoader.exists(path):
return null
return load(path) as Texture2D
+1
View File
@@ -0,0 +1 @@
uid://mox72d37bu25
+37
View File
@@ -0,0 +1,37 @@
class_name PlayerBullet
extends Area2D
## 玩家子弹:沿自身方向直线飞行(斜射弹共享此类),命中敌机造成伤害。
const SPEED := 760.0
const TEX := preload("res://assets/svg/bullet_player.svg")
var direction := Vector2.UP
func _ready() -> void:
collision_layer = 2
# 4 空中敌机 + 32 地面/海面单位(只被打,不与玩家机体碰撞)
collision_mask = 4 | 32
var sprite := Sprite2D.new()
sprite.texture = TEX
add_child(sprite)
var shape := CollisionShape2D.new()
var rect := RectangleShape2D.new()
rect.size = Vector2(6.0, 16.0)
shape.shape = rect
add_child(shape)
# 旋转节点本身:贴图与碰撞矩形一起对齐弹道方向
rotation = Vector2.UP.angle_to(direction)
area_entered.connect(_on_area_entered)
func _process(delta: float) -> void:
position += direction * SPEED * delta
if global_position.y < -30.0 or global_position.x < -30.0 or global_position.x > 570.0:
queue_free()
func _on_area_entered(area: Area2D) -> void:
if area is EnemyBase:
(area as EnemyBase).apply_damage(1)
queue_free()
+1
View File
@@ -0,0 +1 @@
uid://cun0cmlwov27j
+40
View File
@@ -0,0 +1,40 @@
class_name Powerup
extends Area2D
## 掉落道具:缓慢下落 + 左右摇摆 + 缓慢自转,玩家接触即收集。
## kind: Kind.POWER 火力升级 / Kind.BOMB 炸弹+1
enum Kind { POWER, BOMB }
const FALL_SPEED := 105.0
const SWAY_AMP := 26.0
const TEX_POWER := preload("res://assets/svg/powerup.svg")
const TEX_BOMB := preload("res://assets/svg/bomb_powerup.svg")
var kind := Kind.POWER
var _t := 0.0
var _base_x := 0.0
var _sprite: Sprite2D
func _ready() -> void:
collision_layer = 16
collision_mask = 0
_base_x = position.x
_sprite = Sprite2D.new()
_sprite.texture = TEX_BOMB if kind == Kind.BOMB else TEX_POWER
add_child(_sprite)
var shape := CollisionShape2D.new()
var circle := CircleShape2D.new()
circle.radius = 15.0
shape.shape = circle
add_child(shape)
func _process(delta: float) -> void:
_t += delta
position.y += FALL_SPEED * delta
position.x = _base_x + sin(_t * 1.9) * SWAY_AMP
_sprite.rotation += delta * 2.4
if position.y > 1010.0:
queue_free()
+1
View File
@@ -0,0 +1 @@
uid://bu4bn1v74e4xc
+150
View File
@@ -0,0 +1,150 @@
class_name Title
extends Node2D
## 启动界面:滚动海面打底,左右选择战机,回车/J 出击。
const PLANE_A_TEX := Player.TEX
const PLANE_B_TEX := preload("res://assets/svg/plane_funnel.svg") # 兜底:美术 PNG 存在时优先
const FUNNEL_TEX := preload("res://assets/svg/funnel.svg")
const PLANE_B_ART := "res://assets/art/plane_funnel.png"
static func _plane_b_texture() -> Texture2D:
if ResourceLoader.exists(PLANE_B_ART):
return load(PLANE_B_ART)
return PLANE_B_TEX
var _sel := 0
var _started := false
var _borders: Array[ColorRect] = []
func _ready() -> void:
add_child(OceanScroll.new())
var dim := ColorRect.new()
dim.color = Color(0.02, 0.05, 0.1, 0.62)
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(dim)
var layer := CanvasLayer.new()
layer.layer = 10
add_child(layer)
var title := Label.new()
title.text = "AERO STRIKE"
title.position = Vector2(0.0, 130.0)
title.size = Vector2(540.0, 80.0)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
title.add_theme_font_size_override("font_size", 64)
title.add_theme_color_override("font_color", Color(1.0, 0.85, 0.35))
title.add_theme_color_override("font_shadow_color", Color(0.0, 0.0, 0.0, 0.8))
title.add_theme_constant_override("shadow_offset_x", 3)
title.add_theme_constant_override("shadow_offset_y", 3)
layer.add_child(title)
var subtitle := Label.new()
subtitle.text = "1 9 4 2"
subtitle.position = Vector2(0.0, 205.0)
subtitle.size = Vector2(540.0, 40.0)
subtitle.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
subtitle.add_theme_font_size_override("font_size", 28)
subtitle.add_theme_color_override("font_color", Color(0.85, 0.9, 1.0))
layer.add_child(subtitle)
var choose := Label.new()
choose.text = "— 选择战机 —"
choose.position = Vector2(0.0, 300.0)
choose.size = Vector2(540.0, 34.0)
choose.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
choose.add_theme_font_size_override("font_size", 24)
choose.add_theme_color_override("font_color", Color.WHITE)
layer.add_child(choose)
_build_card(layer, 0, 40.0, PLANE_A_TEX, "P-38 战斗机", "宽域机枪弹幕", "P 升级火力 · K 炸弹")
_build_card(layer, 1, 280.0, _plane_b_texture(), "雷光 浮游炮型", "贯穿激光 + 浮游炮", "I 聚焦合体激光 · K 炸弹", true)
var hint := Label.new()
hint.text = "←→ 选择 回车 / J 出击"
hint.position = Vector2(0.0, 880.0)
hint.size = Vector2(540.0, 34.0)
hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
hint.add_theme_font_size_override("font_size", 22)
hint.add_theme_color_override("font_color", Color(0.75, 0.8, 0.85))
layer.add_child(hint)
_update_selection()
func _build_card(parent: Node, index: int, x: float, tex: Texture2D, plane_name: String, line1: String, line2: String, with_funnels := false) -> void:
var bg := ColorRect.new()
bg.position = Vector2(x, 350.0)
bg.size = Vector2(220.0, 330.0)
bg.color = Color(0.08, 0.12, 0.18, 0.92)
parent.add_child(bg)
var border := ColorRect.new()
border.position = bg.position - Vector2(4.0, 4.0)
border.size = bg.size + Vector2(8.0, 8.0)
border.color = Color(1.0, 0.82, 0.25)
parent.add_child(border)
_borders.append(border)
var icon := Sprite2D.new()
icon.texture = tex
icon.scale = Vector2.ONE * 1.7
icon.position = Vector2(x + 110.0, 455.0)
parent.add_child(icon)
if with_funnels:
for i in 2:
var funnel := Sprite2D.new()
funnel.texture = FUNNEL_TEX
funnel.position = Vector2(x + 110.0 + (-60.0 if i == 0 else 60.0), 425.0 + 30.0 * i)
parent.add_child(funnel)
var name_label := Label.new()
name_label.text = plane_name
name_label.position = Vector2(x, 545.0)
name_label.size = Vector2(220.0, 34.0)
name_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
name_label.add_theme_font_size_override("font_size", 24)
name_label.add_theme_color_override("font_color", Color.WHITE)
parent.add_child(name_label)
var desc1 := Label.new()
desc1.text = line1
desc1.position = Vector2(x, 600.0)
desc1.size = Vector2(220.0, 28.0)
desc1.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
desc1.add_theme_font_size_override("font_size", 19)
desc1.add_theme_color_override("font_color", Color(0.8, 0.88, 1.0))
parent.add_child(desc1)
var desc2 := Label.new()
desc2.text = line2
desc2.position = Vector2(x, 640.0)
desc2.size = Vector2(220.0, 26.0)
desc2.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
desc2.add_theme_font_size_override("font_size", 17)
desc2.add_theme_color_override("font_color", Color(0.7, 0.78, 0.88))
parent.add_child(desc2)
func _process(_delta: float) -> void:
if _started:
return
if Input.is_action_just_pressed("move_left") and _sel != 0:
_sel = 0
_update_selection()
elif Input.is_action_just_pressed("move_right") and _sel != 1:
_sel = 1
_update_selection()
if Input.is_action_just_pressed("ui_accept") or Input.is_action_just_pressed("fire"):
_started = true
GameSession.plane_variant = _sel
get_tree().change_scene_to_file("res://scenes/game.tscn")
func _update_selection() -> void:
for i in _borders.size():
_borders[i].visible = i == _sel
+1
View File
@@ -0,0 +1 @@
uid://bteck1h6rqrlb
+163
View File
@@ -0,0 +1,163 @@
class_name WaveSpawner
extends Node
## 波次生成器:时间线循环刷敌,每轮提升难度。
var difficulty := 1.0
var game: Game
func _ready() -> void:
call_deferred("_run")
func _run() -> void:
game = get_tree().get_first_node_in_group("game") as Game
await get_tree().create_timer(0.8).timeout
var round_num := 1
while true:
difficulty = minf(1.0 + (round_num - 1) * 0.12, 1.8)
await _play_round()
await _wait_playing()
# 关底 Boss 战
if game != null and is_instance_valid(game):
game.show_boss_warning()
await _timer(2.0)
await _wait_playing()
var boss: Boss = null
if game != null and is_instance_valid(game):
boss = game.spawn_boss(difficulty)
while boss != null and is_instance_valid(boss):
await _timer(0.3)
await _wait_playing()
await _timer(2.5)
round_num += 1
await get_tree().create_timer(2.5).timeout
func _play_round() -> void:
await _wait_playing()
await _column(140.0)
await _timer(3.2)
await _wait_playing()
_v_formation(270.0)
await _timer(3.8)
await _wait_playing()
_sine_squad(170.0)
_sine_squad(370.0)
await _timer(3.8)
await _wait_playing()
_fortress(120.0)
_fortress(420.0)
await _timer(2.6)
await _wait_playing()
await _column(400.0)
await _timer(0.8)
await _column(120.0)
await _timer(3.0)
await _wait_playing()
_medium(1, 210.0)
await _timer(4.0)
await _wait_playing()
_ship(190.0)
await _timer(4.0)
await _wait_playing()
_v_formation(160.0)
await _timer(1.4)
_v_formation(380.0)
await _timer(3.6)
await _wait_playing()
_sine_squad(270.0)
_fortress(270.0)
await _timer(3.4)
await _wait_playing()
_medium(-1, 260.0)
await _column(270.0)
_ship(380.0)
await _timer(4.2)
## 游戏结束时挂起等待,避免继续刷敌
func _wait_playing() -> void:
while game == null or game.state != Game.State.PLAYING:
await get_tree().create_timer(0.5).timeout
func _timer(duration: float) -> void:
await get_tree().create_timer(duration).timeout
## 纵队:同一直线依次下压,头机掉宝
func _column(x: float) -> void:
for i in 5:
_spawn_small(x, -30.0 - 46.0, 190.0 * difficulty, 0.0, 2.0, 0.0, true, 250.0 * difficulty, i == 0)
await _timer(0.32)
## V 字编队:5 机整体压境,仅领队开火并掉宝
func _v_formation(center_x: float) -> void:
var offsets := [Vector2(0.0, -30.0), Vector2(-44.0, -66.0), Vector2(44.0, -66.0), Vector2(-88.0, -102.0), Vector2(88.0, -102.0)]
for i in offsets.size():
var offset: Vector2 = offsets[i]
var is_leader := i == 0
_spawn_small(center_x + offset.x, offset.y, 150.0 * difficulty, 16.0, 1.6, 0.0, is_leader, 240.0 * difficulty, is_leader)
## 摆动机队:同路径依次进场、正弦横摆,头机掉宝
func _sine_squad(x: float) -> void:
for i in 4:
_spawn_small(x, -30.0, 130.0 * difficulty, 72.0, 1.8, 0.0, randf() < 0.5, 230.0 * difficulty, i == 0)
await _timer(0.4)
## 中型轰炸机横掠
func _medium(p_side: int, y: float) -> void:
var enemy := EnemyMedium.new()
enemy.setup(p_side, y, 112.0 * difficulty, 235.0 * difficulty, maxf(0.9, 1.6 / difficulty))
_add_enemy(enemy)
## 地面固定堡垒
func _fortress(x: float) -> void:
var enemy := EnemyFortress.new()
enemy.setup(x, -60.0, difficulty)
_add_enemy(enemy)
## 驱逐舰
func _ship(x: float) -> void:
var enemy := EnemyShip.new()
enemy.setup(x, -140.0, difficulty)
_add_enemy(enemy)
func _spawn_small(
x: float,
y: float,
vy: float,
sway_amp: float,
sway_freq: float,
phase: float,
shoot: bool,
bullet_speed: float,
drop := false
) -> void:
var enemy := EnemySmall.new()
enemy.setup(x, y, vy, sway_amp, sway_freq, phase, shoot, bullet_speed)
enemy.drops_powerup = drop
_add_enemy(enemy)
func _add_enemy(enemy: EnemyBase) -> void:
var container := get_tree().get_first_node_in_group("enemies")
if container == null:
return
container.add_child(enemy)
+1
View File
@@ -0,0 +1 @@
uid://fwblkorrxm2i