Initial: playable 3-level Breakout with menus, audio, and power-ups

This commit is contained in:
2026-08-12 17:27:11 +08:00
commit 319e75447a
45 changed files with 1594 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
extends Node
## 简易音效:用 AudioStreamWAV 程序合成 4 种音效。
## 每次播放 new 一个 AudioStreamPlayer,结束后自动释放。
## 之后要替换成 .wav 素材,把 _generate_xxx() 换成 load() 资源即可。
const SAMPLE_RATE: int = 22050
func play_hit() -> void:
_play(_make_sin(880.0, 0.05, 0.4))
func play_break() -> void:
_play(_make_noise(0.18, 0.5))
func play_lost() -> void:
_play(_make_sweep(440.0, 110.0, 0.4, 0.5))
func play_win() -> void:
_play(_make_sweep(523.0, 1046.0, 0.5, 0.5))
# ───────── 合成函数 ─────────
func _make_sin(freq: float, duration: float, volume: float) -> AudioStreamWAV:
return _build(duration, func(t: float, d: float) -> float:
var env: float = 1.0 - t / d # 线性衰减
return sin(TAU * freq * t) * volume * env
)
func _make_noise(duration: float, volume: float) -> AudioStreamWAV:
return _build(duration, func(t: float, d: float) -> float:
var env: float = 1.0 - t / d
return randf_range(-volume, volume) * env
)
func _make_sweep(from_freq: float, to_freq: float, duration: float, volume: float) -> AudioStreamWAV:
return _build(duration, func(t: float, d: float) -> float:
var progress: float = t / d
var f: float = lerp(from_freq, to_freq, progress)
var env: float = (1.0 - progress) * (1.0 - progress) # 平方衰减
return sin(TAU * f * t) * volume * env
)
# 构造一个 AudioStreamWAV;sample_fn(t, duration) 返回 [-1, 1] 的振幅
func _build(duration: float, sample_fn: Callable) -> AudioStreamWAV:
var sample_count: int = int(SAMPLE_RATE * duration)
var data := PackedByteArray()
data.resize(sample_count * 2) # 16-bit 单声道
for i in range(sample_count):
var t: float = float(i) / SAMPLE_RATE
var s: float = clamp(sample_fn.call(t, duration), -1.0, 1.0)
# 16-bit: 范围 [-32768, 32767]
var sample_i: int = int(s * 32767.0)
data[2 * i] = sample_i & 0xFF
data[2 * i + 1] = (sample_i >> 8) & 0xFF
var stream := AudioStreamWAV.new()
stream.format = AudioStreamWAV.FORMAT_16_BITS
stream.mix_rate = SAMPLE_RATE
stream.stereo = false
stream.data = data
return stream
func _play(stream: AudioStream) -> void:
var player := AudioStreamPlayer.new()
player.stream = stream
add_child(player)
player.play()
player.finished.connect(player.queue_free)
+1
View File
@@ -0,0 +1 @@
uid://xy32x5yh4ha6
+79
View File
@@ -0,0 +1,79 @@
extends CharacterBody2D
## 球:未发射时停在挡板上,空格发射后在场地内反弹。
##
## 反弹规则:
## - 撞墙/砖块:镜面反射(用 collision.get_normal())
## - 撞挡板:按击中挡板的横向偏移换算反弹角度——挡板中心=竖直反弹,
## 越靠边缘反弹角越大(打砖块手感的核心)。
## - 球速随击砖次数缓慢上升,到 max_speed 封顶。
signal lost ## 球出界
signal brick_hit(brick) ## 命中砖块(用于计连击/提速)
@export var speed: float = 400.0 ## 初始球速
@export var max_speed: float = 700.0 ## 球速上限
@export var speed_gain_per_hit: float = 4.0 ## 每击碎一砖后提速
@export var spawn_offset_y: float = -24.0 ## 球相对挡板的垂直偏移
@export var launch_angle_deg: float = 60.0 ## 初始发射角度
@export var max_bounce_angle_deg: float = 75.0 ## 挡板最大反弹角(中心绝对值)
@onready var _paddle: CharacterBody2D = get_parent().get_node("Paddle")
var _launched: bool = false
var _current_speed: float = 0.0
func _ready() -> void:
_current_speed = speed
velocity = Vector2.ZERO
func _physics_process(_delta: float) -> void:
if not _launched:
# 跟随挡板
position.x = _paddle.position.x
position.y = _paddle.position.y + spawn_offset_y
if Input.is_key_pressed(KEY_SPACE):
_launch()
return
# 已发射:每帧移动并检测碰撞
var motion: Vector2 = velocity * _delta
var collision: KinematicCollision2D = move_and_collide(motion)
if collision:
var body := collision.get_collider()
# 命中挡板:按击中位置算反弹角度
if body == _paddle:
_bounce_off_paddle(collision)
else:
# 撞墙/砖块:镜面反射
velocity = velocity.bounce(collision.get_normal())
velocity.x += randf_range(-5.0, 5.0)
# 命中砖块:转给 main 并提速
if body and body.is_in_group("bricks"):
brick_hit.emit(body)
_current_speed = min(_current_speed + speed_gain_per_hit, max_speed)
velocity = velocity.normalized() * _current_speed
func _bounce_off_paddle(collision: KinematicCollision2D) -> void:
# 球相对挡板中心的横向偏移,归一化到 [-1, 1]
# 挡板加宽时,半宽从 paddle 实时读
var half_paddle: float = _paddle.get_half_width() if _paddle.has_method("get_half_width") else 48.0
var offset: float = clamp(
(position.x - _paddle.position.x) / half_paddle, -1.0, 1.0
)
# 偏移 → 反弹角(0 = 竖直,max_bounce_angle_deg = 边缘)
var angle_rad: float = deg_to_rad(offset * max_bounce_angle_deg)
# 球只能向上飞(-Y 方向)
velocity = Vector2(sin(angle_rad), -cos(angle_rad)) * _current_speed
func _launch() -> void:
_launched = true
var rad: float = deg_to_rad(launch_angle_deg)
var dir_x: float = [-1.0, 1.0][randi() % 2]
velocity = Vector2(cos(rad) * dir_x, -sin(rad)) * _current_speed
func reset_to_paddle() -> void:
_launched = false
_current_speed = speed
velocity = Vector2.ZERO
position.x = _paddle.position.x
position.y = _paddle.position.y + spawn_offset_y
+1
View File
@@ -0,0 +1 @@
uid://bg7uydgk1mnr6
+53
View File
@@ -0,0 +1,53 @@
extends StaticBody2D
## 砖块:被球撞扣血,归零销毁并发出 destroyed(points) 信号。
##
## 反馈:被击中时闪白 + 缩放抖动;血量越低颜色越亮。
## 击杀时按 power_up_drop_chance 概率额外发 power_up_dropped(位置) 信号。
signal destroyed(points: int)
signal power_up_dropped(spawn_position: Vector2)
@export var max_hp: int = 1
@export var points: int = 100
@export var power_up_drop_chance: float = 0.15 ## 击杀掉落道具的概率
@export var color_by_hp: Dictionary = {
1: Color(0.30, 0.70, 0.40),
2: Color(0.95, 0.75, 0.20),
3: Color(0.85, 0.30, 0.30),
}
var _hp: int
func _ready() -> void:
add_to_group("bricks")
_hp = max_hp
_refresh_color()
func hit() -> void:
_play_hit_feedback()
_hp -= 1
if _hp <= 0:
# 击杀:摇骰子决定是否掉落道具
if randf() < power_up_drop_chance:
power_up_dropped.emit(position)
destroyed.emit(points)
queue_free()
else:
_refresh_color()
func _play_hit_feedback() -> void:
var rect := get_node_or_null("ColorRect") as ColorRect
if not rect:
return
var original_color: Color = rect.color
rect.color = Color(1, 1, 1)
var tween := create_tween()
tween.tween_property(rect, "color", original_color, 0.15)
var original_scale: Vector2 = rect.scale
tween.parallel().tween_property(rect, "scale", original_scale * 1.15, 0.05)
tween.tween_property(rect, "scale", original_scale, 0.1)
func _refresh_color() -> void:
var rect := get_node_or_null("ColorRect")
if rect and color_by_hp.has(_hp):
rect.color = color_by_hp[_hp]
+1
View File
@@ -0,0 +1 @@
uid://pmnr3q4eq1br
+40
View File
@@ -0,0 +1,40 @@
extends Node2D
## 关卡生成器:读 data/level_*.gd 的字符串网格,按坐标实例化砖块。
## 改关卡 = 改 data/ 下的脚本,代码不动。
const BrickScene: PackedScene = preload("res://scenes/brick.tscn")
@export var brick_size: Vector2 = Vector2(96, 32) ## 砖块宽高
@export var origin: Vector2 = Vector2(128, 80) ## 网格左上角起始坐标
@export var char_to_hp: Dictionary = {
"1": 1,
"2": 2,
"3": 3,
"U": -1, # -1 表示不可破坏
}
func load_layout(layout: Array[String]) -> void:
# 清空旧砖块
for child in get_children():
child.queue_free()
for row in range(layout.size()):
var line: String = layout[row]
for col in range(line.length()):
var ch: String = line[col]
if ch == "." or not char_to_hp.has(ch):
continue
_spawn_brick(col, row, ch)
func _spawn_brick(col: int, row: int, ch: String) -> void:
var brick: StaticBody2D = BrickScene.instantiate()
add_child(brick)
brick.position = origin + Vector2(col * brick_size.x, row * brick_size.y)
if char_to_hp[ch] == -1:
# 不可破坏:直接给个高血量占位(M1 先不细分)
brick.max_hp = 9999
brick.points = 0
else:
brick.max_hp = char_to_hp[ch]
brick.points = brick.max_hp * 100
+1
View File
@@ -0,0 +1 @@
uid://db6l7rb1cvbbl
+190
View File
@@ -0,0 +1,190 @@
extends Node2D
## 游戏主控:状态机 + 关卡列表 + 分数/生命权威数据 + 信号连接。
## 状态:MENU / PLAYING / LEVEL_CLEAR / GAME_OVER
##
## 流程:
## MENU → 空格开始 → PLAYING(关卡 1)
## PLAYING → 通关 → LEVEL_CLEAR(空格下一关)
## → 命数归零 → GAME_OVER(空格重开/回菜单)
## LEVEL_CLEAR → 最后一关 → 显示"通关" → 回 MENU
## GAME_OVER → 空格回 MENU
enum GameState { MENU, PLAYING, LEVEL_CLEAR, GAME_OVER }
# 关卡数据(按顺序加载)
const LEVELS: Array = [
preload("res://data/level_1.gd"),
preload("res://data/level_2.gd"),
preload("res://data/level_3.gd"),
]
# 场景切换
const MainMenuScene: PackedScene = preload("res://scenes/ui/main_menu.tscn")
const PowerUpScene: PackedScene = preload("res://scenes/power_up.tscn")
@export var initial_lives: int = 3
@onready var world: Node2D = $World
@onready var level: Node2D = $World/Level
@onready var paddle: CharacterBody2D = $World/Paddle
@onready var ball: CharacterBody2D = $World/Ball
@onready var lose_zone: Area2D = $World/LoseZone
@onready var hud: CanvasLayer = $UI/HUD
@onready var audio: Node = $Audio
@onready var power_ups: Node2D = $World/PowerUps
var state: GameState = GameState.MENU
var score: int = 0
var lives: int = initial_lives
var current_level_index: int = 0
var _remaining_bricks: int = 0
func _ready() -> void:
# 信号连接
ball.lost.connect(_on_ball_lost)
ball.brick_hit.connect(_on_ball_hit_brick)
lose_zone.body_entered.connect(_on_lose_zone_body_entered)
# 初始状态:菜单
_show_menu()
func _process(_delta: float) -> void:
match state:
GameState.MENU:
pass # 菜单场景自己处理输入
GameState.LEVEL_CLEAR:
if Input.is_key_pressed(KEY_SPACE):
_go_to_next_level()
GameState.GAME_OVER:
if Input.is_key_pressed(KEY_SPACE):
_show_menu()
# ───────── 状态切换 ─────────
func _show_menu() -> void:
state = GameState.MENU
clear_power_ups()
world.visible = false
hud.visible = false
# 加载菜单场景作为 UI 子节点(如果还没在)
var menu: Control = $UI.get_node_or_null("MainMenu")
if menu == null:
menu = MainMenuScene.instantiate()
menu.name = "MainMenu"
$UI.add_child(menu)
menu.start_pressed.connect(start_game)
menu.quit_pressed.connect(_on_quit_pressed)
menu.visible = true # 重新显示(可能之前被 hidden)
func _on_quit_pressed() -> void:
get_tree().quit()
func start_game() -> void:
# 主菜单"开始"按钮调用
# 关闭主菜单(先隐藏,再删除)
var menu: Control = $UI.get_node_or_null("MainMenu")
if menu:
menu.visible = false
menu.queue_free()
score = 0
lives = initial_lives
current_level_index = 0
paddle.reset_state()
world.visible = true
hud.visible = true
hud.update_score(score)
hud.update_lives(lives)
_load_current_level()
state = GameState.PLAYING
func _load_current_level() -> void:
clear_power_ups()
paddle.reset_state()
level.load_layout(LEVELS[current_level_index].LAYOUT)
_remaining_bricks = level.get_child_count()
ball.reset_to_paddle()
hud.show_message("Level %d — 按 空格 发球" % (current_level_index + 1))
func _go_to_next_level() -> void:
current_level_index += 1
if current_level_index >= LEVELS.size():
# 通关
_game_complete()
return
_load_current_level()
state = GameState.PLAYING
func _game_complete() -> void:
state = GameState.GAME_OVER
hud.show_message("通关!总分 %d — 按 空格 回主菜单" % score)
func _game_over() -> void:
state = GameState.GAME_OVER
hud.show_message("Game Over — 按 空格 回主菜单")
# ───────── 信号 ─────────
func _on_ball_lost() -> void:
audio.play_lost()
lives -= 1
hud.update_lives(lives)
if lives <= 0:
_game_over()
else:
ball.reset_to_paddle()
hud.show_message("按 空格 重新发球")
func _on_ball_hit_brick(brick: Node) -> void:
if not brick.has_method("hit"):
return
if not brick.destroyed.is_connected(_on_brick_destroyed):
brick.destroyed.connect(_on_brick_destroyed)
if not brick.power_up_dropped.is_connected(_on_brick_power_up_dropped):
brick.power_up_dropped.connect(_on_brick_power_up_dropped)
audio.play_hit()
brick.hit()
func _on_brick_power_up_dropped(spawn_position: Vector2) -> void:
spawn_power_up(spawn_position)
func _on_brick_destroyed(points: int) -> void:
score += points
_remaining_bricks -= 1
hud.update_score(score)
audio.play_break()
if _remaining_bricks <= 0 and state == GameState.PLAYING:
state = GameState.LEVEL_CLEAR
hud.show_message("Level Clear! — 按 空格 进下一关")
audio.play_win()
func _on_lose_zone_body_entered(body: Node) -> void:
if body == ball:
ball.lost.emit()
# ───────── 道具 ─────────
func spawn_power_up(position: Vector2) -> void:
var pu: Area2D = PowerUpScene.instantiate()
power_ups.add_child(pu)
pu.position = position
pu.collected.connect(_on_power_up_collected)
func _on_power_up_collected(kind: String) -> void:
audio.play_break() # 蹭原来的音效;有需要再单独加
match kind:
"wide_paddle":
paddle.widen()
hud.show_message("挡板变宽!")
"multi_ball":
hud.show_message("多球(暂未实现)")
"slow_ball":
hud.show_message("减速(暂未实现)")
"extra_life":
lives += 1
hud.update_lives(lives)
hud.show_message("+1 命")
_:
pass
func clear_power_ups() -> void:
for child in power_ups.get_children():
child.queue_free()
+1
View File
@@ -0,0 +1 @@
uid://dpkxsjlv1g3cq
+80
View File
@@ -0,0 +1,80 @@
extends CharacterBody2D
## 挡板:跟随键盘或鼠标左右移动,支持加宽道具。
## 速度/边界在编辑器里可调。
@export var speed: float = 600.0
@export var use_mouse: bool = true
@export var left_bound: float = 32.0
@export var right_bound: float = 1248.0
@export var default_size: Vector2 = Vector2(96, 16) ## 初始宽高
@export var wide_size: Vector2 = Vector2(160, 16) ## 加宽后尺寸
@export var wide_duration: float = 10.0 ## 加宽持续时间(秒)
@onready var _collision_shape: CollisionShape2D = $CollisionShape2D
@onready var _visual: ColorRect = $ColorRect
@onready var _shape: RectangleShape2D = _collision_shape.shape
var _wide_timer: float = 0.0
var _is_wide_active: bool = false ## 边界是否已被缩过(防止多次加宽累积)
func _ready() -> void:
add_to_group("paddle")
_resize(default_size)
func _physics_process(_delta: float) -> void:
# 加宽倒计时
if _wide_timer > 0.0:
_wide_timer -= _delta
if _wide_timer <= 0.0:
_resize(default_size)
if _is_wide_active:
left_bound -= 32.0
right_bound += 32.0
_is_wide_active = false
var target_x: float = position.x
if use_mouse:
target_x = clamp(get_global_mouse_position().x, left_bound, right_bound)
else:
var dir: float = 0.0
if Input.is_action_pressed("ui_left"):
dir -= 1.0
if Input.is_action_pressed("ui_right"):
dir += 1.0
target_x = position.x + speed * dir * _delta
position.x = clamp(target_x, left_bound, right_bound)
velocity = Vector2.ZERO
func widen() -> void:
if not _is_wide_active:
_resize(wide_size)
left_bound += 32.0
right_bound -= 32.0
_is_wide_active = true
_wide_timer = wide_duration # 续期
func is_wide() -> bool:
return _wide_timer > 0.0
func reset_state() -> void:
# 关卡切换时调用:撤销加宽
if _is_wide_active:
_resize(default_size)
left_bound -= 32.0
right_bound += 32.0
_is_wide_active = false
_wide_timer = 0.0
func get_half_width() -> float:
return _shape.size.x / 2.0
func get_wide_timer() -> float:
return _wide_timer
func _resize(size: Vector2) -> void:
_shape.size = size
_visual.size = size
_visual.position = -size / 2.0 # 中心对齐
+1
View File
@@ -0,0 +1 @@
uid://dt8jfiewca8xk
+53
View File
@@ -0,0 +1,53 @@
extends Area2D
## 道具:Area2D 每帧下落,被挡板接到时发 collected 信号。
## 效果由 main.gd 调度(目前只做"加宽挡板")。
signal collected(kind: String)
enum Kind { WIDE_PADDLE, MULTI_BALL, SLOW_BALL, EXTRA_LIFE }
@export var kind: Kind = Kind.WIDE_PADDLE
@export var fall_speed: float = 150.0
@export var kind_color: Dictionary = {
Kind.WIDE_PADDLE: Color(0.3, 0.9, 0.4),
Kind.MULTI_BALL: Color(0.9, 0.9, 0.3),
Kind.SLOW_BALL: Color(0.4, 0.7, 1.0),
Kind.EXTRA_LIFE: Color(1.0, 0.4, 0.7),
}
@onready var visual: ColorRect = $Visual
@onready var label: Label = $Visual/Label
func _ready() -> void:
# 颜色按类型
if kind_color.has(kind):
visual.color = kind_color[kind]
# 字母标签:W / M / S / L
label.text = _label_for(kind)
func _physics_process(_delta: float) -> void:
position.y += fall_speed * _delta
# 掉到底就消失
if position.y > 730:
queue_free()
func _on_body_entered(body: Node) -> void:
if body.is_in_group("paddle"):
collected.emit(_kind_name())
queue_free()
func _kind_name() -> String:
match kind:
Kind.WIDE_PADDLE: return "wide_paddle"
Kind.MULTI_BALL: return "multi_ball"
Kind.SLOW_BALL: return "slow_ball"
Kind.EXTRA_LIFE: return "extra_life"
return "unknown"
func _label_for(k: Kind) -> String:
match k:
Kind.WIDE_PADDLE: return "W"
Kind.MULTI_BALL: return "M"
Kind.SLOW_BALL: return "S"
Kind.EXTRA_LIFE: return "L"
return "?"
+1
View File
@@ -0,0 +1 @@
uid://bgirk8t8mg4ce
View File
+36
View File
@@ -0,0 +1,36 @@
extends CanvasLayer
## HUD:分数、生命(图标)、提示。接 Main 的方法直接更新。
## 命数图标用 ColorRect 排一排,比纯数字直观。
const LIFE_ICON_SIZE: Vector2 = Vector2(24, 8)
const LIFE_ICON_SPACING: float = 4.0
const LIFE_ICON_COLOR: Color = Color(0.4, 0.6, 1.0)
@onready var score_label: Label = $ScoreLabel
@onready var lives_label: Label = $LivesLabel
@onready var message_label: Label = $MessageLabel
@onready var lives_container: HBoxContainer = $LivesContainer
func _ready() -> void:
show_message("按 空格 发球")
func update_score(score: int) -> void:
score_label.text = "Score: %d" % score
func update_lives(lives: int) -> void:
lives_label.text = "Lives: %d" % lives
_refresh_life_icons(lives)
func show_message(text: String) -> void:
message_label.text = text
func _refresh_life_icons(lives: int) -> void:
# 清空旧图标
for child in lives_container.get_children():
child.queue_free()
# 重新生成 lives 个
for i in range(lives):
var icon := ColorRect.new()
icon.custom_minimum_size = LIFE_ICON_SIZE
icon.color = LIFE_ICON_COLOR
lives_container.add_child(icon)
+1
View File
@@ -0,0 +1 @@
uid://omfhu2gxpqt5
+12
View File
@@ -0,0 +1,12 @@
extends Control
## 主菜单:开始 / 退出。
## 按钮点击通过 signal 调用父节点(main.gd)的方法。
signal start_pressed
signal quit_pressed
func _on_start_pressed() -> void:
start_pressed.emit()
func _on_quit_pressed() -> void:
quit_pressed.emit()
+1
View File
@@ -0,0 +1 @@
uid://b8edba73ci6yf