diff --git a/scenes/game.tscn b/scenes/game.tscn new file mode 100644 index 0000000..97f2058 --- /dev/null +++ b/scenes/game.tscn @@ -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") diff --git a/scenes/title.tscn b/scenes/title.tscn new file mode 100644 index 0000000..168b2bc --- /dev/null +++ b/scenes/title.tscn @@ -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") diff --git a/scripts/boss.gd b/scripts/boss.gd new file mode 100644 index 0000000..90ab75b --- /dev/null +++ b/scripts/boss.gd @@ -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() diff --git a/scripts/boss.gd.uid b/scripts/boss.gd.uid new file mode 100644 index 0000000..bf99449 --- /dev/null +++ b/scripts/boss.gd.uid @@ -0,0 +1 @@ +uid://bu1qx3d8xsx2 diff --git a/scripts/enemy_base.gd b/scripts/enemy_base.gd new file mode 100644 index 0000000..d60040b --- /dev/null +++ b/scripts/enemy_base.gd @@ -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 diff --git a/scripts/enemy_base.gd.uid b/scripts/enemy_base.gd.uid new file mode 100644 index 0000000..cec2aaa --- /dev/null +++ b/scripts/enemy_base.gd.uid @@ -0,0 +1 @@ +uid://tj1k463onld4 diff --git a/scripts/enemy_bullet.gd b/scripts/enemy_bullet.gd new file mode 100644 index 0000000..e613742 --- /dev/null +++ b/scripts/enemy_bullet.gd @@ -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() diff --git a/scripts/enemy_bullet.gd.uid b/scripts/enemy_bullet.gd.uid new file mode 100644 index 0000000..836840e --- /dev/null +++ b/scripts/enemy_bullet.gd.uid @@ -0,0 +1 @@ +uid://dd3gtsnqjt3i3 diff --git a/scripts/enemy_fortress.gd b/scripts/enemy_fortress.gd new file mode 100644 index 0000000..d078b8f --- /dev/null +++ b/scripts/enemy_fortress.gd @@ -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) diff --git a/scripts/enemy_fortress.gd.uid b/scripts/enemy_fortress.gd.uid new file mode 100644 index 0000000..f400df8 --- /dev/null +++ b/scripts/enemy_fortress.gd.uid @@ -0,0 +1 @@ +uid://cbxqadj716vyc diff --git a/scripts/enemy_medium.gd b/scripts/enemy_medium.gd new file mode 100644 index 0000000..6b8e957 --- /dev/null +++ b/scripts/enemy_medium.gd @@ -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) diff --git a/scripts/enemy_medium.gd.uid b/scripts/enemy_medium.gd.uid new file mode 100644 index 0000000..85fd36f --- /dev/null +++ b/scripts/enemy_medium.gd.uid @@ -0,0 +1 @@ +uid://dgt7piqw0kwrx diff --git a/scripts/enemy_ship.gd b/scripts/enemy_ship.gd new file mode 100644 index 0000000..b2df4cb --- /dev/null +++ b/scripts/enemy_ship.gd @@ -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) diff --git a/scripts/enemy_ship.gd.uid b/scripts/enemy_ship.gd.uid new file mode 100644 index 0000000..379441f --- /dev/null +++ b/scripts/enemy_ship.gd.uid @@ -0,0 +1 @@ +uid://h8rqsoqx57oo diff --git a/scripts/enemy_small.gd b/scripts/enemy_small.gd new file mode 100644 index 0000000..68be108 --- /dev/null +++ b/scripts/enemy_small.gd @@ -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)) diff --git a/scripts/enemy_small.gd.uid b/scripts/enemy_small.gd.uid new file mode 100644 index 0000000..eb620e4 --- /dev/null +++ b/scripts/enemy_small.gd.uid @@ -0,0 +1 @@ +uid://b1eyvldxsrtcg diff --git a/scripts/explosion.gd b/scripts/explosion.gd new file mode 100644 index 0000000..4cef45b --- /dev/null +++ b/scripts/explosion.gd @@ -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) diff --git a/scripts/explosion.gd.uid b/scripts/explosion.gd.uid new file mode 100644 index 0000000..ae3a338 --- /dev/null +++ b/scripts/explosion.gd.uid @@ -0,0 +1 @@ +uid://dml5cxody73k0 diff --git a/scripts/game.gd b/scripts/game.gd new file mode 100644 index 0000000..99ec698 --- /dev/null +++ b/scripts/game.gd @@ -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) diff --git a/scripts/game.gd.uid b/scripts/game.gd.uid new file mode 100644 index 0000000..cf1c314 --- /dev/null +++ b/scripts/game.gd.uid @@ -0,0 +1 @@ +uid://cer2qb8xqv72q diff --git a/scripts/game_session.gd b/scripts/game_session.gd new file mode 100644 index 0000000..037fa17 --- /dev/null +++ b/scripts/game_session.gd @@ -0,0 +1,7 @@ +class_name GameSession +extends RefCounted +## 跨场景会话数据(静态变量,进程内共享):标题界面 → 游戏场景传递选择。 +## 0 = Fighter(战斗机) 1 = Funnel(浮游炮型) + + +static var plane_variant := 0 diff --git a/scripts/game_session.gd.uid b/scripts/game_session.gd.uid new file mode 100644 index 0000000..d2e9d4e --- /dev/null +++ b/scripts/game_session.gd.uid @@ -0,0 +1 @@ +uid://ciff1ryon7nvd diff --git a/scripts/laser_beam.gd b/scripts/laser_beam.gd new file mode 100644 index 0000000..91ac2fb --- /dev/null +++ b/scripts/laser_beam.gd @@ -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) diff --git a/scripts/laser_beam.gd.uid b/scripts/laser_beam.gd.uid new file mode 100644 index 0000000..e63a0f2 --- /dev/null +++ b/scripts/laser_beam.gd.uid @@ -0,0 +1 @@ +uid://b8663segsokte diff --git a/scripts/ocean.gd b/scripts/ocean.gd new file mode 100644 index 0000000..5fd2226 --- /dev/null +++ b/scripts/ocean.gd @@ -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) diff --git a/scripts/ocean.gd.uid b/scripts/ocean.gd.uid new file mode 100644 index 0000000..4240555 --- /dev/null +++ b/scripts/ocean.gd.uid @@ -0,0 +1 @@ +uid://bfw6u5ejmhxn1 diff --git a/scripts/player.gd b/scripts/player.gd new file mode 100644 index 0000000..b41e42f --- /dev/null +++ b/scripts/player.gd @@ -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 diff --git a/scripts/player.gd.uid b/scripts/player.gd.uid new file mode 100644 index 0000000..5114224 --- /dev/null +++ b/scripts/player.gd.uid @@ -0,0 +1 @@ +uid://mox72d37bu25 diff --git a/scripts/player_bullet.gd b/scripts/player_bullet.gd new file mode 100644 index 0000000..278c08a --- /dev/null +++ b/scripts/player_bullet.gd @@ -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() diff --git a/scripts/player_bullet.gd.uid b/scripts/player_bullet.gd.uid new file mode 100644 index 0000000..fb821cf --- /dev/null +++ b/scripts/player_bullet.gd.uid @@ -0,0 +1 @@ +uid://cun0cmlwov27j diff --git a/scripts/powerup.gd b/scripts/powerup.gd new file mode 100644 index 0000000..90b1779 --- /dev/null +++ b/scripts/powerup.gd @@ -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() diff --git a/scripts/powerup.gd.uid b/scripts/powerup.gd.uid new file mode 100644 index 0000000..29b49ea --- /dev/null +++ b/scripts/powerup.gd.uid @@ -0,0 +1 @@ +uid://bu4bn1v74e4xc diff --git a/scripts/title.gd b/scripts/title.gd new file mode 100644 index 0000000..0aa87dc --- /dev/null +++ b/scripts/title.gd @@ -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 diff --git a/scripts/title.gd.uid b/scripts/title.gd.uid new file mode 100644 index 0000000..fb7b8c1 --- /dev/null +++ b/scripts/title.gd.uid @@ -0,0 +1 @@ +uid://bteck1h6rqrlb diff --git a/scripts/wave_spawner.gd b/scripts/wave_spawner.gd new file mode 100644 index 0000000..99e307c --- /dev/null +++ b/scripts/wave_spawner.gd @@ -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) diff --git a/scripts/wave_spawner.gd.uid b/scripts/wave_spawner.gd.uid new file mode 100644 index 0000000..22180ed --- /dev/null +++ b/scripts/wave_spawner.gd.uid @@ -0,0 +1 @@ +uid://fwblkorrxm2i