From 319e75447aaac1e109390b749ad26d65b09227b1 Mon Sep 17 00:00:00 2001 From: Jack Date: Wed, 12 Aug 2026 17:27:11 +0800 Subject: [PATCH] Initial: playable 3-level Breakout with menus, audio, and power-ups --- .gitignore | 58 +++++++++++ CLAUDE.md | 65 ++++++++++++ README.md | 118 ++++++++++++++++++++++ assets/audio/.gitkeep | 0 assets/images/.gitkeep | 0 data/level_1.gd | 8 ++ data/level_1.gd.uid | 1 + data/level_2.gd | 11 +++ data/level_2.gd.uid | 1 + data/level_3.gd | 10 ++ data/level_3.gd.uid | 1 + docs/architecture.md | 155 +++++++++++++++++++++++++++++ docs/design.md | 86 ++++++++++++++++ docs/milestones.md | 104 ++++++++++++++++++++ icon.svg | 9 ++ icon.svg.import | 43 ++++++++ project.godot | 31 ++++++ scenes/ball.tscn | 22 +++++ scenes/brick.tscn | 21 ++++ scenes/lose_zone.tscn | 12 +++ scenes/main.tscn | 62 ++++++++++++ scenes/paddle.tscn | 22 +++++ scenes/power_up.tscn | 32 ++++++ scenes/ui/.gitkeep | 0 scenes/ui/hud.tscn | 36 +++++++ scenes/ui/main_menu.tscn | 68 +++++++++++++ scripts/audio.gd | 66 +++++++++++++ scripts/audio.gd.uid | 1 + scripts/ball.gd | 79 +++++++++++++++ scripts/ball.gd.uid | 1 + scripts/brick.gd | 53 ++++++++++ scripts/brick.gd.uid | 1 + scripts/level.gd | 40 ++++++++ scripts/level.gd.uid | 1 + scripts/main.gd | 190 ++++++++++++++++++++++++++++++++++++ scripts/main.gd.uid | 1 + scripts/paddle.gd | 80 +++++++++++++++ scripts/paddle.gd.uid | 1 + scripts/power_up.gd | 53 ++++++++++ scripts/power_up.gd.uid | 1 + scripts/ui/.gitkeep | 0 scripts/ui/hud.gd | 36 +++++++ scripts/ui/hud.gd.uid | 1 + scripts/ui/main_menu.gd | 12 +++ scripts/ui/main_menu.gd.uid | 1 + 45 files changed, 1594 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 assets/audio/.gitkeep create mode 100644 assets/images/.gitkeep create mode 100644 data/level_1.gd create mode 100644 data/level_1.gd.uid create mode 100644 data/level_2.gd create mode 100644 data/level_2.gd.uid create mode 100644 data/level_3.gd create mode 100644 data/level_3.gd.uid create mode 100644 docs/architecture.md create mode 100644 docs/design.md create mode 100644 docs/milestones.md create mode 100644 icon.svg create mode 100644 icon.svg.import create mode 100644 project.godot create mode 100644 scenes/ball.tscn create mode 100644 scenes/brick.tscn create mode 100644 scenes/lose_zone.tscn create mode 100644 scenes/main.tscn create mode 100644 scenes/paddle.tscn create mode 100644 scenes/power_up.tscn create mode 100644 scenes/ui/.gitkeep create mode 100644 scenes/ui/hud.tscn create mode 100644 scenes/ui/main_menu.tscn create mode 100644 scripts/audio.gd create mode 100644 scripts/audio.gd.uid create mode 100644 scripts/ball.gd create mode 100644 scripts/ball.gd.uid create mode 100644 scripts/brick.gd create mode 100644 scripts/brick.gd.uid create mode 100644 scripts/level.gd create mode 100644 scripts/level.gd.uid create mode 100644 scripts/main.gd create mode 100644 scripts/main.gd.uid create mode 100644 scripts/paddle.gd create mode 100644 scripts/paddle.gd.uid create mode 100644 scripts/power_up.gd create mode 100644 scripts/power_up.gd.uid create mode 100644 scripts/ui/.gitkeep create mode 100644 scripts/ui/hud.gd create mode 100644 scripts/ui/hud.gd.uid create mode 100644 scripts/ui/main_menu.gd create mode 100644 scripts/ui/main_menu.gd.uid diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a80d9d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ +# ───────── Godot 4 专用 ───────── +# 缓存目录(每个开发者本地重新生成,不提交) +.godot/ +.import/ + +# 导出文件 +export/ +build/ +*.translation + +# 临时文件 +*~ +*.bak +*.swp +*.tmp + +# 调试输出 +*.log + +# ───────── 操作系统 ───────── +# macOS +.DS_Store +.AppleDouble +.LSOverride +._* + +# Windows +Thumbs.db +ehthumbs.db +desktop.ini + +# Linux +*~ + +# ───────── IDE / 编辑器 ───────── +# VS Code +.vscode/ +!.vscode/extensions.json + +# JetBrains IDE +.idea/ + +# Trae / Cursor 等 +.cursor/ +.trae/ + +# ───────── 备份 / 临时 ───────── +# 备份 +*.orig +*.rej + +# 调试衍生 +*.pyc +__pycache__/ + +# ───────── 项目特殊 ───────── +# assets/ 里如果用户放了真实素材,自己决定哪些提交 +# (现在目录是空的,等你之后加素材再说) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..daced9b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,65 @@ +# CLAUDE.md + +Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed. + +**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment. + +## 1. Think Before Coding + +**Don't assume. Don't hide confusion. Surface tradeoffs.** + +Before implementing: +- State your assumptions explicitly. If uncertain, ask. +- If multiple interpretations exist, present them - don't pick silently. +- If a simpler approach exists, say so. Push back when warranted. +- If something is unclear, stop. Name what's confusing. Ask. + +## 2. Simplicity First + +**Minimum code that solves the problem. Nothing speculative.** + +- No features beyond what was asked. +- No abstractions for single-use code. +- No "flexibility" or "configurability" that wasn't requested. +- No error handling for impossible scenarios. +- If you write 200 lines and it could be 50, rewrite it. + +Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify. + +## 3. Surgical Changes + +**Touch only what you must. Clean up only your own mess.** + +When editing existing code: +- Don't "improve" adjacent code, comments, or formatting. +- Don't refactor things that aren't broken. +- Match existing style, even if you'd do it differently. +- If you notice unrelated dead code, mention it - don't delete it. + +When your changes create orphans: +- Remove imports/variables/functions that YOUR changes made unused. +- Don't remove pre-existing dead code unless asked. + +The test: Every changed line should trace directly to the user's request. + +## 4. Goal-Driven Execution + +**Define success criteria. Loop until verified.** + +Transform tasks into verifiable goals: +- "Add validation" → "Write tests for invalid inputs, then make them pass" +- "Fix the bug" → "Write a test that reproduces it, then make it pass" +- "Refactor X" → "Ensure tests pass before and after" + +For multi-step tasks, state a brief plan: +``` +1. [Step] → verify: [check] +2. [Step] → verify: [check] +3. [Step] → verify: [check] +``` + +Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification. + +--- + +**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8183e4b --- /dev/null +++ b/README.md @@ -0,0 +1,118 @@ +# My Bricks + +一款用 **Godot 4 + GDScript** 开发的打砖块(Breakout)小游戏。新手练手项目,实现了从核心玩法到内容扩展的完整闭环。 + +--- + +## 操作说明 + +| 操作 | 行为 | +|------|------| +| 鼠标移动 | 挡板左右移动 | +| ← / → | 挡板左右移动(键盘备用) | +| 空格 | 发球 / 进入下一关 / 重新开始 | + +主菜单用 **开始游戏 / 退出** 按钮。 + +--- + +## 玩法 + +- 移动挡板反弹小球,击碎场上所有砖块即可过关 +- 共 **3 关**,逐关递进(普通砖块 → 笑脸 → 金字塔+不可破坏墙柱) +- 砖块有 1/2/3 血,颜色绿/黄/红区分 +- 击碎砖块有 **15% 概率** 掉落道具,掉到底部之前接住生效 +- 球从底部出界扣命,**3 命耗尽** → Game Over + +### 已实现道具 + +| 颜色 | 标签 | 效果 | +|------|------|------| +| 绿 | W | 挡板变宽 10 秒 | +| 粉 | L | +1 命 | +| 黄 | M | 多球(暂未实现) | +| 蓝 | S | 减速(暂未实现) | + +--- + +## 技术要点 + +- **引擎**:Godot 4.x(GL Compatibility 渲染器,桌面/网页通吃) +- **语言**:GDScript +- **分辨率**:1280 × 720 +- **物理层**:Layer 1 = World(挡板/墙),Layer 2 = Brick +- **关卡数据驱动**:`data/level_*.gd` 是字符串网格,改关卡不用改代码 +- **信号解耦**:砖块不知道分数谁管,只广播"我碎了";Main 收到后加分/判定 +- **音效**:用 `AudioStreamWAV` 程序合成(hit/break/lost/win),无外部素材依赖 +- **道具**:Area2D 下落,被挡板接到时发信号 + +--- + +## 目录结构 + +``` +my-bricks/ +├── scenes/ # 场景文件 +│ ├── main.tscn # 主场景 +│ ├── ball.tscn +│ ├── paddle.tscn +│ ├── brick.tscn +│ ├── lose_zone.tscn +│ ├── power_up.tscn # 道具 +│ └── ui/ +│ ├── main_menu.tscn +│ └── hud.tscn +├── scripts/ # 脚本 +│ ├── main.gd # 状态机 + 信号中心 +│ ├── ball.gd # 反弹 + 提速 +│ ├── paddle.gd # 移动 + 加宽 +│ ├── brick.gd # 血量 + 掉落 +│ ├── power_up.gd # 道具 +│ ├── audio.gd # 程序合成音效 +│ ├── level.gd # 关卡生成器 +│ └── ui/ +│ ├── main_menu.gd +│ └── hud.gd +├── data/ # 关卡数据 +│ ├── level_1.gd +│ ├── level_2.gd +│ └── level_3.gd +├── docs/ # 设计文档 +│ ├── architecture.md +│ ├── design.md +│ └── milestones.md +└── assets/ # 预留:图片/音频资源 +``` + +--- + +## 运行 + +1. 安装 [Godot 4 稳定版](https://godotengine.org/) +2. 在 Godot 里 `Import` → 选本目录的 `project.godot` +3. 编辑器里按 `F5` 跑 + +--- + +## 调参 + +所有参数都在对应脚本的 `@export` 里,编辑器里直接拖: + +- 球速:[ball.gd](scripts/ball.gd) `speed` / `max_speed` +- 挡板速度:[paddle.gd](scripts/paddle.gd) `speed` +- 道具掉落概率:[brick.gd](scripts/brick.gd) `power_up_drop_chance` +- 关卡内容:[data/level_*.gd](data/) + +--- + +## 进度 + +按 [docs/milestones.md](docs/milestones.md) 走完 M0~M4: + +| 里程碑 | 内容 | 状态 | +|--------|------|------| +| M0 | 环境搭建 | ✅ | +| M1 | 核心玩法闭环 | ✅ | +| M2 | 手感打磨(反弹角度 / 提速 / 碎砖反馈) | ✅ | +| M3 | 多关卡 + 主菜单 + 音效 + 加宽道具 | ✅ | +| M4 | README + 导出 | ✅ | diff --git a/assets/audio/.gitkeep b/assets/audio/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/assets/images/.gitkeep b/assets/images/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/level_1.gd b/data/level_1.gd new file mode 100644 index 0000000..7793622 --- /dev/null +++ b/data/level_1.gd @@ -0,0 +1,8 @@ +# 关卡 1 布局(字符串网格,level.gd 逐字符读取生成砖块) +# 字符 → 砖块类型: "." = 空位, "1"/"2"/"3" = 1/2/3 血砖块, "U" = 不可破坏 +const LAYOUT: Array[String] = [ + "1111111111", + "2222222222", + "3333333333", + "1.2.3.2.1", +] diff --git a/data/level_1.gd.uid b/data/level_1.gd.uid new file mode 100644 index 0000000..c91aab8 --- /dev/null +++ b/data/level_1.gd.uid @@ -0,0 +1 @@ +uid://clgfywpf03dek diff --git a/data/level_2.gd b/data/level_2.gd new file mode 100644 index 0000000..91d77f0 --- /dev/null +++ b/data/level_2.gd @@ -0,0 +1,11 @@ +# 关卡 2 布局:笑脸形状 +const LAYOUT: Array[String] = [ + "..3333333333..", + ".222222222222.", + "11111....11111", + "111..........1", + "11.1.111.1.1.1", + "11.1.1.1.1.1.1", + "11.111.1.1.1.1", + "1.............1", +] diff --git a/data/level_2.gd.uid b/data/level_2.gd.uid new file mode 100644 index 0000000..773c569 --- /dev/null +++ b/data/level_2.gd.uid @@ -0,0 +1 @@ +uid://bhxh1uoyt8cpv diff --git a/data/level_3.gd b/data/level_3.gd new file mode 100644 index 0000000..12f62a4 --- /dev/null +++ b/data/level_3.gd @@ -0,0 +1,10 @@ +# 关卡 3 布局:金字塔 + 两侧墙柱 +const LAYOUT: Array[String] = [ + "....3333333333....", + "...222222222222...", + "..11111111111111..", + ".11111111111111111", + "111111111111111111", + "U................U", + "U................U", +] diff --git a/data/level_3.gd.uid b/data/level_3.gd.uid new file mode 100644 index 0000000..2ad54ae --- /dev/null +++ b/data/level_3.gd.uid @@ -0,0 +1 @@ +uid://br4fbvks2y820 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..15a0eda --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,155 @@ +# my-bricks 架构设计(Godot 4 + GDScript) + +> 面向完全没接触过 Godot 的新手。原则:**每个脚本只做一件事**,场景之间用信号通信,参数全部可配置。 + +## 1. 技术选型 + +- **引擎**:Godot 4.x 稳定版(4.3 或更新,直接官网下载标准版) +- **语言**:GDScript(Godot 4 内置的是 GDScript 2.0,带类型标注,写起来更像 Python) +- **维度**:2D 场景,不碰 3D +- **版本控制**:Git 可选,但从 M1 开始建议每完成一个里程碑提交一次 + +## 2. 项目目录结构 + +``` +my-bricks/ +├── project.godot # 项目配置文件(自动生成) +├── icon.svg +├── scenes/ # 场景文件(.tscn,在编辑器里搭) +│ ├── main.tscn # 主场景:流程控制 + 游戏世界 + UI +│ ├── ball.tscn +│ ├── paddle.tscn +│ ├── brick.tscn +│ └── ui/ +│ ├── main_menu.tscn +│ └── hud.tscn +├── scripts/ # 脚本(与场景同名,附着在根节点上) +│ ├── main.gd +│ ├── ball.gd +│ ├── paddle.gd +│ ├── brick.gd +│ ├── level.gd +│ └── ui/ +│ ├── main_menu.gd +│ └── hud.gd +├── data/ # 数据驱动的关卡定义 +│ └── level_1.gd +└── assets/ + ├── audio/ + └── images/ +``` + +新手习惯建议:**场景和脚本放不同目录**(scenes/ 与 scripts/),文件名一一对应,找起来不迷路。 + +## 3. 场景树与节点职责 + +主场景结构(每个框 = 一个节点,注释 = 职责): + +``` +Main (Node2D) # main.gd +│ # 游戏状态机、关卡切换、 +│ # 分数与生命的"权威数据" +├── World (Node2D) +│ ├── Level (Node2D) # level.gd:读关卡数据,生成砖块 +│ │ └── Brick * n (StaticBody2D) # brick.gd:血量、被击碎 +│ ├── Paddle (CharacterBody2D) # paddle.gd:输入、移动 +│ ├── Ball (CharacterBody2D) # ball.gd:发射、反弹、出界判定 +│ └── LoseZone (Area2D) # 底部一条"死亡线",球碰触发扣命 +├── Walls (StaticBody2D) # 左右 + 天花板(或代码夹逼,二选一) +└── UI (CanvasLayer) # 界面不受相机影响 + ├── HUD # hud.gd:分数/生命/提示文本 + └── MainMenu # main_menu.gd:开始/退出按钮 +``` + +**职责划分(重要)**:分数、生命、当前关卡**只有 Main 知道**;砖块不知道分数谁管,它只发"我碎了"的信号。这样以后加音效、粒子、成就,都只是多连一个信号,不用改核心逻辑。 + +## 4. 信号设计(解耦的关键) + +用信号代替"到处 get_parent 找对象": + +``` +brick.gd: signal destroyed(points: int) # 砖块被击碎(带上分值) +ball.gd: signal lost # 球出界 + signal brick_hit # 命中砖块(用于计连击/提速) +main.gd: 负责连接以上信号 → 加分、扣命、切关卡 +``` + +典型写法(在 Main 里连接): + +```gdscript +$World/Ball.brick_hit.connect(_on_ball_hit_brick) +$World/Ball.lost.connect(_on_ball_lost) +``` + +**为什么用信号**:`brick` 不需要知道"谁在计分",只广播"我碎了"。Main 收到后加分、判断是否过关。组件互不依赖,新手改起来不容易改坏别处。 + +## 5. 物理层设置(一次配好) + +项目设置 → 物理层,给两层命名: +- **Layer 1 = World**:挡板、墙壁 +- **Layer 2 = Brick**:所有砖块 + +球(Ball)的碰撞 mask 勾选 1 + 2:既能撞挡板/墙,也能撞砖块。 +砖块之间互不碰撞,挡板不会被砖块挡住——物理交互简单可控。 + +## 6. 碰撞方案(新手建议) + +- **球 / 挡板 / 砖块** 用 `CharacterBody2D` + `move_and_collide()`: + - 球在 `_physics_process` 里每帧手动移动,碰撞时读 `collision.get_normal()` 做镜面反射。 + - 优点:速度完全由自己控制,行为可预测、好调试;比 `RigidBody2D`(模拟物理)少很多"球为什么乱飞"的玄学。 +- **出界检测** 用 `Area2D`(LoseZone):Area 是"探测"不是"碰撞",球经过它 `body_entered` 信号触发扣命,最合适。 + +> 新手容易踩的坑:`move_and_collide` 必须在 `_physics_process`(物理帧)里调用,放进 `_process`(渲染帧)会导致表现不稳定。 + +## 7. 关卡数据格式 + +`data/level_1.gd`(用脚本存常量,最简单直观): + +```gdscript +# 字符 → 砖块类型: "." = 空位, "1"/"2"/"3" = 1/2/3 血, "U" = 不可破坏 +const LAYOUT: Array[String] = [ + "1111111111", + "2222222222", + "3333333333", + "1.2.3.2.1", +] +``` + +`level.gd` 逐行逐字符读,按坐标实例化砖块。**改关卡 = 改字符串**,代码一行不用动。 + +## 8. 游戏状态机(简单版) + +Main 里一个枚举 + `match` 分发: + +```gdscript +enum GameState { MENU, PLAYING, LEVEL_CLEAR, GAME_OVER } +var state: GameState = GameState.MENU + +func _process(_delta): + match state: + GameState.PLAYING: + _handle_playing() + GameState.GAME_OVER: + _handle_game_over() +``` + +比散落的 `if game_over: ...` 清晰得多,后期加暂停状态也容易。 + +## 9. 常用新手写法速查 + +| 场景 | 写法 | +|------|------| +| 引用节点 | `@onready var ball = $World/Ball` | +| 编辑器可调参数 | `@export var ball_speed := 400.0` | +| 每帧更新 | `func _process(delta)` 或 `func _physics_process(delta)` | +| 物理移动 | `move_and_collide(velocity * delta)` | +| 连接信号 | `node.signal_name.connect(_on_signal)` | + +## 10. 关键风险与对策 + +| 风险 | 对策 | +|------|------| +| 球撞挡板后直上直下、不可控 | M2 优先做"按击中位置分档反弹角度" | +| 球速太快/太慢导致手感差 | 球速、加速系数全部 `@export`,随时在编辑器调 | +| 一个场景里塞太多逻辑 | 每完成一个功能检查职责是否单一,超标就拆场景/拆脚本 | +| 调试困难 | 球速调慢 + `print()` 打碰撞日志,配合 Godot 断点调试 | diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..de442ce --- /dev/null +++ b/docs/design.md @@ -0,0 +1,86 @@ +# my-bricks 游戏设计文档 + +> 版本:v1(调研阶段)| 引擎:Godot 4 + GDScript | 定位:新手练手小游戏 + +## 1. 一句话概述 + +打砖块(Breakout)类小游戏:玩家控制挡板反弹小球,击碎场上全部砖块即可过关。 +目标是**跑通一个完整的玩法闭环**,同时作为 Godot / GDScript 的入门练习项目。 + +## 2. 设计目标与边界 + +**做:** +- 完整闭环:移动挡板 → 反弹小球 → 击碎砖块 → 得分/过关 → 失败/重开 +- 新手可维护:每个场景/脚本单一职责,场景之间用信号(signal)解耦 +- 数据驱动关卡:关卡用字符串网格定义,改关卡不用改代码 + +**不做(第一版明确排除,防止范围蔓延):** +- 不联网、不做存档系统 +- 不做复杂物理模拟(重力、流体、刚体堆叠等) +- 不做完整美术管线,先用纯色几何图形占位,后期可换 + +## 3. 核心玩法 + +### 3.1 玩家操作 +| 操作 | 行为 | +|------|------| +| ← → / A D | 挡板左右移动 | +| 鼠标移动 | 挡板跟随(可选,二选一) | +| 空格 | 发球 / 重开 / 进入下一关 | + +### 3.2 核心循环 +1. 回合开始:球停在挡板上方,按空格发射 +2. 球在场地内反弹;碰到砖块 → 砖块掉血,击碎则得分 +3. 球从底部出界 → 失去一条命,球重置回到挡板 +4. 生命归零 → Game Over;砖块清空 → 过关进入下一关 + +### 3.3 反弹规则(手感核心) +- **挡板碰撞**:反弹角度由球击中挡板的**横向位置**决定——中心位置竖直反弹,越靠边缘角度越大。这是打砖块手感的核心,优先级最高。 +- **墙壁 / 天花板**:镜面反弹(x 或 y 分量取反)。 +- **砖块**:镜面反弹 + 轻微随机扰动,防止球在水平/垂直路径上"鬼打墙"死循环。 + +### 3.4 砖块 +- 砖块有生命值(1 / 2 / 3 血),用颜色区分。 +- 每击碎一块按类型给分,分数写在砖块数据里。 +- 预留"不可破坏"类型,供后期关卡使用。 + +### 3.5 难度曲线 +- 球速随击砖次数**缓慢上升**(每关内)。 +- 每关基础球速提高一个台阶。 +- 球速/挡板速度都用可配置参数(编辑器里能改),方便调手感。 + +## 4. 道具系统(第二里程碑,非第一版必需) + +- 候选道具:**加宽挡板、多球、穿透球、减速、加命**,先挑 1~2 个实现。 +- 实现思路:击碎砖块有概率掉落,掉落物用 Area2D 向下落,被挡板接到后生效。 +- 设计原则:道具锦上添花,不依赖道具也能正常通关。 + +## 5. 关卡设计 + +- 关卡用「字符串网格」定义:每行一个字符串 = 一行砖块,每个字符映射一种砖块类型。 +- 第一版准备 3~5 个示例关卡,从"整面墙"到"留空位、混合血量"。 +- 具体数据格式见 [architecture.md](architecture.md) 第 7 节。 + +## 6. 界面与流程 + +``` +主菜单 → 游戏(HUD 显示分数/生命/关卡) → 过关提示 → 下一关 + ↘ Game Over → 返回主菜单 +``` + +- 主菜单:开始游戏 / 退出 +- HUD:分数、剩余生命、当前关卡 +- 状态机:`MENU → PLAYING → LEVEL_CLEAR / GAME_OVER` + +## 7. 美术与音频方向 + +- **美术**:纯色/渐变几何图形(ColorRect 或简单 Sprite2D)即可,先不引入图片资源。 +- **音频**:免费素材或程序合成;需要 4 类音效——命中、碎砖、出界、过关。 +- **视觉反馈**(小成本提升手感):碎砖时粒子飞出(GPUParticles2D)或简单 Tween 缩放,命中时球/砖颜色闪动。 + +## 8. 第一版验收标准 + +- [ ] 键盘能完整玩完一关并进入下一关 +- [ ] 生命耗尽出现 Game Over,可重开 +- [ ] HUD 分数与生命显示正确、实时更新 +- [ ] 球与挡板的反弹角度可控、可预期(不出现"一直直上直下") diff --git a/docs/milestones.md b/docs/milestones.md new file mode 100644 index 0000000..cd08d14 --- /dev/null +++ b/docs/milestones.md @@ -0,0 +1,104 @@ +# my-bricks 开发里程碑 + +> 新手学习路径,按"每步都能跑、每步都能看到效果"划分。 +> 时间只是业余时间的大致估算,按你自己的节奏来,不必赶。 + +总原则: +- 每个里程碑结束时,游戏**必须能运行**,绝不攒一堆代码一起调试。 +- 一个里程碑内写坏的代码,当步修好,不留债到下一步。 +- 每完成一个里程碑,`git commit` 一次(从 M1 开始建仓库)。 + +--- + +## M0 环境搭建(约半天) + +**目标**:装好工具,跑通"空场景 → 画面上有个方块"。 + +- [ ] 下载安装 Godot 4 稳定版(官网 godotengine.org,选 4.x 标准版) +- [ ] 新建项目,分辨率设为 1280×720,主场景随便选一个 +- [ ] 场景里放一个 ColorRect(或任意节点),按 F5 运行看到它 +- [ ] 熟悉三个面板:场景树(左)、属性(右)、运行按钮(右上) +- [ ] 打开项目设置 → 物理层,命名 Layer 1 = World、Layer 2 = Brick(给 M1 做准备) + +**验收**:能运行一个空场景,节点能选中、能改属性。 + +**遇到的坑记下来**:装不上/运行不了等环境问题,优先查 Godot 官方文档和社区。 + +--- + +## M1 核心玩法闭环(约 1~2 天) + +**目标**:完成"能玩一局":挡板、球、砖块、计分、生命、Game Over。 + +- [ ] 创建 `paddle.tscn`:CharacterBody2D + CollisionShape2D + 可见图形(ColorRect/Sprite) + - `paddle.gd`:键盘 ←→/AD + 鼠标移动,`@export` 挡板速度 +- [ ] 创建 `ball.tscn`:CharacterBody2D + CollisionShape2D + 图形 + - `ball.gd`:初始停在挡板上(发球状态)→ 空格发射 → `_physics_process` 里 `move_and_collide` + - 撞墙/砖块/挡板:读 `collision.get_normal()` 做镜面反射 +- [ ] 创建 `brick.tscn`:StaticBody2D + CollisionShape2D + ColorRect + - `brick.gd`:`@export` 血量;被撞扣血,归零销毁并发 `destroyed(points)` 信号 +- [ ] `level.gd`:读 `data/level_1.gd` 的字符串网格,按坐标生成一排砖块 +- [ ] `main.tscn` + `main.gd`:组装 World/Paddle/Ball/LoseZone/UI + - LoseZone:屏幕底部一条 Area2D,球经过 `body_entered` → 扣命 + 重置球 + - HUD:分数 Label、生命 Label(先用系统字体,样式后补) + - 状态机:`PLAYING` / `GAME_OVER`(先只做这两个,MENU 留到 M3) +- [ ] 球速、挡板速度、初始命数全部 `@export`,方便调 + +**验收**: +- 能打碎所有砖块,分数正确累加 +- 球出界扣命,命尽出现 Game Over +- 重开后一切重置正常 + +--- + +## M2 手感打磨(约 1 天) + +**目标**:把"好玩"的核心做出来。这是打砖块最值得花时间的一步。 + +- [ ] **挡板反弹角度分档**(核心):按球击中挡板的横向偏移换算反弹角 + - 中心 → 接近竖直;边缘 → 大角度斜飞。公式写一个函数,M1 里先用它替换纯镜面 +- [ ] 球速随击砖次数缓慢上升(设上限),控制单局节奏 +- [ ] 碎砖反馈:GPUParticles2D 飞出碎片,或 Tween 缩放 + 颜色闪动 +- [ ] 命数用图标显示(简单 Sprite 排一排),比纯数字直观 + +**验收**:反弹可控、可预期;连续玩 3 分钟不觉得"球不受我控制"。 + +--- + +## M3 内容扩展(约 1~2 天) + +**目标**:从"一关"变成"一个游戏"。 + +- [ ] 关卡数据驱动化:`data/` 下加 `level_2.gd`、`level_3.gd`,Main 按顺序加载 +- [ ] 过关判定:砖块清空 → `LEVEL_CLEAR` 提示 → 空格/按钮进下一关 +- [ ] 主菜单场景:开始游戏 / 退出(状态机补上 `MENU`) +- [ ] 音效:命中、碎砖、出界、过关 4 个 AudioStreamPlayer2D(素材先用免费 CC0) +- [ ] 道具(选 1~2 个):加宽挡板、多球(难度大可选做) + - 击碎砖块概率掉落 → Area2D 下落 → 挡板接住生效 → 定时器恢复 + +**验收**:从主菜单开始,能连续玩 3 个关卡通关;有音效、有道具。 + +--- + +## M4 收尾发布(约 1 天) + +**目标**:把作品拿得出手。 + +- [ ] 视觉统一:占位图形换成顺眼的配色/贴图(可选,纯色也完全可行) +- [ ] 导出:桌面(macOS/Windows)或 Web(HTML5),导出前按导出模板指引走一遍 +- [ ] 写 README:游戏介绍 + 操作说明(放项目根目录) +- [ ] 按 [design.md](design.md) 第 8 节过一遍验收清单,修掉残留问题 + +**验收**:导出的程序在另一台机器/浏览器能独立运行。 + +--- + +## 里程碑总览 + +| 里程碑 | 内容 | 产物 | +|--------|------|------| +| M0 | 环境搭建 | 能运行的空场景 | +| M1 | 核心玩法闭环 | 能玩一局(无菜单) | +| M2 | 手感打磨 | 反弹可控、有反馈 | +| M3 | 内容扩展 | 菜单 + 多关卡 + 音效 + 道具 | +| M4 | 收尾发布 | 可发布的成品 | diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..e8c55b4 --- /dev/null +++ b/icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/icon.svg.import b/icon.svg.import new file mode 100644 index 0000000..e3835a6 --- /dev/null +++ b/icon.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dyof4hweeqfxb" +path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://icon.svg" +dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..1fcbdb8 --- /dev/null +++ b/project.godot @@ -0,0 +1,31 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=5 + +[application] + +config/name="my-bricks" +run/main_scene="res://scenes/main.tscn" +config/features=PackedStringArray("4.7", "GL Compatibility") +config/icon="res://icon.svg" + +[display] + +window/size/viewport_width=1280 +window/size/viewport_height=720 + +[layer_names] + +2d_physics/layer_1="World" +2d_physics/layer_2="Brick" + +[rendering] + +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" diff --git a/scenes/ball.tscn b/scenes/ball.tscn new file mode 100644 index 0000000..5eccf44 --- /dev/null +++ b/scenes/ball.tscn @@ -0,0 +1,22 @@ +[gd_scene format=3 uid="uid://b0ball00001"] + +[ext_resource type="Script" path="res://scripts/ball.gd" id="1_ball"] + +[sub_resource type="CircleShape2D" id="CircleShape2D_ball"] +radius = 8.0 + +[node name="Ball" type="CharacterBody2D"] +position = Vector2(640, 600) +collision_layer = 0 +collision_mask = 3 +script = ExtResource("1_ball") + +[node name="CollisionShape2D" type="CollisionShape2D" parent="."] +shape = SubResource("CircleShape2D_ball") + +[node name="ColorRect" type="ColorRect" parent="."] +offset_left = -8.0 +offset_top = -8.0 +offset_right = 8.0 +offset_bottom = 8.0 +color = Color(1, 1, 1, 1) diff --git a/scenes/brick.tscn b/scenes/brick.tscn new file mode 100644 index 0000000..45efed2 --- /dev/null +++ b/scenes/brick.tscn @@ -0,0 +1,21 @@ +[gd_scene format=3 uid="uid://b0brick0001"] + +[ext_resource type="Script" path="res://scripts/brick.gd" id="1_brick"] + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_brick"] +size = Vector2(96, 32) + +[node name="Brick" type="StaticBody2D"] +collision_layer = 2 +collision_mask = 0 +script = ExtResource("1_brick") + +[node name="CollisionShape2D" type="CollisionShape2D" parent="."] +shape = SubResource("RectangleShape2D_brick") + +[node name="ColorRect" type="ColorRect" parent="."] +offset_left = -48.0 +offset_top = -16.0 +offset_right = 48.0 +offset_bottom = 16.0 +color = Color(0.3, 0.7, 0.4, 1) diff --git a/scenes/lose_zone.tscn b/scenes/lose_zone.tscn new file mode 100644 index 0000000..554f95e --- /dev/null +++ b/scenes/lose_zone.tscn @@ -0,0 +1,12 @@ +[gd_scene format=3 uid="uid://b0lose0one1"] + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_lose"] +size = Vector2(1280, 20) + +[node name="LoseZone" type="Area2D"] +position = Vector2(640, 710) +collision_layer = 0 +collision_mask = 3 + +[node name="CollisionShape2D" type="CollisionShape2D" parent="."] +shape = SubResource("RectangleShape2D_lose") diff --git a/scenes/main.tscn b/scenes/main.tscn new file mode 100644 index 0000000..36e3a8b --- /dev/null +++ b/scenes/main.tscn @@ -0,0 +1,62 @@ +[gd_scene load_steps=10 format=3 uid="uid://b0main00001"] + +[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"] +[ext_resource type="PackedScene" uid="uid://b0paddle0001" path="res://scenes/paddle.tscn" id="2_paddle"] +[ext_resource type="PackedScene" uid="uid://b0ball00001" path="res://scenes/ball.tscn" id="3_ball"] +[ext_resource type="PackedScene" uid="uid://b0brick0001" path="res://scenes/brick.tscn" id="4_brick"] +[ext_resource type="PackedScene" uid="uid://b0losezone1" path="res://scenes/lose_zone.tscn" id="5_lose"] +[ext_resource type="PackedScene" uid="uid://b0hud000001" path="res://scenes/ui/hud.tscn" id="6_hud"] +[ext_resource type="Script" path="res://scripts/audio.gd" id="7_audio"] +[ext_resource type="Script" path="res://scripts/level.gd" id="8_level"] + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_wall"] +size = Vector2(1280, 20) + +[node name="Main" type="Node2D"] +script = ExtResource("1_main") + +[node name="Background" type="ColorRect" parent="."] +offset_right = 1280.0 +offset_bottom = 720.0 +color = Color(0.1, 0.12, 0.16, 1) +z_index = -10 + +[node name="Audio" type="Node" parent="."] +script = ExtResource("7_audio") + +[node name="World" type="Node2D" parent="."] + +[node name="Level" type="Node2D" parent="World"] +script = ExtResource("8_level") + +[node name="PowerUps" type="Node2D" parent="World"] + +[node name="Paddle" parent="World" instance=ExtResource("2_paddle")] +position = Vector2(640, 640) + +[node name="Ball" parent="World" instance=ExtResource("3_ball")] +position = Vector2(640, 612) + +[node name="LoseZone" parent="World" instance=ExtResource("5_lose")] + +[node name="Walls" type="StaticBody2D" parent="World"] +collision_layer = 1 +collision_mask = 0 + +[node name="Top" type="CollisionShape2D" parent="World/Walls"] +position = Vector2(640, 10) +shape = SubResource("RectangleShape2D_wall") + +[node name="LeftWall" type="CollisionShape2D" parent="World/Walls"] +position = Vector2(10, 360) +rotation = 1.5708 +shape = SubResource("RectangleShape2D_wall") + +[node name="RightWall" type="CollisionShape2D" parent="World/Walls"] +position = Vector2(1270, 360) +rotation = 1.5708 +shape = SubResource("RectangleShape2D_wall") + +[node name="UI" type="CanvasLayer" parent="."] + +[node name="HUD" parent="UI" instance=ExtResource("6_hud")] diff --git a/scenes/paddle.tscn b/scenes/paddle.tscn new file mode 100644 index 0000000..86f0dbc --- /dev/null +++ b/scenes/paddle.tscn @@ -0,0 +1,22 @@ +[gd_scene load_steps=3 format=3 uid="uid://b0paddle0001"] + +[ext_resource type="Script" path="res://scripts/paddle.gd" id="1_paddle"] + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_paddle"] +size = Vector2(96, 16) + +[node name="Paddle" type="CharacterBody2D"] +position = Vector2(640, 640) +collision_layer = 1 +collision_mask = 0 +script = ExtResource("1_paddle") + +[node name="CollisionShape2D" type="CollisionShape2D" parent="."] +shape = SubResource("RectangleShape2D_paddle") + +[node name="ColorRect" type="ColorRect" parent="."] +offset_left = -48.0 +offset_top = -8.0 +offset_right = 48.0 +offset_bottom = 8.0 +color = Color(0.4, 0.6, 1, 1) diff --git a/scenes/power_up.tscn b/scenes/power_up.tscn new file mode 100644 index 0000000..087867f --- /dev/null +++ b/scenes/power_up.tscn @@ -0,0 +1,32 @@ +[gd_scene format=3 uid="uid://b0powerup01"] + +[ext_resource type="Script" path="res://scripts/power_up.gd" id="1_pu"] + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_pu"] +size = Vector2(28, 16) + +[node name="PowerUp" type="Area2D"] +collision_layer = 0 +collision_mask = 1 +script = ExtResource("1_pu") + +[node name="CollisionShape2D" type="CollisionShape2D" parent="."] +shape = SubResource("RectangleShape2D_pu") + +[node name="Visual" type="ColorRect" parent="."] +offset_left = -14.0 +offset_top = -8.0 +offset_right = 14.0 +offset_bottom = 8.0 +color = Color(0.3, 0.9, 0.4, 1) + +[node name="Label" type="Label" parent="Visual"] +offset_left = -14.0 +offset_top = -8.0 +offset_right = 14.0 +offset_bottom = 8.0 +text = "W" +horizontal_alignment = 1 +vertical_alignment = 1 + +[connection signal="body_entered" from="." to="." method="_on_body_entered"] diff --git a/scenes/ui/.gitkeep b/scenes/ui/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/scenes/ui/hud.tscn b/scenes/ui/hud.tscn new file mode 100644 index 0000000..1bb5633 --- /dev/null +++ b/scenes/ui/hud.tscn @@ -0,0 +1,36 @@ +[gd_scene load_steps=2 format=3 uid="uid://b0hud000001"] + +[ext_resource type="Script" path="res://scripts/ui/hud.gd" id="1_hud"] + +[node name="HUD" type="CanvasLayer"] +script = ExtResource("1_hud") + +[node name="ScoreLabel" type="Label" parent="."] +offset_left = 20.0 +offset_top = 20.0 +offset_right = 300.0 +offset_bottom = 60.0 +text = "Score: 0" + +[node name="LivesLabel" type="Label" parent="."] +offset_left = 1000.0 +offset_top = 20.0 +offset_right = 1260.0 +offset_bottom = 60.0 +text = "Lives: 3" +horizontal_alignment = 2 + +[node name="LivesContainer" type="HBoxContainer" parent="."] +offset_left = 900.0 +offset_top = 60.0 +offset_right = 1260.0 +offset_bottom = 80.0 +alignment = 2 + +[node name="MessageLabel" type="Label" parent="."] +offset_left = 0.0 +offset_top = 340.0 +offset_right = 1280.0 +offset_bottom = 380.0 +text = "按 空格 发球" +horizontal_alignment = 1 diff --git a/scenes/ui/main_menu.tscn b/scenes/ui/main_menu.tscn new file mode 100644 index 0000000..611ff9c --- /dev/null +++ b/scenes/ui/main_menu.tscn @@ -0,0 +1,68 @@ +[gd_scene load_steps=2 format=3 uid="uid://b0mainmenu1"] + +[ext_resource type="Script" path="res://scripts/ui/main_menu.gd" id="1_menu"] + +[node name="MainMenu" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_menu") + +[node name="Panel" type="ColorRect" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +color = Color(0.08, 0.1, 0.14, 1) + +[node name="Title" type="Label" parent="."] +layout_mode = 1 +anchors_preset = 10 +anchor_right = 1.0 +offset_top = 180.0 +offset_bottom = 280.0 +text = "MY BRICKS" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="StartButton" type="Button" parent="."] +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -100.0 +offset_top = -40.0 +offset_right = 100.0 +offset_bottom = 0.0 +text = "开始游戏" + +[node name="QuitButton" type="Button" parent="."] +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -100.0 +offset_top = 20.0 +offset_right = 100.0 +offset_bottom = 60.0 +text = "退出" + +[node name="Hint" type="Label" parent="."] +layout_mode = 1 +anchors_preset = 12 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_top = -40.0 +text = "空格 = 发球 / 鼠标移动挡板" +horizontal_alignment = 1 + +[connection signal="pressed" from="StartButton" to="." method="_on_start_pressed"] +[connection signal="pressed" from="QuitButton" to="." method="_on_quit_pressed"] diff --git a/scripts/audio.gd b/scripts/audio.gd new file mode 100644 index 0000000..6faf790 --- /dev/null +++ b/scripts/audio.gd @@ -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) diff --git a/scripts/audio.gd.uid b/scripts/audio.gd.uid new file mode 100644 index 0000000..4e7184c --- /dev/null +++ b/scripts/audio.gd.uid @@ -0,0 +1 @@ +uid://xy32x5yh4ha6 diff --git a/scripts/ball.gd b/scripts/ball.gd new file mode 100644 index 0000000..025a657 --- /dev/null +++ b/scripts/ball.gd @@ -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 diff --git a/scripts/ball.gd.uid b/scripts/ball.gd.uid new file mode 100644 index 0000000..a1823e2 --- /dev/null +++ b/scripts/ball.gd.uid @@ -0,0 +1 @@ +uid://bg7uydgk1mnr6 diff --git a/scripts/brick.gd b/scripts/brick.gd new file mode 100644 index 0000000..436d90c --- /dev/null +++ b/scripts/brick.gd @@ -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] diff --git a/scripts/brick.gd.uid b/scripts/brick.gd.uid new file mode 100644 index 0000000..73c5be7 --- /dev/null +++ b/scripts/brick.gd.uid @@ -0,0 +1 @@ +uid://pmnr3q4eq1br diff --git a/scripts/level.gd b/scripts/level.gd new file mode 100644 index 0000000..0c69a1a --- /dev/null +++ b/scripts/level.gd @@ -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 diff --git a/scripts/level.gd.uid b/scripts/level.gd.uid new file mode 100644 index 0000000..63f4b47 --- /dev/null +++ b/scripts/level.gd.uid @@ -0,0 +1 @@ +uid://db6l7rb1cvbbl diff --git a/scripts/main.gd b/scripts/main.gd new file mode 100644 index 0000000..d1074b6 --- /dev/null +++ b/scripts/main.gd @@ -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() diff --git a/scripts/main.gd.uid b/scripts/main.gd.uid new file mode 100644 index 0000000..aff5398 --- /dev/null +++ b/scripts/main.gd.uid @@ -0,0 +1 @@ +uid://dpkxsjlv1g3cq diff --git a/scripts/paddle.gd b/scripts/paddle.gd new file mode 100644 index 0000000..68ad9e2 --- /dev/null +++ b/scripts/paddle.gd @@ -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 # 中心对齐 diff --git a/scripts/paddle.gd.uid b/scripts/paddle.gd.uid new file mode 100644 index 0000000..21db2b7 --- /dev/null +++ b/scripts/paddle.gd.uid @@ -0,0 +1 @@ +uid://dt8jfiewca8xk diff --git a/scripts/power_up.gd b/scripts/power_up.gd new file mode 100644 index 0000000..dffa54e --- /dev/null +++ b/scripts/power_up.gd @@ -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 "?" diff --git a/scripts/power_up.gd.uid b/scripts/power_up.gd.uid new file mode 100644 index 0000000..597a37d --- /dev/null +++ b/scripts/power_up.gd.uid @@ -0,0 +1 @@ +uid://bgirk8t8mg4ce diff --git a/scripts/ui/.gitkeep b/scripts/ui/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/scripts/ui/hud.gd b/scripts/ui/hud.gd new file mode 100644 index 0000000..ef17ce5 --- /dev/null +++ b/scripts/ui/hud.gd @@ -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) diff --git a/scripts/ui/hud.gd.uid b/scripts/ui/hud.gd.uid new file mode 100644 index 0000000..f33922a --- /dev/null +++ b/scripts/ui/hud.gd.uid @@ -0,0 +1 @@ +uid://omfhu2gxpqt5 diff --git a/scripts/ui/main_menu.gd b/scripts/ui/main_menu.gd new file mode 100644 index 0000000..75da9c3 --- /dev/null +++ b/scripts/ui/main_menu.gd @@ -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() diff --git a/scripts/ui/main_menu.gd.uid b/scripts/ui/main_menu.gd.uid new file mode 100644 index 0000000..c3a8f26 --- /dev/null +++ b/scripts/ui/main_menu.gd.uid @@ -0,0 +1 @@ +uid://b8edba73ci6yf