Initial: playable 3-level Breakout with menus, audio, and power-ups
This commit is contained in:
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://xy32x5yh4ha6
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://bg7uydgk1mnr6
|
||||
@@ -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]
|
||||
@@ -0,0 +1 @@
|
||||
uid://pmnr3q4eq1br
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://db6l7rb1cvbbl
|
||||
+190
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://dpkxsjlv1g3cq
|
||||
@@ -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 # 中心对齐
|
||||
@@ -0,0 +1 @@
|
||||
uid://dt8jfiewca8xk
|
||||
@@ -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 "?"
|
||||
@@ -0,0 +1 @@
|
||||
uid://bgirk8t8mg4ce
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://omfhu2gxpqt5
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://b8edba73ci6yf
|
||||
Reference in New Issue
Block a user